feat(phase-1): implement PostgreSQL + Prisma + Authentication system

Core Features:
- Complete Prisma database schema with all entities (users, orgs, projects, checks, etc.)
- Production-grade authentication service with Argon2 password hashing
- JWT-based session management with HttpOnly cookies
- Comprehensive auth middleware with role-based access control
- RESTful auth API endpoints: register, login, logout, me, refresh
- Database seeding with demo data for development
- Rate limiting on auth endpoints (5 attempts/15min)

Technical Implementation:
- Type-safe authentication with Zod validation
- Proper error handling and logging throughout
- Secure password hashing with Argon2id
- JWT tokens with 7-day expiration
- Database transactions for atomic operations
- Comprehensive middleware for optional/required auth
- Role hierarchy system (MEMBER < ADMIN < OWNER)

Database Schema:
- Users with secure password storage
- Organizations with membership management
- Projects for organizing redirect checks
- Complete audit logging system
- API key management for programmatic access
- Bulk job tracking for future phases

Backward Compatibility:
- All existing endpoints preserved and functional
- No breaking changes to legacy API responses
- New auth system runs alongside existing functionality

Ready for Phase 2: Enhanced redirect tracking with database persistence
This commit is contained in:
Andrei
2025-08-18 07:25:45 +00:00
parent db9e3ef650
commit 459eda89fe
11 changed files with 1364 additions and 1 deletions

View File

@@ -0,0 +1,12 @@
-- Database initialization script for Redirect Intelligence v2
-- This script runs when the PostgreSQL container starts
-- Enable necessary extensions
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE EXTENSION IF NOT EXISTS "citext";
-- Create database if it doesn't exist (handled by Docker environment)
-- The database 'redirect_intelligence' is created by Docker environment variables
-- Grant necessary permissions
GRANT ALL PRIVILEGES ON DATABASE redirect_intelligence TO postgres;

View File

