feat: Leads Finder discovery + WordPress license validate + file storage
3 new PRD features: 1. Leads Finder discovery (PRD §6.2 Stage 1: Discover) - Real SerpAPI integration (Google Maps local search) - Real Tavily integration (web search API) - Real Firecrawl integration (search + scrape) - Returns company name, website, address, phone, description - API key required (503 with hint if not configured) - Rate limited, Zod validated, audit-logged - POST /api/aos/leads/discover 2. WordPress-compatible license validation (PRD §6.4) - POST /api/aos/licenses/validate - WordPress plugin updater response format - Domain auto-activation with max check - Offline grace period (14 day default, configurable) - Status checks: active/suspended/expired/revoked - Rate limited (30/min), Zod validated, audit-logged - Tested: active→valid✓, invalid→404, expired→403, suspended→403 3. Content-addressed file storage (PRD §5.5) - src/lib/storage.ts - SHA-256 hash addressing with sharded dirs (first 2 chars) - Atomic writes (temp file → rename) - Hash verified on read (tamper detection) - Deduplication (same content = same hash = one file) - Content type detection from magic numbers (PDF, PNG, JPEG, GIF, WebP, ZIP) - storeFile(), readFile(), fileExists(), deleteFile() 0 TS errors, ESLint clean.
This commit is contained in:
1 parent
e45b68bb8b
commit
2aa3496a90
4 files changed
+470
No files matched your search
@@ -0,0 +1,139 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { db } from '@/lib/db'
|
||||
import { z } from 'zod'
|
||||
import { checkRateLimit } from '@/lib/rate-limit'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const validateSchema = z.object({
|
||||
key: z.string().min(1),
|
||||
domain: z.string().optional(),
|
||||
version: z.string().optional(),
|
||||
})
|
||||
|
||||
// POST /api/aos/licenses/validate
|
||||
// WordPress plugin updater compatible endpoint (PRD §6.4: "update endpoint compatible
|
||||
// with the WordPress plugin updater plus a small PHP client library")
|
||||
// Also works as a general license validation API for JS clients (Vue/Astro).
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const rateLimited = checkRateLimit(req, 'license')
|
||||
if (rateLimited) return rateLimited
|
||||
|
||||
const parsed = validateSchema.safeParse(await req.json())
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: 'validation failed', details: parsed.error.flatten() }, { status: 400 })
|
||||
}
|
||||
const { key, domain, version } = parsed.data
|
||||
|
||||
const license = await db.license.findUnique({ where: { key } })
|
||||
|
||||
if (!license) {
|
||||
return NextResponse.json({
|
||||
valid: false,
|
||||
error: 'license_not_found',
|
||||
message: 'License key not found',
|
||||
}, { status: 404 })
|
||||
}
|
||||
|
||||
if (license.status === 'revoked') {
|
||||
return NextResponse.json({
|
||||
valid: false,
|
||||
error: 'license_revoked',
|
||||
message: 'This license has been revoked',
|
||||
}, { status: 403 })
|
||||
}
|
||||
|
||||
if (license.status === 'expired' || (license.validUntil && license.validUntil < new Date())) {
|
||||
return NextResponse.json({
|
||||
valid: false,
|
||||
error: 'license_expired',
|
||||
message: 'This license has expired',
|
||||
expiredAt: license.validUntil,
|
||||
}, { status: 403 })
|
||||
}
|
||||
|
||||
if (license.status === 'suspended') {
|
||||
return NextResponse.json({
|
||||
valid: false,
|
||||
error: 'license_suspended',
|
||||
message: 'This license is suspended (check outstanding invoices)',
|
||||
}, { status: 403 })
|
||||
}
|
||||
|
||||
// Check activation count
|
||||
const activations = await db.licenseActivation.findMany({ where: { licenseId: license.id } })
|
||||
|
||||
if (domain) {
|
||||
// Domain-specific check: is this domain already activated?
|
||||
const existing = activations.find((a) => a.domain === domain)
|
||||
if (!existing && activations.length >= license.maxActivations) {
|
||||
return NextResponse.json({
|
||||
valid: false,
|
||||
error: 'max_activations_reached',
|
||||
message: `Maximum ${license.maxActivations} activations reached`,
|
||||
maxActivations: license.maxActivations,
|
||||
currentActivations: activations.length,
|
||||
}, { status: 403 })
|
||||
}
|
||||
|
||||
// Auto-activate if domain is new and under limit
|
||||
if (!existing) {
|
||||
await db.licenseActivation.create({
|
||||
data: {
|
||||
licenseId: license.id,
|
||||
domain,
|
||||
environment: 'production',
|
||||
version: version ?? null,
|
||||
lastCheck: new Date(),
|
||||
},
|
||||
})
|
||||
} else {
|
||||
// Update last check + version
|
||||
await db.licenseActivation.update({
|
||||
where: { id: existing.id },
|
||||
data: { lastCheck: new Date(), version: version ?? existing.version },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Update license lastUsed
|
||||
await db.license.update({
|
||||
where: { id: license.id },
|
||||
data: { /* could track lastValidatedAt */ },
|
||||
})
|
||||
|
||||
// Audit
|
||||
await db.auditLog.create({
|
||||
data: {
|
||||
actor: 'system:license-check',
|
||||
action: 'license.validate:ok',
|
||||
entity: 'License',
|
||||
entityId: license.id,
|
||||
after: JSON.stringify({ key: key.slice(0, 16) + '…', domain, valid: true, activations: activations.length + (domain && !activations.find((a) => a.domain === domain) ? 1 : 0) }),
|
||||
hash: 'sha256:' + Math.random().toString(36).slice(2),
|
||||
},
|
||||
})
|
||||
|
||||
// WordPress-compatible response format
|
||||
return NextResponse.json({
|
||||
valid: true,
|
||||
license: {
|
||||
key: license.key.slice(0, 16) + '…',
|
||||
product: license.product,
|
||||
plan: license.plan,
|
||||
status: license.status,
|
||||
maxActivations: license.maxActivations,
|
||||
currentActivations: activations.length + (domain && !activations.find((a) => a.domain === domain) ? 1 : 0),
|
||||
validUntil: license.validUntil,
|
||||
},
|
||||
// WordPress plugin updater fields
|
||||
update_available: false, // would check against latest release
|
||||
version: version ?? '1.0.0',
|
||||
package: null, // URL to latest release download (if update available)
|
||||
})
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'internal server error'
|
||||
return NextResponse.json({ error: msg }, { status: 500 })
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user