feat(infra): add mana-sync and mana-notify-go to docker-compose

- mana-sync on port 3051 (Go sync server for local-first apps)
- mana-notify-go on port 3040 (Go notification service)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-27 22:35:05 +01:00
parent 313779f439
commit ef19018e71
60 changed files with 908 additions and 2876 deletions

View file

@ -0,0 +1,18 @@
export interface Config {
port: number;
databaseUrl: string;
manaAuthUrl: string;
serviceKey: string;
cors: { origins: string[] };
}
export function loadConfig(): Config {
const env = (key: string, fallback?: string) => process.env[key] || fallback || '';
return {
port: parseInt(env('PORT', '3062'), 10),
databaseUrl: env('DATABASE_URL', 'postgresql://manacore:devpassword@localhost:5432/mana_user'),
manaAuthUrl: env('MANA_CORE_AUTH_URL', 'http://localhost:3001'),
serviceKey: env('MANA_CORE_SERVICE_KEY', 'dev-service-key'),
cors: { origins: env('CORS_ORIGINS', 'http://localhost:5173').split(',') },
};
}

View file

@ -0,0 +1,15 @@
import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
import * as schema from './schema/index';
let db: ReturnType<typeof drizzle<typeof schema>> | null = null;
export function getDb(databaseUrl: string) {
if (!db) {
const client = postgres(databaseUrl, { max: 10 });
db = drizzle(client, { schema });
}
return db;
}
export type Database = ReturnType<typeof getDb>;

View file

@ -0,0 +1,4 @@
export * from './tag-groups';
export * from './tags';
export * from './tag-links';
export * from './settings';

View file

@ -0,0 +1,18 @@
import { pgTable, text, jsonb, timestamp } from 'drizzle-orm/pg-core';
export const userSettings = pgTable('user_settings', {
userId: text('user_id').primaryKey(),
globalSettings: jsonb('global_settings')
.default({
nav: { desktopPosition: 'top', sidebarCollapsed: false },
theme: { mode: 'system', colorScheme: 'ocean' },
locale: 'de',
})
.notNull(),
appOverrides: jsonb('app_overrides').default({}).notNull(),
deviceSettings: jsonb('device_settings').default({}).notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
});
export type UserSettings = typeof userSettings.$inferSelect;

View file

@ -0,0 +1,31 @@
import {
pgTable,
varchar,
text,
uuid,
timestamp,
index,
unique,
integer,
} from 'drizzle-orm/pg-core';
export const tagGroups = pgTable(
'tag_groups',
{
id: uuid('id').primaryKey().defaultRandom(),
userId: text('user_id').notNull(),
name: varchar('name', { length: 100 }).notNull(),
color: varchar('color', { length: 7 }).default('#3B82F6'),
icon: varchar('icon', { length: 50 }),
sortOrder: integer('sort_order').default(0).notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
},
(table) => [
index('tag_groups_user_idx').on(table.userId),
unique('tag_groups_user_name_unique').on(table.userId, table.name),
]
);
export type TagGroup = typeof tagGroups.$inferSelect;
export type NewTagGroup = typeof tagGroups.$inferInsert;

View file

@ -0,0 +1,26 @@
import { pgTable, varchar, text, uuid, timestamp, index, unique } from 'drizzle-orm/pg-core';
import { tags } from './tags';
export const tagLinks = pgTable(
'tag_links',
{
id: uuid('id').primaryKey().defaultRandom(),
tagId: uuid('tag_id')
.notNull()
.references(() => tags.id, { onDelete: 'cascade' }),
appId: varchar('app_id', { length: 50 }).notNull(),
entityId: varchar('entity_id', { length: 255 }).notNull(),
entityType: varchar('entity_type', { length: 100 }).notNull(),
userId: text('user_id').notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
},
(table) => [
index('tag_links_tag_idx').on(table.tagId),
index('tag_links_entity_idx').on(table.appId, table.entityId),
index('tag_links_user_app_idx').on(table.userId, table.appId),
unique('tag_links_unique').on(table.tagId, table.appId, table.entityId),
]
);
export type TagLink = typeof tagLinks.$inferSelect;
export type NewTagLink = typeof tagLinks.$inferInsert;

View file

