@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.
@@ -0,0 +1,78 @@
1
+ import { getPlanLimits, type Plan } from '@doccov/db';
2
+ import type { Context, MiddlewareHandler, Next } from 'hono';
3
+ import { db } from '../db/client';
4
+
5
+ const usageStore = new Map<string, { count: number; resetAt: number }>();
6
+
7
+ setInterval(() => {
8
+ const now = Date.now();
9
+ usageStore.forEach((entry, key) => {
10
+ if (now > entry.resetAt) usageStore.delete(key);
11
+ });
12
+ }, 60_000).unref();
13
+
14
+ /**
15
+ * Rate limit by org plan
16
+ * Requires apiKey middleware to run first
17
+ */
18
+ export function orgRateLimit(): MiddlewareHandler {
19
+ return async (c: Context, next: Next) => {
20
+ const org = c.get('org');
21
+ if (!org) return c.json({ error: 'Auth required' }, 401);
22
+
23
+ const limits = getPlanLimits(org.plan as Plan);
24
+ const dailyLimit = limits.analysesPerDay;
25
+
26
+ // Unlimited for enterprise
27
+ if (dailyLimit === Infinity) {
28
+ await next();
29
+ return;
30
+ }
31
+
32
+ const key = `org:${org.id}`;
33
+ const now = Date.now();
34
+ const dayMs = 24 * 60 * 60 * 1000;
35
+
36
+ let entry = usageStore.get(key);
37
+ if (!entry || now > entry.resetAt) {
38
+ entry = { count: 0, resetAt: now + dayMs };
39
+ usageStore.set(key, entry);
40
+ }
41
+
42
+ if (entry.count >= dailyLimit) {
43
+ c.header('X-RateLimit-Limit', String(dailyLimit));
44
+ c.header('X-RateLimit-Remaining', '0');
45
+ c.header('X-RateLimit-Reset', String(Math.ceil(entry.resetAt / 1000)));
46
+
47
+ return c.json(
48
+ {
49
+ error: 'Daily analysis limit exceeded',
50
+ limit: dailyLimit,
51
+ plan: org.plan,
52
+ resetAt: new Date(entry.resetAt).toISOString(),
53
+ upgrade: org.plan === 'team' ? 'https://doccov.com/pricing' : undefined,
54
+ },
55
+ 429,
56
+ );
57
+ }
58
+
59
+ entry.count++;
60
+
61
+ c.header('X-RateLimit-Limit', String(dailyLimit));
62
+ c.header('X-RateLimit-Remaining', String(dailyLimit - entry.count));
63
+ c.header('X-RateLimit-Reset', String(Math.ceil(entry.resetAt / 1000)));
64
+
65
+ // Track in DB (async, don't block)
66
+ db.insertInto('usage_records')
67
+ .values({
68
+ id: crypto.randomUUID(),
69
+ orgId: org.id,
70
+ feature: 'analysis',
71
+ count: 1,
72
+ })
73
+ .execute()
74
+ .catch(console.error);
75
+
76
+ await next();
77
+ };
78
+ }
@@ -0,0 +1,127 @@
1
+ import { Hono } from 'hono';
2
+ import { nanoid } from 'nanoid';
3
+ import { auth } from '../auth/config';
4
+ import { db } from '../db/client';
5
+ import { generateApiKey } from '../utils/api-keys';
6
+
7
+ export const apiKeysRoute = new Hono();
8
+
9
+ // List keys for org
10
+ apiKeysRoute.get('/', async (c) => {
11
+ const orgId = c.req.query('orgId');
12
+ if (!orgId) return c.json({ error: 'orgId required' }, 400);
13
+
14
+ const session = await auth.api.getSession({ headers: c.req.raw.headers });
15
+ if (!session) return c.json({ error: 'Unauthorized' }, 401);
16
+
17
+ const membership = await db
18
+ .selectFrom('org_members')
19
+ .where('orgId', '=', orgId)
20
+ .where('userId', '=', session.user.id)
21
+ .select('role')
22
+ .executeTakeFirst();
23
+
24
+ if (!membership) return c.json({ error: 'Not a member' }, 403);
25
+
26
+ const keys = await db
27
+ .selectFrom('api_keys')
28
+ .where('orgId', '=', orgId)
29
+ .select(['id', 'name', 'keyPrefix', 'lastUsedAt', 'expiresAt', 'createdAt'])
30
+ .orderBy('createdAt', 'desc')
31
+ .execute();
32
+
33
+ return c.json({ keys });
34
+ });
35
+
36
+ // Create key (paid only)
37
+ apiKeysRoute.post('/', async (c) => {
38
+ const { orgId, name, expiresIn } = await c.req.json<{
39
+ orgId: string;
40
+ name: string;
41
+ expiresIn?: number;
42
+ }>();
43
+
44
+ if (!orgId || !name) return c.json({ error: 'orgId and name required' }, 400);
45
+
46
+ const session = await auth.api.getSession({ headers: c.req.raw.headers });
47
+ if (!session) return c.json({ error: 'Unauthorized' }, 401);
48
+
49
+ const membership = await db
50
+ .selectFrom('org_members')
51
+ .innerJoin('organizations', 'organizations.id', 'org_members.orgId')
52
+ .where('org_members.orgId', '=', orgId)
53
+ .where('org_members.userId', '=', session.user.id)
54
+ .where('org_members.role', 'in', ['owner', 'admin'])
55
+ .select(['org_members.role', 'organizations.plan'])
56
+ .executeTakeFirst();
57
+
58
+ if (!membership) return c.json({ error: 'Admin access required' }, 403);
59
+
60
+ if (membership.plan === 'free') {
61
+ return c.json(
62
+ {
63
+ error: 'API keys require a paid plan',
64
+ upgrade: 'https://doccov.com/pricing',
65
+ },
66
+ 403,
67
+ );
68
+ }
69
+
70
+ const { key, hash, prefix } = generateApiKey();
71
+ const id = nanoid(21);
72
+ const expiresAt = expiresIn ? new Date(Date.now() + expiresIn * 24 * 60 * 60 * 1000) : null;
73
+
74
+ await db
75
+ .insertInto('api_keys')
76
+ .values({
77
+ id,
78
+ orgId,
79
+ name,
80
+ keyHash: hash,
81
+ keyPrefix: prefix,
82
+ expiresAt,
83
+ })
84
+ .execute();
85
+
86
+ return c.json(
87
+ {
88
+ id,
89
+ key, // Shown once!
90
+ name,
91
+ prefix,
92
+ expiresAt,
93
+ message: 'Save this key now. It cannot be retrieved again.',
94
+ },
95
+ 201,
96
+ );
97
+ });
98
+
99
+ // Revoke key
100
+ apiKeysRoute.delete('/:keyId', async (c) => {
101
+ const keyId = c.req.param('keyId');
102
+
103
+ const session = await auth.api.getSession({ headers: c.req.raw.headers });
104
+ if (!session) return c.json({ error: 'Unauthorized' }, 401);
105
+
106
+ const key = await db
107
+ .selectFrom('api_keys')
108
+ .where('id', '=', keyId)
109
+ .select(['id', 'orgId'])
110
+ .executeTakeFirst();
111
+
112
+ if (!key) return c.json({ error: 'Key not found' }, 404);
113
+
114
+ const membership = await db
115
+ .selectFrom('org_members')
116
+ .where('orgId', '=', key.orgId)
117
+ .where('userId', '=', session.user.id)
118
+ .where('role', 'in', ['owner', 'admin'])
119
+ .select('role')
120
+ .executeTakeFirst();
121
+
122
+ if (!membership) return c.json({ error: 'Admin access required' }, 403);
123
+
124
+ await db.deleteFrom('api_keys').where('id', '=', keyId).execute();
125
+
126
+ return c.json({ deleted: true });
127
+ });
@@ -0,0 +1,62 @@
1
+ import { Hono } from 'hono';
2
+ import { auth } from '../auth/config';
3
+ import { createPersonalOrg } from '../auth/hooks';
4
+ import { db } from '../db/client';
5
+
6
+ export const authRoute = new Hono();
7
+
8
+ // Custom routes BEFORE better-auth catch-all
9
+
10
+ // Get current session with orgs
11
+ authRoute.get('/session', async (c) => {
12
+ const session = await auth.api.getSession({ headers: c.req.raw.headers });
13
+
14
+ if (!session) {
15
+ return c.json({ user: null, session: null, organizations: [] });
16
+ }
17
+
18
+ // Get user's orgs
19
+ const memberships = await db
20
+ .selectFrom('org_members')
21
+ .innerJoin('organizations', 'organizations.id', 'org_members.orgId')
22
+ .where('org_members.userId', '=', session.user.id)
23
+ .select([
24
+ 'organizations.id',
25
+ 'organizations.name',
26
+ 'organizations.slug',
27
+ 'organizations.plan',
28
+ 'organizations.isPersonal',
29
+ 'org_members.role',
30
+ ])
31
+ .execute();
32
+
33
+ return c.json({
34
+ user: session.user,
35
+ session: session.session,
36
+ organizations: memberships,
37
+ });
38
+ });
39
+
40
+ // Webhook for post-signup actions
41
+ authRoute.post('/webhook/user-created', async (c) => {
42
+ const { userId, email, name } = await c.req.json();
43
+
44
+ // Check if user already has an org
45
+ const existingMembership = await db
46
+ .selectFrom('org_members')
47
+ .where('userId', '=', userId)
48
+ .select('id')
49
+ .executeTakeFirst();
50
+
51
+ if (!existingMembership) {
52
+ await createPersonalOrg(userId, name, email);
53
+ }
54
+
55
+ return c.json({ ok: true });
56
+ });
57
+
58
+ // Better Auth handles all other routes
59
+ authRoute.on(['GET', 'POST'], '/*', async (c) => {
60
+ const response = await auth.handler(c.req.raw);
61
+ return response;
62
+ });
@@ -0,0 +1,202 @@
1
+ import { CustomerPortal, Webhooks } from '@polar-sh/hono';
2
+ import { Hono } from 'hono';
3
+ import { auth } from '../auth/config';
4
+ import { db } from '../db/client';
5
+
6
+ export const billingRoute = new Hono();
7
+
8
+ const POLAR_ACCESS_TOKEN = process.env.POLAR_ACCESS_TOKEN!;
9
+ const POLAR_WEBHOOK_SECRET = process.env.POLAR_WEBHOOK_SECRET!;
10
+ const SITE_URL = process.env.SITE_URL || 'http://localhost:3000';
11
+ const API_URL = process.env.API_URL || 'http://localhost:3001';
12
+
13
+ const PRODUCTS = {
14
+ team: process.env.POLAR_PRODUCT_TEAM!,
15
+ pro: process.env.POLAR_PRODUCT_PRO!,
16
+ };
17
+
18
+ const POLAR_API =
19
+ process.env.NODE_ENV === 'production' ? 'https://api.polar.sh' : 'https://sandbox-api.polar.sh';
20
+
21
+ // ============ Checkout ============
22
+ billingRoute.get('/checkout', async (c) => {
23
+ const plan = c.req.query('plan') as 'team' | 'pro';
24
+ const orgId = c.req.query('orgId');
25
+
26
+ if (!plan || !PRODUCTS[plan]) {
27
+ return c.json({ error: 'Invalid plan' }, 400);
28
+ }
29
+ if (!orgId) {
30
+ return c.json({ error: 'orgId required' }, 400);
31
+ }
32
+
33
+ const session = await auth.api.getSession({ headers: c.req.raw.headers });
34
+ if (!session) {
35
+ return c.redirect(`${SITE_URL}/login?callbackUrl=/pricing`);
36
+ }
37
+
38
+ // Verify user is owner/admin of org
39
+ const membership = await db
40
+ .selectFrom('org_members')
41
+ .where('orgId', '=', orgId)
42
+ .where('userId', '=', session.user.id)
43
+ .where('role', 'in', ['owner', 'admin'])
44
+ .select('id')
45
+ .executeTakeFirst();
46
+
47
+ if (!membership) {
48
+ return c.json({ error: 'Not authorized' }, 403);
49
+ }
50
+
51
+ // Create Polar checkout session via API
52
+ const res = await fetch(`${POLAR_API}/v1/checkouts/`, {
53
+ method: 'POST',
54
+ headers: {
55
+ Authorization: `Bearer ${POLAR_ACCESS_TOKEN}`,
56
+ 'Content-Type': 'application/json',
57
+ },
58
+ body: JSON.stringify({
59
+ products: [PRODUCTS[plan]],
60
+ success_url: `${SITE_URL}/dashboard?upgraded=true`,
61
+ metadata: { orgId, plan },
62
+ customer_email: session.user.email,
63
+ }),
64
+ });
65
+
66
+ if (!res.ok) {
67
+ const error = await res.text();
68
+ console.error('Polar checkout error:', error);
69
+ return c.json({ error: 'Failed to create checkout session' }, 500);
70
+ }
71
+
72
+ const checkout = await res.json();
73
+ return c.redirect(checkout.url);
74
+ });
75
+
76
+ // ============ Customer Portal ============
77
+ billingRoute.get('/portal', async (c) => {
78
+ const orgId = c.req.query('orgId');
79
+ if (!orgId) return c.json({ error: 'orgId required' }, 400);
80
+
81
+ const session = await auth.api.getSession({ headers: c.req.raw.headers });
82
+ if (!session) return c.redirect(`${SITE_URL}/login`);
83
+
84
+ const org = await db
85
+ .selectFrom('organizations')
86
+ .innerJoin('org_members', 'org_members.orgId', 'organizations.id')
87
+ .where('organizations.id', '=', orgId)
88
+ .where('org_members.userId', '=', session.user.id)
89
+ .where('org_members.role', 'in', ['owner', 'admin'])
90
+ .select(['organizations.polarCustomerId'])
91
+ .executeTakeFirst();
92
+
93
+ if (!org?.polarCustomerId) {
94
+ return c.json({ error: 'No billing account found' }, 404);
95
+ }
96
+
97
+ const portalHandler = CustomerPortal({
98
+ accessToken: POLAR_ACCESS_TOKEN,
99
+ getCustomerId: async () => org.polarCustomerId!,
100
+ server: process.env.NODE_ENV === 'production' ? 'production' : 'sandbox',
101
+ });
102
+
103
+ return portalHandler(c);
104
+ });
105
+
106
+ // ============ Webhooks ============
107
+ billingRoute.post(
108
+ '/webhook',
109
+ Webhooks({
110
+ webhookSecret: POLAR_WEBHOOK_SECRET,
111
+
112
+ onSubscriptionActive: async (payload) => {
113
+ const subscription = payload.data;
114
+ const metadata = subscription.metadata as { orgId?: string; plan?: string };
115
+
116
+ if (!metadata?.orgId || !metadata?.plan) {
117
+ console.error('Missing metadata:', subscription.id);
118
+ return;
119
+ }
120
+
121
+ await db
122
+ .updateTable('organizations')
123
+ .set({
124
+ plan: metadata.plan as 'team' | 'pro',
125
+ polarCustomerId: subscription.customerId,
126
+ polarSubscriptionId: subscription.id,
127
+ })
128
+ .where('id', '=', metadata.orgId)
129
+ .execute();
130
+
131
+ console.log(`Org ${metadata.orgId} upgraded to ${metadata.plan}`);
132
+ },
133
+
134
+ onSubscriptionCanceled: async (payload) => {
135
+ const subscription = payload.data;
136
+
137
+ const org = await db
138
+ .selectFrom('organizations')
139
+ .where('polarSubscriptionId', '=', subscription.id)
140
+ .select('id')
141
+ .executeTakeFirst();
142
+
143
+ if (org) {
144
+ await db
145
+ .updateTable('organizations')
146
+ .set({ plan: 'free' })
147
+ .where('id', '=', org.id)
148
+ .execute();
149
+ }
150
+ },
151
+
152
+ onSubscriptionRevoked: async (payload) => {
153
+ const subscription = payload.data;
154
+
155
+ const org = await db
156
+ .selectFrom('organizations')
157
+ .where('polarSubscriptionId', '=', subscription.id)
158
+ .select('id')
159
+ .executeTakeFirst();
160
+
161
+ if (org) {
162
+ await db
163
+ .updateTable('organizations')
164
+ .set({ plan: 'free', polarSubscriptionId: null })
165
+ .where('id', '=', org.id)
166
+ .execute();
167
+ }
168
+ },
169
+ }),
170
+ );
171
+
172
+ // ============ Status ============
173
+ billingRoute.get('/status', async (c) => {
174
+ const orgId = c.req.query('orgId');
175
+ if (!orgId) return c.json({ error: 'orgId required' }, 400);
176
+
177
+ const session = await auth.api.getSession({ headers: c.req.raw.headers });
178
+ if (!session) return c.json({ error: 'Unauthorized' }, 401);
179
+
180
+ const org = await db
181
+ .selectFrom('organizations')
182
+ .innerJoin('org_members', 'org_members.orgId', 'organizations.id')
183
+ .where('organizations.id', '=', orgId)
184
+ .where('org_members.userId', '=', session.user.id)
185
+ .select([
186
+ 'organizations.plan',
187
+ 'organizations.polarCustomerId',
188
+ 'organizations.polarSubscriptionId',
189
+ 'organizations.aiCallsUsed',
190
+ 'organizations.aiCallsResetAt',
191
+ ])
192
+ .executeTakeFirst();
193
+
194
+ if (!org) return c.json({ error: 'Organization not found' }, 404);
195
+
196
+ return c.json({
197
+ plan: org.plan,
198
+ hasSubscription: !!org.polarSubscriptionId,
199
+ usage: { aiCalls: org.aiCallsUsed, resetAt: org.aiCallsResetAt },
200
+ portalUrl: org.polarCustomerId ? `${API_URL}/billing/portal?orgId=${orgId}` : null,
201
+ });
202
+ });