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,21 @@
# Сборка из корня: docker build -t gooseek/auth-svc:latest -f services/auth-svc/Dockerfile .
FROM node:22-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
COPY services/auth-svc/package.json ./services/auth-svc/
RUN npm ci -w auth-svc
COPY services/auth-svc/tsconfig.json ./services/auth-svc/
COPY services/auth-svc/src ./services/auth-svc/src
WORKDIR /app/services/auth-svc
RUN npm run build
FROM node:22-alpine
WORKDIR /app
COPY package.json package-lock.json ./
COPY services/auth-svc/package.json ./services/auth-svc/
RUN npm ci -w auth-svc --omit=dev
COPY --from=builder /app/services/auth-svc/dist ./services/auth-svc/dist
WORKDIR /app/services/auth-svc
EXPOSE 3014
ENV PORT=3014
CMD ["node", "dist/index.js"]

Binary file not shown.

View File

@@ -0,0 +1,33 @@
{
"name": "auth-svc",
"version": "1.0.0",
"private": true,
"type": "module",
"description": "Auth service: SSO, JWT, Bearer validation for API-to-API",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/index.js",
"db:generate": "npx @better-auth/cli generate",
"db:migrate": "npx @better-auth/cli migrate --yes"
},
"dependencies": {
"dotenv": "^16.4.5",
"@better-auth/sso": "^1.3.0",
"better-auth": "^1.4.0",
"better-auth-credentials-plugin": "^0.4.0",
"better-sqlite3": "^11.9.1",
"cors": "^2.8.5",
"express": "^4.21.0",
"ldap-authentication": "^3.3.6",
"zod": "^3.23.0"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.12",
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/node": "^22.10.0",
"tsx": "^4.19.2",
"typescript": "^5.7.2"
}
}

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);

View File

@@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["src/**/*"]
}