@ -0,0 +1,34 @@
import {
pgTable,
varchar,
text,
uuid,
timestamp,
index,
unique,
integer,
} from 'drizzle-orm/pg-core';
import { tagGroups } from './tag-groups';
export const tags = pgTable(
'tags',
{
id: uuid('id').primaryKey().defaultRandom(),
userId: text('user_id').notNull(),
name: varchar('name', { length: 100 }).notNull(),
color: varchar('color', { length: 7 }).default('#3B82F6'),
icon: varchar('icon', { length: 50 }),
groupId: uuid('group_id').references(() => tagGroups.id, { onDelete: 'set null' }),
sortOrder: integer('sort_order').default(0).notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
},
(table) => [
index('tags_user_idx').on(table.userId),
index('tags_group_idx').on(table.groupId),
unique('tags_user_name_unique').on(table.userId, table.name),
]
);
export type Tag = typeof tags.$inferSelect;
export type NewTag = typeof tags.$inferInsert;

View file

@ -0,0 +1,50 @@
/**
* mana-user User preferences, tags, and storage service
*
* Hono + Bun runtime. Extracted from mana-core-auth.
* Handles: user settings, tags, tag groups, tag links, avatar storage.
*/
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 { TagsService } from './services/tags';
import { SettingsService } from './services/settings';
import { healthRoutes } from './routes/health';
import { createTagRoutes } from './routes/tags';
import { createTagGroupRoutes } from './routes/tag-groups';
import { createTagLinkRoutes } from './routes/tag-links';
import { createSettingsRoutes } from './routes/settings';
const config = loadConfig();
const db = getDb(config.databaseUrl);
const tagsService = new TagsService(db);
const settingsService = new SettingsService(db);
const app = new Hono();
app.onError(errorHandler);
app.use('*', cors({ origin: config.cors.origins, credentials: true }));
// Health (no auth)
app.route('/health', healthRoutes);
// All API routes require JWT auth
app.use('/api/v1/*', jwtAuth(config.manaAuthUrl));
// Routes
app.route('/api/v1/tags', createTagRoutes(tagsService));
app.route('/api/v1/tag-groups', createTagGroupRoutes(tagsService));
app.route('/api/v1/tag-links', createTagLinkRoutes(tagsService));
app.route('/api/v1/settings', createSettingsRoutes(settingsService));
console.log(`mana-user starting on port ${config.port}...`);
export default {
port: config.port,
fetch: app.fetch,
};

View file

@ -0,0 +1,43 @@
import { HTTPException } from 'hono/http-exception';
export class BadRequestError extends HTTPException {
constructor(message: string) {
super(400, { message });
}
}
export class UnauthorizedError extends HTTPException {
constructor(message = 'Unauthorized') {
super(401, { message });
}
}
export class ForbiddenError extends HTTPException {
constructor(message = 'Forbidden') {
super(403, { message });
}
}
export class NotFoundError extends HTTPException {
constructor(message = 'Not found') {
super(404, { message });
}
}
export class ConflictError extends HTTPException {
constructor(message = 'Conflict') {
super(409, { message });
}
}
export class InsufficientCreditsError extends HTTPException {
constructor(
public readonly required: number,
public readonly available: number
) {
super(402, {
message: 'Insufficient credits',
cause: { required, available },
});
}
}

View file

@ -0,0 +1,29 @@
/**
* Global error handler middleware for Hono.
*/
import type { ErrorHandler } from 'hono';
import { HTTPException } from 'hono/http-exception';
export const errorHandler: ErrorHandler = (err, c) => {
if (err instanceof HTTPException) {
const cause = err.cause as Record<string, unknown> | undefined;
return c.json(
{
statusCode: err.status,
message: err.message,
...(cause ? { details: cause } : {}),
},
err.status
);
}
console.error('Unhandled error:', err);
return c.json(
{
statusCode: 500,
message: 'Internal server error',
},
500
);
};

View file

@ -0,0 +1,57 @@
/**
* JWT Authentication Middleware
*
* Validates Bearer tokens via JWKS from mana-core-auth.
* Uses jose library with EdDSA algorithm.
*/
import type { MiddlewareHandler } from 'hono';
import { createRemoteJWKSet, jwtVerify } from 'jose';
import { UnauthorizedError } from '../lib/errors';
let jwks: ReturnType<typeof createRemoteJWKSet> | null = null;
function getJwks(authUrl: string) {
if (!jwks) {
jwks = createRemoteJWKSet(new URL('/api/auth/jwks', authUrl));
}
return jwks;
}
export interface AuthUser {
userId: string;
email: string;
role: string;
}
/**
* Middleware that validates JWT tokens from Authorization: Bearer header.
* Sets c.set('user', { userId, email, role }) on success.
*/
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');
}
};
}

View file

