mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:21:09 +02:00
refactor: consolidate codebase — remove archived code, deduplicate packages, standardize middleware
- Delete 17 server-archived/ directories (consolidated into apps/api/) - Delete apps-archived/ (clock, wisekeep) and services-archived/ (it-landing, ollama-metrics-proxy) - Fix type safety: replace all `any` casts in cross-app-queries.ts with proper Local* types - Remove duplicate shared-auth-stores package (identical copy of shared-auth-ui/stores/) - Remove duplicate theme store from shared-stores (canonical version in shared-theme) - Migrate memoro-server rate-limiter to shared-hono/rateLimitMiddleware - Migrate uload-server JWT auth + error handler to shared-hono (authMiddleware, errorHandler) - Migrate arcade-server error handling to shared-hono - Merge shared-profile-ui and shared-app-onboarding into shared-ui - Unify /clock route into /times/clock, remove redirect stubs Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
7ee57b7afd
commit
d8ce4eaf34
309 changed files with 172 additions and 21667 deletions
|
|
@ -1,24 +0,0 @@
|
|||
{
|
||||
"name": "@news/server",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "bun run --watch src/index.ts",
|
||||
"start": "bun run src/index.ts",
|
||||
"type-check": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mozilla/readability": "^0.5.0",
|
||||
"drizzle-orm": "^0.44.7",
|
||||
"hono": "^4.7.0",
|
||||
"jose": "^6.1.2",
|
||||
"jsdom": "^25.0.0",
|
||||
"postgres": "^3.4.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "^1.2.0",
|
||||
"@types/jsdom": "^21.1.0",
|
||||
"typescript": "^5.0.0"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
export interface Config {
|
||||
port: number;
|
||||
databaseUrl: string;
|
||||
manaAuthUrl: string;
|
||||
cors: { origins: string[] };
|
||||
}
|
||||
|
||||
export function loadConfig(): Config {
|
||||
const requiredEnv = (key: string, fallback?: string): string => {
|
||||
const value = process.env[key] || fallback;
|
||||
if (!value) throw new Error(`Missing required env var: ${key}`);
|
||||
return value;
|
||||
};
|
||||
|
||||
return {
|
||||
port: parseInt(process.env.PORT || '3071', 10),
|
||||
databaseUrl: requiredEnv(
|
||||
'DATABASE_URL',
|
||||
'postgresql://manacore:devpassword@localhost:5432/mana_sync'
|
||||
),
|
||||
manaAuthUrl: requiredEnv('MANA_CORE_AUTH_URL', 'http://localhost:3001'),
|
||||
cors: {
|
||||
origins: (process.env.CORS_ORIGINS || 'http://localhost:5173').split(','),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
import { drizzle } from 'drizzle-orm/postgres-js';
|
||||
import postgres from 'postgres';
|
||||
|
||||
let db: ReturnType<typeof drizzle> | null = null;
|
||||
|
||||
export function getDb(databaseUrl: string) {
|
||||
if (!db) {
|
||||
const client = postgres(databaseUrl, { max: 10 });
|
||||
db = drizzle(client);
|
||||
}
|
||||
return db;
|
||||
}
|
||||
|
||||
export type Database = ReturnType<typeof getDb>;
|
||||
|
|
@ -1,46 +0,0 @@
|
|||
import { Hono } from 'hono';
|
||||
import { cors } from 'hono/cors';
|
||||
import { loadConfig } from './config';
|
||||
import { getDb } from './db/connection';
|
||||
import { errorHandler } from './middleware/error-handler';
|
||||
import { jwtAuth } from './middleware/jwt-auth';
|
||||
import { ExtractService } from './services/extract';
|
||||
import { FeedService } from './services/feed';
|
||||
import { healthRoutes } from './routes/health';
|
||||
import { createExtractRoutes } from './routes/extract';
|
||||
import { createFeedRoutes } from './routes/feed';
|
||||
|
||||
const config = loadConfig();
|
||||
const db = getDb(config.databaseUrl);
|
||||
|
||||
const extractService = new ExtractService();
|
||||
const feedService = new FeedService(db);
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
app.onError(errorHandler);
|
||||
app.use('*', cors({ origin: config.cors.origins, credentials: true }));
|
||||
|
||||
// Public routes (no auth)
|
||||
app.route('/health', healthRoutes);
|
||||
app.route('/api/v1/feed', createFeedRoutes(feedService));
|
||||
|
||||
// Preview extraction (public)
|
||||
app.post('/api/v1/extract/preview', async (c) => {
|
||||
const { url } = await c.req.json<{ url: string }>();
|
||||
if (!url) return c.json({ error: 'URL is required' }, 400);
|
||||
const article = await extractService.extractFromUrl(url);
|
||||
return c.json(article);
|
||||
});
|
||||
|
||||
// Protected routes (auth required)
|
||||
app.use('/api/v1/extract/save', jwtAuth(config.manaAuthUrl));
|
||||
app.route('/api/v1/extract', createExtractRoutes(extractService));
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`news-server starting on port ${config.port}...`);
|
||||
|
||||
export default {
|
||||
port: config.port,
|
||||
fetch: app.fetch,
|
||||
};
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
import { HTTPException } from 'hono/http-exception';
|
||||
|
||||
export class NotFoundError extends HTTPException {
|
||||
constructor(message = 'Not found') {
|
||||
super(404, { message });
|
||||
}
|
||||
}
|
||||
|
||||
export class BadRequestError extends HTTPException {
|
||||
constructor(message = 'Bad request') {
|
||||
super(400, { message });
|
||||
}
|
||||
}
|
||||
|
||||
export class UnauthorizedError extends HTTPException {
|
||||
constructor(message = 'Unauthorized') {
|
||||
super(401, { message });
|
||||
}
|
||||
}
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
import type { ErrorHandler } from 'hono';
|
||||
import { HTTPException } from 'hono/http-exception';
|
||||
|
||||
export const errorHandler: ErrorHandler = (err, c) => {
|
||||
if (err instanceof HTTPException) {
|
||||
return c.json({ statusCode: err.status, message: err.message }, err.status);
|
||||
}
|
||||
|
||||
console.error('Unhandled error:', err);
|
||||
return c.json({ statusCode: 500, message: 'Internal server error' }, 500);
|
||||
};
|
||||
|
|
@ -1,46 +0,0 @@
|
|||
import type { MiddlewareHandler } from 'hono';
|
||||
import { createRemoteJWKSet, jwtVerify } from 'jose';
|
||||
import { UnauthorizedError } from '../lib/errors';
|
||||
|
||||
export interface AuthUser {
|
||||
userId: string;
|
||||
email: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
let jwks: ReturnType<typeof createRemoteJWKSet> | null = null;
|
||||
|
||||
function getJwks(authUrl: string) {
|
||||
if (!jwks) {
|
||||
jwks = createRemoteJWKSet(new URL('/api/auth/jwks', authUrl));
|
||||
}
|
||||
return jwks;
|
||||
}
|
||||
|
||||
export function jwtAuth(authUrl: string): MiddlewareHandler {
|
||||
return async (c, next) => {
|
||||
const authHeader = c.req.header('Authorization');
|
||||
if (!authHeader?.startsWith('Bearer ')) {
|
||||
throw new UnauthorizedError('Missing or invalid Authorization header');
|
||||
}
|
||||
|
||||
const token = authHeader.slice(7);
|
||||
try {
|
||||
const { payload } = await jwtVerify(token, getJwks(authUrl), {
|
||||
issuer: authUrl,
|
||||
audience: 'manacore',
|
||||
});
|
||||
|
||||
const user: AuthUser = {
|
||||
userId: payload.sub || '',
|
||||
email: (payload.email as string) || '',
|
||||
role: (payload.role as string) || 'user',
|
||||
};
|
||||
|
||||
c.set('user', user);
|
||||
await next();
|
||||
} catch {
|
||||
throw new UnauthorizedError('Invalid or expired token');
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -1,38 +0,0 @@
|
|||
import { Hono } from 'hono';
|
||||
import type { ExtractService } from '../services/extract';
|
||||
import type { AuthUser } from '../middleware/jwt-auth';
|
||||
import { BadRequestError } from '../lib/errors';
|
||||
|
||||
export function createExtractRoutes(extractService: ExtractService) {
|
||||
return new Hono<{ Variables: { user: AuthUser } }>()
|
||||
.post('/preview', async (c) => {
|
||||
const { url } = await c.req.json<{ url: string }>();
|
||||
if (!url) throw new BadRequestError('URL is required');
|
||||
|
||||
const article = await extractService.extractFromUrl(url);
|
||||
return c.json(article);
|
||||
})
|
||||
.post('/save', async (c) => {
|
||||
const { url } = await c.req.json<{ url: string }>();
|
||||
if (!url) throw new BadRequestError('URL is required');
|
||||
|
||||
const extracted = await extractService.extractFromUrl(url);
|
||||
|
||||
// Return extracted data — client saves to local-first store
|
||||
return c.json({
|
||||
id: crypto.randomUUID(),
|
||||
type: 'saved',
|
||||
sourceOrigin: 'user_saved',
|
||||
originalUrl: url,
|
||||
title: extracted.title,
|
||||
content: extracted.content,
|
||||
htmlContent: extracted.htmlContent,
|
||||
excerpt: extracted.excerpt,
|
||||
author: extracted.byline,
|
||||
siteName: extracted.siteName,
|
||||
wordCount: extracted.wordCount,
|
||||
readingTimeMinutes: extracted.readingTimeMinutes,
|
||||
isArchived: false,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
import { Hono } from 'hono';
|
||||
import type { FeedService } from '../services/feed';
|
||||
|
||||
export function createFeedRoutes(feedService: FeedService) {
|
||||
return new Hono()
|
||||
.get('/', async (c) => {
|
||||
const type = c.req.query('type');
|
||||
const categoryId = c.req.query('categoryId');
|
||||
const limit = parseInt(c.req.query('limit') || '20', 10);
|
||||
const offset = parseInt(c.req.query('offset') || '0', 10);
|
||||
|
||||
const articles = await feedService.getArticles({ type, categoryId, limit, offset });
|
||||
return c.json(articles);
|
||||
})
|
||||
.get('/:id', async (c) => {
|
||||
const id = c.req.param('id');
|
||||
const article = await feedService.getArticleById(id);
|
||||
if (!article) return c.json({ error: 'Article not found' }, 404);
|
||||
return c.json(article);
|
||||
});
|
||||
}
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
import { Hono } from 'hono';
|
||||
|
||||
export const healthRoutes = new Hono().get('/', (c) =>
|
||||
c.json({
|
||||
status: 'ok',
|
||||
service: 'news-server',
|
||||
runtime: 'bun',
|
||||
timestamp: new Date().toISOString(),
|
||||
})
|
||||
);
|
||||
|
|
@ -1,50 +0,0 @@
|
|||
import { Readability } from '@mozilla/readability';
|
||||
import { JSDOM } from 'jsdom';
|
||||
|
||||
export interface ExtractedArticle {
|
||||
title: string;
|
||||
content: string;
|
||||
htmlContent: string;
|
||||
excerpt: string;
|
||||
byline: string | null;
|
||||
siteName: string | null;
|
||||
wordCount: number;
|
||||
readingTimeMinutes: number;
|
||||
}
|
||||
|
||||
export class ExtractService {
|
||||
async extractFromUrl(url: string): Promise<ExtractedArticle> {
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (compatible; ManaNews/1.0; +https://mana.how)',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch URL: ${response.status}`);
|
||||
}
|
||||
|
||||
const html = await response.text();
|
||||
const dom = new JSDOM(html, { url });
|
||||
const reader = new Readability(dom.window.document);
|
||||
const article = reader.parse();
|
||||
|
||||
if (!article) {
|
||||
throw new Error('Could not extract article content');
|
||||
}
|
||||
|
||||
const wordCount = article.textContent.split(/\s+/).filter(Boolean).length;
|
||||
const readingTimeMinutes = Math.max(1, Math.ceil(wordCount / 200));
|
||||
|
||||
return {
|
||||
title: article.title,
|
||||
content: article.textContent,
|
||||
htmlContent: article.content,
|
||||
excerpt: article.excerpt || article.textContent.slice(0, 200),
|
||||
byline: article.byline || null,
|
||||
siteName: article.siteName || null,
|
||||
wordCount,
|
||||
readingTimeMinutes,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -1,71 +0,0 @@
|
|||
import { sql } from 'drizzle-orm';
|
||||
import type { Database } from '../db/connection';
|
||||
|
||||
/**
|
||||
* Feed service reads AI-generated articles from sync_changes.
|
||||
* Articles with type='feed'|'summary'|'in_depth' and sourceOrigin='ai'.
|
||||
*/
|
||||
export class FeedService {
|
||||
constructor(private db: Database) {}
|
||||
|
||||
async getArticles(opts: { type?: string; categoryId?: string; limit?: number; offset?: number }) {
|
||||
const limit = opts.limit || 20;
|
||||
const offset = opts.offset || 0;
|
||||
|
||||
let whereClause = sql`app_id = 'news' AND table_name = 'articles' AND op != 'delete'`;
|
||||
|
||||
if (opts.type) {
|
||||
whereClause = sql`${whereClause} AND data->>'type' = ${opts.type}`;
|
||||
}
|
||||
if (opts.categoryId) {
|
||||
whereClause = sql`${whereClause} AND data->>'categoryId' = ${opts.categoryId}`;
|
||||
}
|
||||
|
||||
const result = await this.db.execute(sql`
|
||||
SELECT DISTINCT ON (record_id)
|
||||
record_id as id,
|
||||
data->>'title' as title,
|
||||
data->>'excerpt' as excerpt,
|
||||
data->>'author' as author,
|
||||
data->>'imageUrl' as "imageUrl",
|
||||
data->>'type' as type,
|
||||
data->>'categoryId' as "categoryId",
|
||||
(data->>'wordCount')::int as "wordCount",
|
||||
(data->>'readingTimeMinutes')::int as "readingTimeMinutes",
|
||||
data->>'publishedAt' as "publishedAt",
|
||||
created_at as "createdAt"
|
||||
FROM sync_changes
|
||||
WHERE ${whereClause}
|
||||
ORDER BY record_id, created_at DESC
|
||||
LIMIT ${limit} OFFSET ${offset}
|
||||
`);
|
||||
|
||||
return result as unknown as Record<string, unknown>[];
|
||||
}
|
||||
|
||||
async getArticleById(id: string) {
|
||||
const result = await this.db.execute(sql`
|
||||
SELECT DISTINCT ON (record_id)
|
||||
record_id as id,
|
||||
data->>'title' as title,
|
||||
data->>'content' as content,
|
||||
data->>'htmlContent' as "htmlContent",
|
||||
data->>'excerpt' as excerpt,
|
||||
data->>'author' as author,
|
||||
data->>'imageUrl' as "imageUrl",
|
||||
data->>'originalUrl' as "originalUrl",
|
||||
data->>'type' as type,
|
||||
(data->>'wordCount')::int as "wordCount",
|
||||
(data->>'readingTimeMinutes')::int as "readingTimeMinutes",
|
||||
data->>'publishedAt' as "publishedAt",
|
||||
created_at as "createdAt"
|
||||
FROM sync_changes
|
||||
WHERE app_id = 'news' AND table_name = 'articles' AND record_id = ${id} AND op != 'delete'
|
||||
ORDER BY record_id, created_at DESC
|
||||
LIMIT 1
|
||||
`);
|
||||
|
||||
const rows = result as unknown as Record<string, unknown>[];
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ES2022"],
|
||||
"types": ["bun-types"],
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue