feat: default locale Russian, geo determines language for other countries

- localization-svc: defaultLocale ru, resolveLocale only by geo
- web-svc: DEFAULT_LOCALE ru, layout lang=ru, embeddedTranslations fallback ru
- countryToLocale: default ru when no country or unknown country

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
home
2026-02-23 15:10:38 +03:00
parent 8fc82a3b90
commit cd6b7857ba
606 changed files with 26148 additions and 14297 deletions

View File

@@ -0,0 +1,63 @@
import { config } from 'dotenv';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
config({ path: path.resolve(fileURLToPath(import.meta.url), '../../../../.env') });
/**
* auth-svc — SSO, JWT, Bearer validation для API-to-API
* docs/architecture: 01-perplexity-analogue-design.md §3.2, auth-svc
* Миграция из apps/auth-mcs в services/
*/
import express from 'express';
import cors from 'cors';
import { toNodeHandler, fromNodeHeaders } from 'better-auth/node';
import { auth } from './lib/auth.js';
const PORT = parseInt(process.env.PORT ?? '3014', 10);
const app = express();
app.use(
cors({
origin: true,
methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'],
credentials: true,
})
);
app.get('/api/auth/me', async (req, res) => {
try {
const session = await auth.api.getSession({
headers: fromNodeHeaders(req.headers),
});
if (!session?.user?.id) {
return res.status(401).json({ error: 'Unauthorized' });
}
const { id, name, email, image } = session.user;
return res.json({ id, name: name ?? undefined, email: email ?? undefined, image: image ?? undefined });
} catch {
return res.status(401).json({ error: 'Unauthorized' });
}
});
app.all('/api/auth/*', toNodeHandler(auth));
app.use(express.json({ limit: '1mb' }));
app.get('/health', (_req, res) => res.json({ status: 'ok', service: 'auth-svc' }));
app.get('/ready', (_req, res) => res.json({ status: 'ready' }));
app.use((err: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
console.error('[auth-svc] Unhandled error:', err);
res.status(500).json({
error: 'Internal server error',
message: err instanceof Error ? err.message : String(err),
});
});
app.listen(PORT, '0.0.0.0', () => {
console.log(`auth-svc listening on :${PORT}`);
console.log(' GET /api/auth/me — Bearer validation for library-svc, memory-svc, etc.');
console.log(' Перед первым запуском: cd services/auth-svc && npm run db:migrate');
});

View File

@@ -0,0 +1,103 @@
import { betterAuth } from 'better-auth';
import { bearer, oidcProvider } from 'better-auth/plugins';
import { sso } from '@better-auth/sso';
import { credentials } from 'better-auth-credentials-plugin';
import { authenticate } from 'ldap-authentication';
import { z } from 'zod';
import { db } from './db.js';
const PORT = parseInt(process.env.PORT ?? '3014', 10);
const baseUrl =
process.env.BETTER_AUTH_URL || process.env.AUTH_SERVICE_URL || `http://localhost:${PORT}`;
export const auth = betterAuth({
database: db,
basePath: '/api/auth',
baseURL: baseUrl,
telemetry: { enabled: false },
trustedOrigins: [
baseUrl,
'http://localhost:3000',
'http://localhost:3014',
`http://localhost:${PORT}`,
...(process.env.TRUSTED_ORIGINS || '').split(',').filter(Boolean),
],
emailAndPassword: {
enabled: true,
},
plugins: [
bearer(),
sso(),
oidcProvider({
loginPage: '/sign-in',
allowDynamicClientRegistration: true,
trustedClients: (() => {
try {
if (process.env.TRUSTED_CLIENTS) {
return JSON.parse(process.env.TRUSTED_CLIENTS);
}
} catch {
/* ignore */
}
return [
{
clientId: process.env.DEFAULT_CLIENT_ID || 'gooseek',
clientSecret: process.env.DEFAULT_CLIENT_SECRET || 'gooseek-secret-change-me',
name: 'GooSeek',
type: 'web' as const,
redirectUrls: [
'http://localhost:3000/api/auth/callback/better-auth',
`${baseUrl}/api/auth/callback/better-auth`,
'https://gooseek.ru/api/auth/callback/better-auth',
'https://www.gooseek.ru/api/auth/callback/better-auth',
],
disabled: false,
skipConsent: true,
},
];
})(),
}),
...(process.env.LDAP_URL
? [
// @ts-expect-error — credentials plugin has deep generic instantiation
credentials({
autoSignUp: true,
linkAccountIfExisting: true,
providerId: 'ldap',
path: '/sign-in/ldap',
inputSchema: z.object({
credential: z.string().min(1, 'Username or DN required'),
password: z.string().min(1, 'Password required'),
}),
async callback(_ctx, parsed) {
const ldapResult = await authenticate({
ldapOpts: {
url: process.env.LDAP_URL!,
connectTimeout: 5000,
...(process.env.LDAP_URL!.startsWith('ldaps://')
? { tlsOptions: { minVersion: 'TLSv1.2' } }
: {}),
},
adminDn: process.env.LDAP_BIND_DN || '',
adminPassword: process.env.LDAP_PASSWORD || '',
userSearchBase: process.env.LDAP_BASE_DN || '',
usernameAttribute: process.env.LDAP_USERNAME_ATTR || 'uid',
username: parsed.credential,
userPassword: parsed.password,
});
const uid = ldapResult[process.env.LDAP_USERNAME_ATTR || 'uid'];
const email =
(Array.isArray(ldapResult.mail) ? ldapResult.mail[0] : ldapResult.mail) ||
`${uid}@local`;
return {
email,
name: ldapResult.displayName || ldapResult.cn || String(uid),
};
},
}),
]
: []),
],
});

View File

@@ -0,0 +1,15 @@
import Database from 'better-sqlite3';
import path from 'node:path';
import fs from 'node:fs';
const defaultPath = path.join(process.cwd(), 'data', 'auth.db');
const dbPath = process.env.DATABASE_URL?.startsWith('file:')
? process.env.DATABASE_URL.replace(/^file:/, '')
: process.env.DATABASE_PATH || defaultPath;
const dir = path.dirname(dbPath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
export const db = new Database(dbPath);