@ -0,0 +1,26 @@
/**
* Service-to-Service Authentication Middleware
*
* Validates X-Service-Key header for backend-to-backend calls.
* Used by /internal/* routes.
*/
import type { MiddlewareHandler } from 'hono';
import { UnauthorizedError } from '../lib/errors';
/**
* Middleware that validates X-Service-Key header.
* Sets c.set('appId', ...) from X-App-Id header.
*/
export function serviceAuth(serviceKey: string): MiddlewareHandler {
return async (c, next) => {
const key = c.req.header('X-Service-Key');
if (!key || key !== serviceKey) {
throw new UnauthorizedError('Invalid or missing service key');
}
const appId = c.req.header('X-App-Id') || 'unknown';
c.set('appId', appId);
await next();
};
}

View file

@ -0,0 +1,5 @@
import { Hono } from 'hono';
export const healthRoutes = new Hono().get('/', (c) =>
c.json({ status: 'ok', service: 'mana-user', timestamp: new Date().toISOString() })
);

View file

@ -0,0 +1,30 @@
import { Hono } from 'hono';
import type { SettingsService } from '../services/settings';
import type { AuthUser } from '../middleware/jwt-auth';
export function createSettingsRoutes(settingsService: SettingsService) {
return new Hono<{ Variables: { user: AuthUser } }>()
.get('/', async (c) => {
const user = c.get('user');
return c.json(await settingsService.getSettings(user.userId));
})
.put('/global', async (c) => {
const user = c.get('user');
const body = await c.req.json();
return c.json(await settingsService.updateGlobalSettings(user.userId, body));
})
.put('/app/:appId', async (c) => {
const user = c.get('user');
const body = await c.req.json();
return c.json(
await settingsService.updateAppOverride(user.userId, c.req.param('appId'), body)
);
})
.put('/device/:deviceId', async (c) => {
const user = c.get('user');
const body = await c.req.json();
return c.json(
await settingsService.updateDeviceSettings(user.userId, c.req.param('deviceId'), body)
);
});
}

View file

@ -0,0 +1,26 @@
import { Hono } from 'hono';
import type { TagsService } from '../services/tags';
import type { AuthUser } from '../middleware/jwt-auth';
export function createTagGroupRoutes(tagsService: TagsService) {
return new Hono<{ Variables: { user: AuthUser } }>()
.get('/', async (c) => {
const user = c.get('user');
return c.json(await tagsService.getUserGroups(user.userId));
})
.post('/', async (c) => {
const user = c.get('user');
const body = await c.req.json();
return c.json(await tagsService.createGroup(user.userId, body), 201);
})
.put('/:id', async (c) => {
const user = c.get('user');
const body = await c.req.json();
return c.json(await tagsService.updateGroup(user.userId, c.req.param('id'), body));
})
.delete('/:id', async (c) => {
const user = c.get('user');
await tagsService.deleteGroup(user.userId, c.req.param('id'));
return c.json({ success: true });
});
}

View file

@ -0,0 +1,40 @@
import { Hono } from 'hono';
import type { TagsService } from '../services/tags';
import type { AuthUser } from '../middleware/jwt-auth';
export function createTagLinkRoutes(tagsService: TagsService) {
return new Hono<{ Variables: { user: AuthUser } }>()
.get('/entity', async (c) => {
const user = c.get('user');
const appId = c.req.query('appId') || '';
const entityId = c.req.query('entityId') || '';
const resolved = await tagsService.getLinksForEntity(user.userId, appId, entityId);
return c.json(resolved);
})
.post('/', async (c) => {
const user = c.get('user');
const body = await c.req.json();
const link = await tagsService.createLink(user.userId, body);
return c.json(link, 201);
})
.post('/sync', async (c) => {
const user = c.get('user');
const { appId, entityId, entityType, tagIds } = await c.req.json();
const result = await tagsService.syncLinks(user.userId, appId, entityId, entityType, tagIds);
return c.json(result);
})
.get('/query', async (c) => {
const user = c.get('user');
const links = await tagsService.queryLinks(user.userId, {
appId: c.req.query('appId'),
entityId: c.req.query('entityId'),
tagId: c.req.query('tagId'),
});
return c.json(links);
})
.delete('/:id', async (c) => {
const user = c.get('user');
await tagsService.deleteLink(user.userId, c.req.param('id'));
return c.json({ success: true });
});
}

View file

