Vercel-only architecture — website + API on one platform #4004
10 changed files+395−2
Addedapp/api/auth/google/route.js+44−0View fileUnifiedSplit
@@ -0,0 +1,44 @@
1import { sql } from '@vercel/postgres'
2import { NextResponse } from 'next/server'
3import bcrypt from 'bcryptjs'
4import { nanoid } from 'nanoid'
5import { initDb } from '../../../../lib/db'
6
7export async function POST(request) {
8 try {
9 await initDb()
10 const { credential } = await request.json()
11 if (!credential) return NextResponse.json({ error: 'No Google credential' }, { status: 400 })
12
13 const verifyResponse = await fetch(`https://oauth2.googleapis.com/tokeninfo?id_token=${credential}`)
14 if (!verifyResponse.ok) return NextResponse.json({ error: 'Invalid Google credential' }, { status: 401 })
15
16 const googleUser = await verifyResponse.json()
17 const googleClientId = process.env.GOOGLE_CLIENT_ID
18 if (googleClientId && googleUser.aud !== googleClientId) {
19 return NextResponse.json({ error: 'Google token not issued for this app' }, { status: 401 })
20 }
21
22 const email = googleUser.email?.toLowerCase()
23 if (!email) return NextResponse.json({ error: 'No email in Google account' }, { status: 400 })
24
25 const { rows } = await sql`SELECT * FROM users WHERE email = ${email}`
26 let user = rows[0]
27
28 if (!user) {
29 const id = nanoid()
30 const hash = await bcrypt.hash(nanoid(32), 10)
31 await sql`INSERT INTO users (id, email, password_hash) VALUES (${id}, ${email}, ${hash})`
32 user = { id, email, plan: 'free' }
33 }
34
35 const token = nanoid(32)
36 const expires = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString()
37 await sql`INSERT INTO sessions (token, user_id, expires_at) VALUES (${token}, ${user.id}, ${expires})`
38
39 return NextResponse.json({ token, plan: user.plan || 'free', email: user.email, name: googleUser.name || '' })
40 } catch (err) {
41 console.error('Google auth error:', err)
42 return NextResponse.json({ error: 'Google sign-in failed' }, { status: 500 })
43 }
44}
Addedapp/api/auth/login/route.js+30−0View fileUnifiedSplit
@@ -0,0 +1,30 @@
1import { sql } from '@vercel/postgres'
2import { NextResponse } from 'next/server'
3import bcrypt from 'bcryptjs'
4import { nanoid } from 'nanoid'
5import { initDb } from '../../../../lib/db'
6
7export async function POST(request) {
8 try {
9 await initDb()
10 const { email, password } = await request.json()
11
12 if (!email || !password) return NextResponse.json({ error: 'Email and password required' }, { status: 400 })
13
14 const { rows } = await sql`SELECT * FROM users WHERE email = ${email.toLowerCase()}`
15 if (rows.length === 0) return NextResponse.json({ error: 'Invalid email or password' }, { status: 401 })
16
17 const user = rows[0]
18 const valid = await bcrypt.compare(password, user.password_hash)
19 if (!valid) return NextResponse.json({ error: 'Invalid email or password' }, { status: 401 })
20
21 const token = nanoid(32)
22 const expires = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString()
23 await sql`INSERT INTO sessions (token, user_id, expires_at) VALUES (${token}, ${user.id}, ${expires})`
24
25 return NextResponse.json({ token, plan: user.plan, email: user.email })
26 } catch (err) {
27 console.error('Login error:', err)
28 return NextResponse.json({ error: 'Login failed. Try again.' }, { status: 500 })
29 }
30}
Addedapp/api/auth/signup/route.js+31−0View fileUnifiedSplit
@@ -0,0 +1,31 @@
1import { sql } from '@vercel/postgres'
2import { NextResponse } from 'next/server'
3import bcrypt from 'bcryptjs'
4import { nanoid } from 'nanoid'
5import { initDb } from '../../../../lib/db'
6
7export async function POST(request) {
8 try {
9 await initDb()
10 const { email, password } = await request.json()
11
12 if (!email || !password) return NextResponse.json({ error: 'Email and password required' }, { status: 400 })
13 if (password.length < 6) return NextResponse.json({ error: 'Password must be at least 6 characters' }, { status: 400 })
14
15 const { rows: existing } = await sql`SELECT id FROM users WHERE email = ${email.toLowerCase()}`
16 if (existing.length > 0) return NextResponse.json({ error: 'Account already exists. Try logging in.' }, { status: 409 })
17
18 const id = nanoid()
19 const hash = await bcrypt.hash(password, 10)
20 await sql`INSERT INTO users (id, email, password_hash) VALUES (${id}, ${email.toLowerCase()}, ${hash})`
21
22 const token = nanoid(32)
23 const expires = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString()
24 await sql`INSERT INTO sessions (token, user_id, expires_at) VALUES (${token}, ${id}, ${expires})`
25
26 return NextResponse.json({ token, plan: 'free', email: email.toLowerCase() })
27 } catch (err) {
28 console.error('Signup error:', err)
29 return NextResponse.json({ error: 'Signup failed. Try again.' }, { status: 500 })
30 }
31}
Addedapp/api/grammar/route.js+52−0View fileUnifiedSplit
@@ -0,0 +1,52 @@
1import { NextResponse } from 'next/server'
2import { authenticate, checkLimit, recordUsage, initDb } from '../../../lib/db'
3
4export async function POST(request) {
5 await initDb()
6 const user = await authenticate(request)
7 if (!user) return NextResponse.json({ error: 'Please sign in' }, { status: 401 })
8
9 const withinLimit = await checkLimit(user, 'grammar')
10 if (!withinLimit) {
11 return NextResponse.json({ error: 'Daily grammar limit reached', upgrade: user.plan === 'free' }, { status: 429 })
12 }
13
14 const { text } = await request.json()
15 if (!text || text.length < 5) return NextResponse.json({ error: 'Text too short' }, { status: 400 })
16 if (text.length > 3000) return NextResponse.json({ error: 'Text too long (max 3000 chars)' }, { status: 400 })
17
18 const claudeKey = process.env.CLAUDE_API_KEY
19 if (!claudeKey) return NextResponse.json({ error: 'Service unavailable' }, { status: 503 })
20
21 try {
22 const response = await fetch('https://api.anthropic.com/v1/messages', {
23 method: 'POST',
24 headers: {
25 'Content-Type': 'application/json',
26 'x-api-key': claudeKey,
27 'anthropic-version': '2023-06-01',
28 },
29 body: JSON.stringify({
30 model: 'claude-haiku-4-5-20251001',
31 max_tokens: 512,
32 system: 'You are a grammar checker. Analyze the text for grammar, spelling, and punctuation errors. Return a JSON array of corrections. Each correction has: "original" (wrong text), "corrected" (fixed text), "reason" (brief, max 8 words). If correct, return []. Return ONLY valid JSON.',
33 messages: [{ role: 'user', content: text }],
34 }),
35 })
36
37 if (!response.ok) return NextResponse.json({ error: 'Grammar check failed' }, { status: 502 })
38
39 const data = await response.json()
40 const content = data.content?.[0]?.text?.trim() || '[]'
41 const jsonStr = content.replace(/^```json?\n?/, '').replace(/\n?```$/, '').trim()
42
43 let corrections
44 try { corrections = JSON.parse(jsonStr) } catch { corrections = [] }
45
46 await recordUsage(user.user_id, 'grammar', data.usage?.output_tokens || 0)
47 return NextResponse.json({ corrections })
48 } catch (err) {
49 console.error('Grammar check error:', err)
50 return NextResponse.json({ error: 'Grammar check failed' }, { status: 500 })
51 }
52}
Addedapp/api/health/route.js+5−0View fileUnifiedSplit
@@ -0,0 +1,5 @@
1import { NextResponse } from 'next/server'
2
3export async function GET() {
4 return NextResponse.json({ status: 'ok', version: '1.1.0' })
5}
Addedapp/api/rewrite/route.js+58−0View fileUnifiedSplit
@@ -0,0 +1,58 @@
1import { NextResponse } from 'next/server'
2import { authenticate, checkLimit, recordUsage, getVoiceContext, initDb } from '../../../lib/db'
3
4export async function POST(request) {
5 await initDb()
6 const user = await authenticate(request)
7 if (!user) return NextResponse.json({ error: 'Please sign in' }, { status: 401 })
8
9 const withinLimit = await checkLimit(user, 'rewrite')
10 if (!withinLimit) {
11 return NextResponse.json({ error: 'Daily rewrite limit reached', upgrade: user.plan === 'free' }, { status: 429 })
12 }
13
14 const { text, mode = 'professional', preserveVoice = true } = await request.json()
15 if (!text) return NextResponse.json({ error: 'No text provided' }, { status: 400 })
16
17 const claudeKey = process.env.CLAUDE_API_KEY
18 if (!claudeKey) return NextResponse.json({ error: 'Service unavailable' }, { status: 503 })
19
20 const voiceContext = (preserveVoice && user.plan !== 'free') ? await getVoiceContext(user.user_id) : ''
21
22 const prompts = {
23 professional: 'Rewrite this dictated text into clean, professional prose. Fix grammar, remove filler words. Keep original meaning. Return ONLY the rewritten text.',
24 casual: 'Clean up this dictated text into natural, casual writing. Fix errors but keep the conversational tone. Return ONLY the cleaned text.',
25 concise: 'Rewrite this to be as concise as possible. Remove all filler and redundancy. Return ONLY the rewritten text.',
26 email: 'Rewrite this as a professional email with greeting and sign-off. Return ONLY the email text.',
27 slack: 'Clean up for a Slack message. Keep it casual and brief. Return ONLY the message.',
28 code: 'Convert this spoken description into a clear technical request or code comment. Return ONLY the formatted text.',
29 }
30
31 try {
32 const response = await fetch('https://api.anthropic.com/v1/messages', {
33 method: 'POST',
34 headers: {
35 'Content-Type': 'application/json',
36 'x-api-key': claudeKey,
37 'anthropic-version': '2023-06-01',
38 },
39 body: JSON.stringify({
40 model: 'claude-sonnet-4-20250514',
41 max_tokens: 1024,
42 system: (prompts[mode] || prompts.professional) + voiceContext,
43 messages: [{ role: 'user', content: text }],
44 }),
45 })
46
47 if (!response.ok) return NextResponse.json({ error: 'Rewrite failed' }, { status: 502 })
48
49 const data = await response.json()
50 const rewritten = data.content?.[0]?.text?.trim() || text
51
52 await recordUsage(user.user_id, 'rewrite', data.usage?.output_tokens || 0)
53 return NextResponse.json({ text: rewritten })
54 } catch (err) {
55 console.error('Rewrite error:', err)
56 return NextResponse.json({ error: 'Rewrite failed' }, { status: 500 })
57 }
58}
Addedapp/api/usage/route.js+34−0View fileUnifiedSplit
@@ -0,0 +1,34 @@
1import { NextResponse } from 'next/server'
2import { sql } from '@vercel/postgres'
3import { authenticate, initDb } from '../../../lib/db'
4
5export async function GET(request) {
6 await initDb()
7 const user = await authenticate(request)
8 if (!user) return NextResponse.json({ error: 'Please sign in' }, { status: 401 })
9
10 const limits = {
11 free: { grammar_per_day: 10, voice_minutes_per_month: 60, rewrite_per_day: 5 },
12 pro: { grammar_per_day: 999999, voice_minutes_per_month: 999999, rewrite_per_day: 999999 },
13 business: { grammar_per_day: 999999, voice_minutes_per_month: 999999, rewrite_per_day: 999999 },
14 }
15
16 const plan = limits[user.plan] || limits.free
17
18 const { rows: grammarRows } = await sql`
19 SELECT COUNT(*) as count FROM usage WHERE user_id = ${user.user_id} AND type = 'grammar' AND created_at > NOW() - INTERVAL '1 day'
20 `
21 const { rows: rewriteRows } = await sql`
22 SELECT COUNT(*) as count FROM usage WHERE user_id = ${user.user_id} AND type = 'rewrite' AND created_at > NOW() - INTERVAL '1 day'
23 `
24 const { rows: voiceRows } = await sql`
25 SELECT COUNT(*) as count FROM usage WHERE user_id = ${user.user_id} AND type = 'voice' AND created_at > NOW() - INTERVAL '30 days'
26 `
27
28 return NextResponse.json({
29 plan: user.plan,
30 grammar: { used: grammarRows[0]?.count || 0, limit: plan.grammar_per_day },
31 rewrite: { used: rewriteRows[0]?.count || 0, limit: plan.rewrite_per_day },
32 voice: { used: voiceRows[0]?.count || 0, limit: plan.voice_minutes_per_month },
33 })
34}
Modifiedextension/grammar.js+1−1View fileUnifiedSplit
@@ -17,7 +17,7 @@
1717 let lastCheckedText = ''
1818 let activeTooltip = null
1919
20 const API_BASE = 'https://api.48co.nz' // managed API proxy
20 const API_BASE = 'https://48co.nz/api' // managed API (runs on same domain as website)
2121
2222 // ── Init: load settings ────────────────────────────────
2323 chrome.storage.local.get(['grammarEnabled', 'authToken', 'claudeApiKey'], (data) => {
Addedlib/db.js+136−0View fileUnifiedSplit
@@ -0,0 +1,136 @@
1/**
2 * 48co Database Layer
3 *
4 * Uses Vercel Postgres (@vercel/postgres) for production.
5 * All database operations go through this module.
6 */
7import { sql } from '@vercel/postgres'
8
9// ── Initialize tables on first use ──────────────────────
10let initialized = false
11
12export async function initDb() {
13 if (initialized) return
14 initialized = true
15
16 await sql`
17 CREATE TABLE IF NOT EXISTS users (
18 id TEXT PRIMARY KEY,
19 email TEXT UNIQUE NOT NULL,
20 password_hash TEXT NOT NULL,
21 plan TEXT DEFAULT 'free',
22 created_at TIMESTAMP DEFAULT NOW(),
23 stripe_customer_id TEXT
24 )
25 `
26
27 await sql`
28 CREATE TABLE IF NOT EXISTS sessions (
29 token TEXT PRIMARY KEY,
30 user_id TEXT NOT NULL REFERENCES users(id),
31 created_at TIMESTAMP DEFAULT NOW(),
32 expires_at TIMESTAMP NOT NULL
33 )
34 `
35
36 await sql`
37 CREATE TABLE IF NOT EXISTS usage (
38 id SERIAL PRIMARY KEY,
39 user_id TEXT NOT NULL REFERENCES users(id),
40 type TEXT NOT NULL,
41 created_at TIMESTAMP DEFAULT NOW(),
42 tokens_used INTEGER DEFAULT 0
43 )
44 `
45
46 await sql`
47 CREATE TABLE IF NOT EXISTS voice_samples (
48 id SERIAL PRIMARY KEY,
49 user_id TEXT NOT NULL REFERENCES users(id),
50 content TEXT NOT NULL,
51 source TEXT DEFAULT 'manual',
52 created_at TIMESTAMP DEFAULT NOW()
53 )
54 `
55
56 await sql`CREATE INDEX IF NOT EXISTS idx_usage_user_date ON usage(user_id, created_at)`
57 await sql`CREATE INDEX IF NOT EXISTS idx_sessions_token ON sessions(token)`
58 await sql`CREATE INDEX IF NOT EXISTS idx_voice_samples_user ON voice_samples(user_id)`
59}
60
61// ── Auth helpers ─────────────────────────────────────────
62export async function authenticate(request) {
63 const authHeader = request.headers.get('authorization')
64 const token = authHeader?.replace('Bearer ', '')
65 if (!token) return null
66
67 await initDb()
68
69 const { rows } = await sql`
70 SELECT s.*, u.id as user_id, u.email, u.plan
71 FROM sessions s
72 JOIN users u ON s.user_id = u.id
73 WHERE s.token = ${token} AND s.expires_at > NOW()
74 `
75
76 return rows[0] || null
77}
78
79// ── Usage tracking ───────────────────────────────────────
80const LIMITS = {
81 free: { grammar_per_day: 10, voice_minutes_per_month: 60, rewrite_per_day: 5 },
82 pro: { grammar_per_day: 999999, voice_minutes_per_month: 999999, rewrite_per_day: 999999 },
83 business: { grammar_per_day: 999999, voice_minutes_per_month: 999999, rewrite_per_day: 999999 },
84 enterprise: { grammar_per_day: 999999, voice_minutes_per_month: 999999, rewrite_per_day: 999999 },
85}
86
87export async function checkLimit(user, type) {
88 const limits = LIMITS[user.plan] || LIMITS.free
89
90 if (type === 'grammar') {
91 const { rows } = await sql`
92 SELECT COUNT(*) as count FROM usage
93 WHERE user_id = ${user.user_id} AND type = 'grammar'
94 AND created_at > NOW() - INTERVAL '1 day'
95 `
96 return (rows[0]?.count || 0) < limits.grammar_per_day
97 }
98
99 if (type === 'rewrite') {
100 const { rows } = await sql`
101 SELECT COUNT(*) as count FROM usage
102 WHERE user_id = ${user.user_id} AND type = 'rewrite'
103 AND created_at > NOW() - INTERVAL '1 day'
104 `
105 return (rows[0]?.count || 0) < limits.rewrite_per_day
106 }
107
108 if (type === 'voice') {
109 const { rows } = await sql`
110 SELECT COUNT(*) as count FROM usage
111 WHERE user_id = ${user.user_id} AND type = 'voice'
112 AND created_at > NOW() - INTERVAL '30 days'
113 `
114 return (rows[0]?.count || 0) < limits.voice_minutes_per_month
115 }
116
117 return false
118}
119
120export async function recordUsage(userId, type, tokens = 0) {
121 await sql`INSERT INTO usage (user_id, type, tokens_used) VALUES (${userId}, ${type}, ${tokens})`
122}
123
124// ── Voice samples (Preserve My Voice) ────────────────────
125export async function getVoiceContext(userId) {
126 const { rows } = await sql`
127 SELECT content FROM voice_samples
128 WHERE user_id = ${userId}
129 ORDER BY created_at DESC LIMIT 10
130 `
131
132 if (rows.length === 0) return ''
133
134 const excerpts = rows.map(s => s.content.substring(0, 300)).join('\n---\n')
135 return `\n\nIMPORTANT: The user has a specific writing voice. Here are examples of how they naturally write. Preserve their tone, word choice, sentence structure, and personality. Do NOT make it sound generic or corporate:\n\n${excerpts}`
136}
Modifiedpackage.json+4−1View fileUnifiedSplit
@@ -11,7 +11,10 @@
1111 "dependencies": {
1212 "next": "14.2.0",
1313 "react": "^18.3.0",
14 "react-dom": "^18.3.0"
14 "react-dom": "^18.3.0",
15 "@vercel/postgres": "^0.10.0",
16 "bcryptjs": "^2.4.3",
17 "nanoid": "^5.0.0"
1518 },
1619 "devDependencies": {
1720 "tailwindcss": "^3.4.0",
1821
c comment · e edit title · m merge · a approve · r request changes · ? shortcuts