feat: bootstrap admin auth, DB schema, and encrypted API keys

- Add Prisma schema for AdminUser, Source, Article, SearchRule, SearchRun,
  and encrypted ApiKey storage.
- Implement JWT session middleware and admin login/logout.
- Add AES-256-GCM encryption helpers for API keys.
- Seed default news sources and admin credentials.
- Create admin shell layout and login page.
This commit is contained in:
hermes
2026-06-17 04:30:56 +00:00
parent b77d0c73cd
commit e086141620
18 changed files with 1785 additions and 8 deletions

127
prisma/schema.prisma Normal file
View File

@@ -0,0 +1,127 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "sqlite"
url = env("DATABASE_URL")
}
model AdminUser {
id String @id @default(cuid())
username String @unique
passwordHash String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Source {
id String @id @default(cuid())
name String
baseUrl String?
apiCode String @unique
defaultBias Bias?
credibilityScore Float @default(5)
active Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
articles Article[]
}
model Article {
id String @id @default(cuid())
title String
summary String?
url String @unique
sourceId String
source Source @relation(fields: [sourceId], references: [id])
imageUrl String?
publishedAt DateTime?
fetchedAt DateTime @default(now())
primaryBias Bias
biasConfidence Float @default(0)
politicalTags String @default("[]")
rankScore Float @default(0)
credibilityScore Float @default(5)
status ArticleStatus @default(DRAFT)
topics String @default("[]")
rawData String @default("{}")
aiExplanation String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([primaryBias, status])
@@index([status, publishedAt])
@@index([rankScore])
}
model ApiKey {
id String @id @default(cuid())
provider ApiProvider
encryptedKey String
label String?
isActive Boolean @default(true)
lastUsedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([provider, isActive])
}
model SearchRule {
id String @id @default(cuid())
name String
query String
sources String @default("[]")
schedule String @default("0 */6 * * *")
maxResults Int @default(20)
autoPublish Boolean @default(false)
minRankScore Float @default(5)
isActive Boolean @default(true)
aiProvider ApiProvider @default(KIMI)
aiModel String?
lastRunAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
runs SearchRun[]
}
model SearchRun {
id String @id @default(cuid())
ruleId String
rule SearchRule @relation(fields: [ruleId], references: [id], onDelete: Cascade)
status RunStatus @default(RUNNING)
stage String? // e.g. "FETCHING", "ANALYZING", "SAVING"
progress String? // human-readable progress, e.g. "3 of 12"
articlesFound Int @default(0)
error String?
startedAt DateTime @default(now())
endedAt DateTime?
}
enum Bias {
LEFT
CENTER
RIGHT
}
enum ArticleStatus {
DRAFT
PUBLISHED
ARCHIVED
}
enum ApiProvider {
NEWSAPI
GNEWS
KIMI
WEBSEARCH
NEWSDATA
BRAVE
}
enum RunStatus {
RUNNING
SUCCESS
ERROR
}

44
prisma/seed.ts Normal file
View File

@@ -0,0 +1,44 @@
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
async function main() {
const sources = [
{ name: 'NewsAPI', apiCode: 'newsapi', baseUrl: 'https://newsapi.org', credibilityScore: 7 },
{ name: 'GNews', apiCode: 'gnews', baseUrl: 'https://gnews.io', credibilityScore: 7 },
{ name: 'NewsData', apiCode: 'newsdata', baseUrl: 'https://newsdata.io', credibilityScore: 7 },
{ name: 'Web Search', apiCode: 'websearch', baseUrl: null, credibilityScore: 5 },
{ name: 'Brave Search', apiCode: 'brave', baseUrl: 'https://search.brave.com', credibilityScore: 6 },
];
for (const source of sources) {
await prisma.source.upsert({
where: { apiCode: source.apiCode },
update: {},
create: source,
});
}
const adminUsername = process.env.ADMIN_USERNAME || 'admin';
const adminPasswordHash = process.env.ADMIN_PASSWORD_HASH;
if (adminPasswordHash) {
await prisma.adminUser.upsert({
where: { username: adminUsername },
update: { passwordHash: adminPasswordHash },
create: { username: adminUsername, passwordHash: adminPasswordHash },
});
console.log(`Seeded admin user: ${adminUsername}`);
}
console.log('Seeded default sources.');
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});