@ -0,0 +1,42 @@
import { Hono } from 'hono';
import type { TagsService } from '../services/tags';
import type { AuthUser } from '../middleware/jwt-auth';
export function createTagRoutes(tagsService: TagsService) {
return (
new Hono<{ Variables: { user: AuthUser } }>()
.get('/', async (c) => {
const user = c.get('user');
const allTags = await tagsService.getUserTags(user.userId);
return c.json(allTags);
})
.post('/', async (c) => {
const user = c.get('user');
const body = await c.req.json();
const tag = await tagsService.createTag(user.userId, body);
return c.json(tag, 201);
})
.put('/:id', async (c) => {
const user = c.get('user');
const body = await c.req.json();
const tag = await tagsService.updateTag(user.userId, c.req.param('id'), body);
return c.json(tag);
})
.delete('/:id', async (c) => {
const user = c.get('user');
await tagsService.deleteTag(user.userId, c.req.param('id'));
return c.json({ success: true });
})
.post('/defaults', async (c) => {
const user = c.get('user');
const defaultTags = await tagsService.createDefaultTags(user.userId);
return c.json(defaultTags);
})
// Batch resolve
.post('/resolve', async (c) => {
const { ids } = await c.req.json();
const resolved = await tagsService.getTagsByIds(ids || []);
return c.json(resolved);
})
);
}

View file

@ -0,0 +1,92 @@
/**
* Settings Service User preferences, theme, nav, device settings
*/
import { eq } from 'drizzle-orm';
import { userSettings } from '../db/schema/settings';
import type { Database } from '../db/connection';
export class SettingsService {
constructor(private db: Database) {}
async getSettings(userId: string) {
const [settings] = await this.db
.select()
.from(userSettings)
.where(eq(userSettings.userId, userId))
.limit(1);
if (!settings) return this.initializeSettings(userId);
return settings;
}
async initializeSettings(userId: string) {
const [settings] = await this.db
.insert(userSettings)
.values({ userId })
.onConflictDoNothing()
.returning();
if (!settings) {
// Already exists, fetch it
const [existing] = await this.db
.select()
.from(userSettings)
.where(eq(userSettings.userId, userId))
.limit(1);
return existing;
}
return settings;
}
async updateGlobalSettings(userId: string, updates: Record<string, unknown>) {
const current = await this.getSettings(userId);
const merged = { ...(current.globalSettings as Record<string, unknown>), ...updates };
const [updated] = await this.db
.update(userSettings)
.set({ globalSettings: merged, updatedAt: new Date() })
.where(eq(userSettings.userId, userId))
.returning();
return updated;
}
async updateAppOverride(userId: string, appId: string, overrides: Record<string, unknown>) {
const current = await this.getSettings(userId);
const appOverrides = { ...(current.appOverrides as Record<string, unknown>) };
appOverrides[appId] = {
...((appOverrides[appId] as Record<string, unknown>) || {}),
...overrides,
};
const [updated] = await this.db
.update(userSettings)
.set({ appOverrides, updatedAt: new Date() })
.where(eq(userSettings.userId, userId))
.returning();
return updated;
}
async updateDeviceSettings(userId: string, deviceId: string, settings: Record<string, unknown>) {
const current = await this.getSettings(userId);
const deviceSettings = { ...(current.deviceSettings as Record<string, unknown>) };
deviceSettings[deviceId] = {
...((deviceSettings[deviceId] as Record<string, unknown>) || {}),
...settings,
};
const [updated] = await this.db
.update(userSettings)
.set({ deviceSettings, updatedAt: new Date() })
.where(eq(userSettings.userId, userId))
.returning();
return updated;
}
async deleteSettings(userId: string) {
await this.db.delete(userSettings).where(eq(userSettings.userId, userId));
}
}

View file