@@ -11,7 +11,8 @@
"db:reset": "prisma migrate reset"
},
"dependencies": {
"@prisma/client": "^5.7.1"
"@prisma/client": "^5.7.1",
"argon2": "^0.31.2"
},
"devDependencies": {
"prisma": "^5.7.1",

View File

@@ -0,0 +1,250 @@
// Redirect Intelligence v2 - Database Schema
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
generator client {
provider = "prisma-client-js"
output = "../../../node_modules/.prisma/client"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model User {
id String @id @default(cuid())
email String @unique
name String
passwordHash String @map("password_hash")
createdAt DateTime @default(now()) @map("created_at")
lastLoginAt DateTime? @map("last_login_at")
memberships OrgMembership[]
auditLogs AuditLog[]
@@map("users")
}
model Organization {
id String @id @default(cuid())
name String
plan String @default("free")
createdAt DateTime @default(now()) @map("created_at")
memberships OrgMembership[]
projects Project[]
apiKeys ApiKey[]
auditLogs AuditLog[]
@@map("organizations")
}
model OrgMembership {
id String @id @default(cuid())
orgId String @map("org_id")
userId String @map("user_id")
role Role
organization Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([orgId, userId])
@@map("org_memberships")
}
model Project {
id String @id @default(cuid())
orgId String @map("org_id")
name String
settingsJson Json @map("settings_json") @default("{}")
createdAt DateTime @default(now()) @map("created_at")
organization Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
checks Check[]
bulkJobs BulkJob[]
@@map("projects")
}
model Check {
id String @id @default(cuid())
projectId String @map("project_id")
inputUrl String @map("input_url")
method String @default("GET")
headersJson Json @map("headers_json") @default("{}")
userAgent String? @map("user_agent")
startedAt DateTime @map("started_at")
finishedAt DateTime? @map("finished_at")
status CheckStatus
finalUrl String? @map("final_url")
totalTimeMs Int? @map("total_time_ms")
reportId String? @map("report_id")
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
hops Hop[]
sslInspections SslInspection[]
seoFlags SeoFlags?
securityFlags SecurityFlags?
reports Report[]
@@index([projectId, startedAt(sort: Desc)])
@@map("checks")
}
model Hop {
id String @id @default(cuid())
checkId String @map("check_id")
hopIndex Int @map("hop_index")
url String
scheme String?
statusCode Int? @map("status_code")
redirectType RedirectType @map("redirect_type")
latencyMs Int? @map("latency_ms")
contentType String? @map("content_type")
reason String?
responseHeadersJson Json @map("response_headers_json") @default("{}")
check Check @relation(fields: [checkId], references: [id], onDelete: Cascade)
@@index([checkId, hopIndex])
@@map("hops")
}
model SslInspection {
id String @id @default(cuid())
checkId String @map("check_id")
host String
validFrom DateTime? @map("valid_from")
validTo DateTime? @map("valid_to")
daysToExpiry Int? @map("days_to_expiry")
issuer String?
protocol String?
warningsJson Json @map("warnings_json") @default("[]")
check Check @relation(fields: [checkId], references: [id], onDelete: Cascade)
@@map("ssl_inspections")
}
model SeoFlags {
id String @id @default(cuid())
checkId String @unique @map("check_id")
robotsTxtStatus String? @map("robots_txt_status")
robotsTxtRulesJson Json @map("robots_txt_rules_json") @default("{}")
metaRobots String? @map("meta_robots")
canonicalUrl String? @map("canonical_url")
sitemapPresent Boolean @default(false) @map("sitemap_present")
noindex Boolean @default(false)
nofollow Boolean @default(false)
check Check @relation(fields: [checkId], references: [id], onDelete: Cascade)
@@map("seo_flags")
}
model SecurityFlags {
id String @id @default(cuid())
checkId String @unique @map("check_id")
safeBrowsingStatus String? @map("safe_browsing_status")
mixedContent MixedContent @map("mixed_content") @default(NONE)
httpsToHttp Boolean @map("https_to_http") @default(false)
check Check @relation(fields: [checkId], references: [id], onDelete: Cascade)
@@map("security_flags")
}
model Report {
id String @id @default(cuid())
checkId String @map("check_id")
markdownPath String? @map("markdown_path")
pdfPath String? @map("pdf_path")
createdAt DateTime @default(now()) @map("created_at")
check Check @relation(fields: [checkId], references: [id], onDelete: Cascade)
@@map("reports")
}
model BulkJob {
id String @id @default(cuid())
projectId String @map("project_id")
uploadPath String @map("upload_path")
status JobStatus
progressJson Json @map("progress_json") @default("{}")
createdAt DateTime @default(now()) @map("created_at")
completedAt DateTime? @map("completed_at")
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
@@map("bulk_jobs")
}
model ApiKey {
id String @id @default(cuid())
orgId String @map("org_id")
name String
tokenHash String @unique @map("token_hash")
permsJson Json @map("perms_json") @default("{}")
rateLimitQuota Int @map("rate_limit_quota") @default(1000)
createdAt DateTime @default(now()) @map("created_at")
organization Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
@@index([tokenHash])
@@map("api_keys")
}
model AuditLog {
id String @id @default(cuid())
orgId String @map("org_id")
actorUserId String? @map("actor_user_id")
action String
entity String
entityId String @map("entity_id")
metaJson Json @map("meta_json") @default("{}")
createdAt DateTime @default(now()) @map("created_at")
organization Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
actor User? @relation(fields: [actorUserId], references: [id], onDelete: SetNull)
@@map("audit_logs")
}
enum Role {
OWNER
ADMIN
MEMBER
}
enum CheckStatus {
OK
ERROR
TIMEOUT
LOOP
}
enum RedirectType {
HTTP_301
HTTP_302
HTTP_307
HTTP_308
META_REFRESH
JS
FINAL
OTHER
}
enum MixedContent {
NONE
PRESENT
FINAL_TO_HTTP
}
enum JobStatus {
QUEUED
RUNNING
DONE
ERROR
}

View File

@@ -0,0 +1,118 @@
/**
* Database Seed Script for Redirect Intelligence v2
*
* This script creates initial data for development and testing
*/
import { PrismaClient, Role } from '@prisma/client';
import argon2 from 'argon2';
const prisma = new PrismaClient();
async function main() {
console.log('🌱 Starting database seed...');
// Create demo user
const demoPassword = await argon2.hash('demo123456');
const demoUser = await prisma.user.upsert({
where: { email: 'demo@redirectintelligence.com' },
update: {},
create: {
email: 'demo@redirectintelligence.com',
name: 'Demo User',
passwordHash: demoPassword,
},
});
console.log('👤 Created demo user:', demoUser.email);
// Create demo organization
const demoOrg = await prisma.organization.upsert({
where: { id: 'demo-org' },
update: {},
create: {
id: 'demo-org',
name: 'Demo Organization',
plan: 'pro',
},
});
console.log('🏢 Created demo organization:', demoOrg.name);
// Create membership
await prisma.orgMembership.upsert({
where: {
orgId_userId: {
orgId: demoOrg.id,
userId: demoUser.id,
},
},
update: {},
create: {
orgId: demoOrg.id,
userId: demoUser.id,
role: Role.OWNER,
},
});
console.log('🤝 Created organization membership');
// Create demo project
const demoProject = await prisma.project.upsert({
where: { id: 'demo-project' },
update: {},
create: {
id: 'demo-project',
name: 'Demo Project',
orgId: demoOrg.id,
settingsJson: {
description: 'Demo project for testing redirect tracking',
defaultMethod: 'GET',
enableSSLAnalysis: true,
enableSEOAnalysis: true,
},
},
});
console.log('📁 Created demo project:', demoProject.name);
// Create a default "anonymous" project for non-authenticated users
const anonymousProject = await prisma.project.upsert({
where: { id: 'anonymous-project' },
update: {},
create: {
id: 'anonymous-project',
name: 'Anonymous Checks',
orgId: demoOrg.id,
settingsJson: {
description: 'Project for anonymous redirect checks',
public: true,
},
},
});
console.log('🌐 Created anonymous project:', anonymousProject.name);
console.log('✅ Database seed completed successfully!');
console.log(`
📝 Demo Credentials:
Email: ${demoUser.email}
Password: demo123456
🔗 You can now:
1. Login with these credentials
2. Create checks in the demo project
3. Test API endpoints with authentication
`);
}
main()
.then(async () => {
await prisma.$disconnect();
})
.catch(async (e) => {
console.error('❌ Error during seed:', e);
await prisma.$disconnect();
process.exit(1);
});