@ -0,0 +1,210 @@
/**
* Tags Service CRUD for user tags, tag groups, and tag links
*/
import { eq, and, desc, inArray } from 'drizzle-orm';
import { tags, tagGroups, tagLinks } from '../db/schema/index';
import type { Database } from '../db/connection';
import { NotFoundError, BadRequestError } from '../lib/errors';
const DEFAULT_TAGS = [
{ name: 'Arbeit', color: '#3B82F6', icon: 'briefcase' },
{ name: 'Persönlich', color: '#10B981', icon: 'user' },
{ name: 'Familie', color: '#F59E0B', icon: 'users' },
{ name: 'Wichtig', color: '#EF4444', icon: 'star' },
];
export class TagsService {
constructor(private db: Database) {}
// ─── Tags ───────────────────────────────────────────────
async getUserTags(userId: string) {
return this.db.select().from(tags).where(eq(tags.userId, userId)).orderBy(tags.sortOrder);
}
async getTagById(userId: string, tagId: string) {
const [tag] = await this.db
.select()
.from(tags)
.where(and(eq(tags.id, tagId), eq(tags.userId, userId)))
.limit(1);
return tag;
}
async getTagsByIds(tagIds: string[]) {
if (tagIds.length === 0) return [];
return this.db.select().from(tags).where(inArray(tags.id, tagIds));
}
async createTag(
userId: string,
data: { name: string; color?: string; icon?: string; groupId?: string; sortOrder?: number }
) {
const [tag] = await this.db
.insert(tags)
.values({ userId, ...data })
.returning();
return tag;
}
async updateTag(
userId: string,
tagId: string,
data: {
name?: string;
color?: string;
icon?: string;
groupId?: string | null;
sortOrder?: number;
}
) {
const [tag] = await this.db
.update(tags)
.set({ ...data, updatedAt: new Date() })
.where(and(eq(tags.id, tagId), eq(tags.userId, userId)))
.returning();
if (!tag) throw new NotFoundError('Tag not found');
return tag;
}
async deleteTag(userId: string, tagId: string) {
const result = await this.db
.delete(tags)
.where(and(eq(tags.id, tagId), eq(tags.userId, userId)))
.returning();
if (result.length === 0) throw new NotFoundError('Tag not found');
}
async createDefaultTags(userId: string) {
const existing = await this.getUserTags(userId);
if (existing.length > 0) return existing;
const created = [];
for (let i = 0; i < DEFAULT_TAGS.length; i++) {
const [tag] = await this.db
.insert(tags)
.values({ userId, ...DEFAULT_TAGS[i], sortOrder: i })
.returning();
created.push(tag);
}
return created;
}
// ─── Tag Groups ─────────────────────────────────────────
async getUserGroups(userId: string) {
return this.db
.select()
.from(tagGroups)
.where(eq(tagGroups.userId, userId))
.orderBy(tagGroups.sortOrder);
}
async createGroup(
userId: string,
data: { name: string; color?: string; icon?: string; sortOrder?: number }
) {
const [group] = await this.db
.insert(tagGroups)
.values({ userId, ...data })
.returning();
return group;
}
async updateGroup(
userId: string,
groupId: string,
data: { name?: string; color?: string; icon?: string; sortOrder?: number }
) {
const [group] = await this.db
.update(tagGroups)
.set({ ...data, updatedAt: new Date() })
.where(and(eq(tagGroups.id, groupId), eq(tagGroups.userId, userId)))
.returning();
if (!group) throw new NotFoundError('Tag group not found');
return group;
}
async deleteGroup(userId: string, groupId: string) {
// Unlink tags from this group first (set groupId to null)
await this.db.update(tags).set({ groupId: null }).where(eq(tags.groupId, groupId));
const result = await this.db
.delete(tagGroups)
.where(and(eq(tagGroups.id, groupId), eq(tagGroups.userId, userId)))
.returning();
if (result.length === 0) throw new NotFoundError('Tag group not found');
}
// ─── Tag Links ──────────────────────────────────────────
async getLinksForEntity(userId: string, appId: string, entityId: string) {
const links = await this.db
.select()
.from(tagLinks)
.where(
and(eq(tagLinks.userId, userId), eq(tagLinks.appId, appId), eq(tagLinks.entityId, entityId))
);
// Resolve full tag objects
const tagIds = links.map((l) => l.tagId);
const resolvedTags = tagIds.length > 0 ? await this.getTagsByIds(tagIds) : [];
return resolvedTags;
}
async createLink(
userId: string,
data: { tagId: string; appId: string; entityId: string; entityType: string }
) {
const [link] = await this.db
.insert(tagLinks)
.values({ userId, ...data })
.returning();
return link;
}
async syncLinks(
userId: string,
appId: string,
entityId: string,
entityType: string,
tagIds: string[]
) {
return this.db.transaction(async (tx) => {
// Delete all existing links for this entity
await tx
.delete(tagLinks)
.where(
and(
eq(tagLinks.userId, userId),
eq(tagLinks.appId, appId),
eq(tagLinks.entityId, entityId)
)
);
// Insert new links
if (tagIds.length > 0) {
await tx
.insert(tagLinks)
.values(tagIds.map((tagId) => ({ tagId, appId, entityId, entityType, userId })));
}
return { synced: tagIds.length };
});
}
async deleteLink(userId: string, linkId: string) {
await this.db.delete(tagLinks).where(and(eq(tagLinks.id, linkId), eq(tagLinks.userId, userId)));
}
async queryLinks(
userId: string,
filters: { appId?: string; entityId?: string; entityType?: string; tagId?: string }
) {
let query = this.db.select().from(tagLinks).where(eq(tagLinks.userId, userId)).$dynamic();
if (filters.appId) query = query.where(eq(tagLinks.appId, filters.appId));
if (filters.entityId) query = query.where(eq(tagLinks.entityId, filters.entityId));
if (filters.tagId) query = query.where(eq(tagLinks.tagId, filters.tagId));
return query;
}
}