@enderworld/onlyapi 1.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +201 -0
- package/LICENSE +21 -0
- package/README.md +338 -0
- package/dist/cli.js +14 -0
- package/package.json +69 -0
- package/src/application/dtos/admin.dto.ts +25 -0
- package/src/application/dtos/auth.dto.ts +97 -0
- package/src/application/dtos/index.ts +40 -0
- package/src/application/index.ts +2 -0
- package/src/application/services/admin.service.ts +150 -0
- package/src/application/services/api-key.service.ts +65 -0
- package/src/application/services/auth.service.ts +606 -0
- package/src/application/services/health.service.ts +97 -0
- package/src/application/services/index.ts +10 -0
- package/src/application/services/user.service.ts +95 -0
- package/src/cli/commands/help.ts +86 -0
- package/src/cli/commands/init.ts +301 -0
- package/src/cli/commands/upgrade.ts +471 -0
- package/src/cli/index.ts +76 -0
- package/src/cli/ui.ts +189 -0
- package/src/cluster.ts +62 -0
- package/src/core/entities/index.ts +1 -0
- package/src/core/entities/user.entity.ts +24 -0
- package/src/core/errors/app-error.ts +81 -0
- package/src/core/errors/index.ts +15 -0
- package/src/core/index.ts +7 -0
- package/src/core/ports/account-lockout.ts +15 -0
- package/src/core/ports/alert-sink.ts +27 -0
- package/src/core/ports/api-key.ts +37 -0
- package/src/core/ports/audit-log.ts +46 -0
- package/src/core/ports/cache.ts +24 -0
- package/src/core/ports/circuit-breaker.ts +42 -0
- package/src/core/ports/event-bus.ts +78 -0
- package/src/core/ports/index.ts +62 -0
- package/src/core/ports/job-queue.ts +73 -0
- package/src/core/ports/logger.ts +21 -0
- package/src/core/ports/metrics.ts +49 -0
- package/src/core/ports/oauth.ts +55 -0
- package/src/core/ports/password-hasher.ts +10 -0
- package/src/core/ports/password-history.ts +23 -0
- package/src/core/ports/password-policy.ts +43 -0
- package/src/core/ports/refresh-token-store.ts +37 -0
- package/src/core/ports/retry.ts +23 -0
- package/src/core/ports/token-blacklist.ts +16 -0
- package/src/core/ports/token-service.ts +23 -0
- package/src/core/ports/totp-service.ts +16 -0
- package/src/core/ports/user.repository.ts +40 -0
- package/src/core/ports/verification-token.ts +41 -0
- package/src/core/ports/webhook.ts +58 -0
- package/src/core/types/brand.ts +19 -0
- package/src/core/types/index.ts +19 -0
- package/src/core/types/pagination.ts +28 -0
- package/src/core/types/result.ts +52 -0
- package/src/infrastructure/alerting/index.ts +1 -0
- package/src/infrastructure/alerting/webhook.ts +100 -0
- package/src/infrastructure/cache/in-memory-cache.ts +111 -0
- package/src/infrastructure/cache/index.ts +6 -0
- package/src/infrastructure/cache/redis-cache.ts +204 -0
- package/src/infrastructure/config/config.ts +185 -0
- package/src/infrastructure/config/index.ts +1 -0
- package/src/infrastructure/database/in-memory-user.repository.ts +134 -0
- package/src/infrastructure/database/index.ts +37 -0
- package/src/infrastructure/database/migrations/001_create_users.ts +26 -0
- package/src/infrastructure/database/migrations/002_create_token_blacklist.ts +21 -0
- package/src/infrastructure/database/migrations/003_create_audit_log.ts +31 -0
- package/src/infrastructure/database/migrations/004_auth_platform.ts +112 -0
- package/src/infrastructure/database/migrations/runner.ts +120 -0
- package/src/infrastructure/database/mssql/index.ts +14 -0
- package/src/infrastructure/database/mssql/migrations.ts +299 -0
- package/src/infrastructure/database/mssql/mssql-account-lockout.ts +95 -0
- package/src/infrastructure/database/mssql/mssql-api-keys.ts +146 -0
- package/src/infrastructure/database/mssql/mssql-audit-log.ts +86 -0
- package/src/infrastructure/database/mssql/mssql-oauth-accounts.ts +118 -0
- package/src/infrastructure/database/mssql/mssql-password-history.ts +71 -0
- package/src/infrastructure/database/mssql/mssql-refresh-token-store.ts +144 -0
- package/src/infrastructure/database/mssql/mssql-token-blacklist.ts +54 -0
- package/src/infrastructure/database/mssql/mssql-user.repository.ts +263 -0
- package/src/infrastructure/database/mssql/mssql-verification-tokens.ts +120 -0
- package/src/infrastructure/database/postgres/index.ts +14 -0
- package/src/infrastructure/database/postgres/migrations.ts +235 -0
- package/src/infrastructure/database/postgres/pg-account-lockout.ts +75 -0
- package/src/infrastructure/database/postgres/pg-api-keys.ts +126 -0
- package/src/infrastructure/database/postgres/pg-audit-log.ts +74 -0
- package/src/infrastructure/database/postgres/pg-oauth-accounts.ts +101 -0
- package/src/infrastructure/database/postgres/pg-password-history.ts +61 -0
- package/src/infrastructure/database/postgres/pg-refresh-token-store.ts +117 -0
- package/src/infrastructure/database/postgres/pg-token-blacklist.ts +48 -0
- package/src/infrastructure/database/postgres/pg-user.repository.ts +237 -0
- package/src/infrastructure/database/postgres/pg-verification-tokens.ts +97 -0
- package/src/infrastructure/database/sqlite-account-lockout.ts +97 -0
- package/src/infrastructure/database/sqlite-api-keys.ts +155 -0
- package/src/infrastructure/database/sqlite-audit-log.ts +90 -0
- package/src/infrastructure/database/sqlite-oauth-accounts.ts +105 -0
- package/src/infrastructure/database/sqlite-password-history.ts +54 -0
- package/src/infrastructure/database/sqlite-refresh-token-store.ts +122 -0
- package/src/infrastructure/database/sqlite-token-blacklist.ts +47 -0
- package/src/infrastructure/database/sqlite-user.repository.ts +260 -0
- package/src/infrastructure/database/sqlite-verification-tokens.ts +112 -0
- package/src/infrastructure/events/event-bus.ts +105 -0
- package/src/infrastructure/events/event-factory.ts +31 -0
- package/src/infrastructure/events/in-memory-webhook-registry.ts +67 -0
- package/src/infrastructure/events/index.ts +4 -0
- package/src/infrastructure/events/webhook-dispatcher.ts +114 -0
- package/src/infrastructure/index.ts +58 -0
- package/src/infrastructure/jobs/index.ts +1 -0
- package/src/infrastructure/jobs/job-queue.ts +185 -0
- package/src/infrastructure/logging/index.ts +1 -0
- package/src/infrastructure/logging/logger.ts +63 -0
- package/src/infrastructure/metrics/index.ts +1 -0
- package/src/infrastructure/metrics/prometheus.ts +231 -0
- package/src/infrastructure/oauth/github.ts +116 -0
- package/src/infrastructure/oauth/google.ts +83 -0
- package/src/infrastructure/oauth/index.ts +2 -0
- package/src/infrastructure/resilience/circuit-breaker.ts +133 -0
- package/src/infrastructure/resilience/index.ts +2 -0
- package/src/infrastructure/resilience/retry.ts +50 -0
- package/src/infrastructure/security/account-lockout.ts +73 -0
- package/src/infrastructure/security/index.ts +6 -0
- package/src/infrastructure/security/password-hasher.ts +31 -0
- package/src/infrastructure/security/password-policy.ts +77 -0
- package/src/infrastructure/security/token-blacklist.ts +45 -0
- package/src/infrastructure/security/token-service.ts +144 -0
- package/src/infrastructure/security/totp-service.ts +142 -0
- package/src/infrastructure/tracing/index.ts +7 -0
- package/src/infrastructure/tracing/trace-context.ts +93 -0
- package/src/main.ts +479 -0
- package/src/presentation/context.ts +26 -0
- package/src/presentation/handlers/admin.handler.ts +114 -0
- package/src/presentation/handlers/api-key.handler.ts +68 -0
- package/src/presentation/handlers/auth.handler.ts +218 -0
- package/src/presentation/handlers/health.handler.ts +27 -0
- package/src/presentation/handlers/index.ts +15 -0
- package/src/presentation/handlers/metrics.handler.ts +21 -0
- package/src/presentation/handlers/oauth.handler.ts +61 -0
- package/src/presentation/handlers/openapi.handler.ts +543 -0
- package/src/presentation/handlers/response.ts +29 -0
- package/src/presentation/handlers/sse.handler.ts +165 -0
- package/src/presentation/handlers/user.handler.ts +81 -0
- package/src/presentation/handlers/webhook.handler.ts +92 -0
- package/src/presentation/handlers/websocket.handler.ts +226 -0
- package/src/presentation/i18n/index.ts +254 -0
- package/src/presentation/index.ts +5 -0
- package/src/presentation/middleware/api-key.ts +18 -0
- package/src/presentation/middleware/auth.ts +39 -0
- package/src/presentation/middleware/cors.ts +41 -0
- package/src/presentation/middleware/index.ts +12 -0
- package/src/presentation/middleware/rate-limit.ts +65 -0
- package/src/presentation/middleware/security-headers.ts +18 -0
- package/src/presentation/middleware/validate.ts +16 -0
- package/src/presentation/middleware/versioning.ts +69 -0
- package/src/presentation/routes/index.ts +1 -0
- package/src/presentation/routes/router.ts +272 -0
- package/src/presentation/server.ts +381 -0
- package/src/shared/cli.ts +294 -0
- package/src/shared/container.ts +65 -0
- package/src/shared/index.ts +2 -0
- package/src/shared/log-format.ts +148 -0
- package/src/shared/utils/id.ts +5 -0
- package/src/shared/utils/index.ts +2 -0
- package/src/shared/utils/timing-safe.ts +20 -0
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* i18n – Internationalisation support.
|
|
3
|
+
*
|
|
4
|
+
* Parses Accept-Language, resolves best locale, and provides
|
|
5
|
+
* translated error messages via a compile-time-safe key system.
|
|
6
|
+
*
|
|
7
|
+
* Zero external dependencies.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** All translatable message keys. Add new keys here. */
|
|
11
|
+
export type MessageKey =
|
|
12
|
+
| "auth.invalid_credentials"
|
|
13
|
+
| "auth.email_taken"
|
|
14
|
+
| "auth.token_expired"
|
|
15
|
+
| "auth.token_invalid"
|
|
16
|
+
| "auth.account_locked"
|
|
17
|
+
| "auth.email_not_verified"
|
|
18
|
+
| "auth.mfa_required"
|
|
19
|
+
| "auth.mfa_invalid"
|
|
20
|
+
| "auth.refresh_token_invalid"
|
|
21
|
+
| "auth.password_too_weak"
|
|
22
|
+
| "auth.password_recently_used"
|
|
23
|
+
| "user.not_found"
|
|
24
|
+
| "user.forbidden"
|
|
25
|
+
| "validation.required"
|
|
26
|
+
| "validation.invalid_email"
|
|
27
|
+
| "validation.too_short"
|
|
28
|
+
| "validation.too_long"
|
|
29
|
+
| "rate_limit.exceeded"
|
|
30
|
+
| "server.internal_error"
|
|
31
|
+
| "server.service_unavailable"
|
|
32
|
+
| "not_found"
|
|
33
|
+
| "forbidden"
|
|
34
|
+
| "unauthorized";
|
|
35
|
+
|
|
36
|
+
type MessageCatalog = Record<MessageKey, string>;
|
|
37
|
+
|
|
38
|
+
/** English (default) */
|
|
39
|
+
const en: MessageCatalog = {
|
|
40
|
+
"auth.invalid_credentials": "Invalid email or password",
|
|
41
|
+
"auth.email_taken": "An account with this email already exists",
|
|
42
|
+
"auth.token_expired": "Authentication token has expired",
|
|
43
|
+
"auth.token_invalid": "Authentication token is invalid",
|
|
44
|
+
"auth.account_locked": "Account is temporarily locked due to too many failed attempts",
|
|
45
|
+
"auth.email_not_verified": "Please verify your email address before logging in",
|
|
46
|
+
"auth.mfa_required": "Multi-factor authentication is required",
|
|
47
|
+
"auth.mfa_invalid": "Invalid MFA code",
|
|
48
|
+
"auth.refresh_token_invalid": "Refresh token is invalid or has been revoked",
|
|
49
|
+
"auth.password_too_weak": "Password does not meet the security requirements",
|
|
50
|
+
"auth.password_recently_used": "This password was used recently. Please choose a different one",
|
|
51
|
+
"user.not_found": "User not found",
|
|
52
|
+
"user.forbidden": "You do not have permission to perform this action",
|
|
53
|
+
"validation.required": "This field is required",
|
|
54
|
+
"validation.invalid_email": "Please provide a valid email address",
|
|
55
|
+
"validation.too_short": "Value is too short",
|
|
56
|
+
"validation.too_long": "Value is too long",
|
|
57
|
+
"rate_limit.exceeded": "Too many requests. Please try again later",
|
|
58
|
+
"server.internal_error": "An internal server error occurred",
|
|
59
|
+
"server.service_unavailable": "Service is temporarily unavailable",
|
|
60
|
+
not_found: "The requested resource was not found",
|
|
61
|
+
forbidden: "Access denied",
|
|
62
|
+
unauthorized: "Authentication is required",
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/** Spanish */
|
|
66
|
+
const es: MessageCatalog = {
|
|
67
|
+
"auth.invalid_credentials": "Correo electrónico o contraseña no válidos",
|
|
68
|
+
"auth.email_taken": "Ya existe una cuenta con este correo electrónico",
|
|
69
|
+
"auth.token_expired": "El token de autenticación ha caducado",
|
|
70
|
+
"auth.token_invalid": "El token de autenticación no es válido",
|
|
71
|
+
"auth.account_locked": "La cuenta está bloqueada temporalmente por demasiados intentos fallidos",
|
|
72
|
+
"auth.email_not_verified": "Por favor, verifique su correo electrónico antes de iniciar sesión",
|
|
73
|
+
"auth.mfa_required": "Se requiere autenticación multifactor",
|
|
74
|
+
"auth.mfa_invalid": "Código MFA no válido",
|
|
75
|
+
"auth.refresh_token_invalid": "El token de actualización no es válido o ha sido revocado",
|
|
76
|
+
"auth.password_too_weak": "La contraseña no cumple los requisitos de seguridad",
|
|
77
|
+
"auth.password_recently_used": "Esta contraseña fue usada recientemente. Elija una diferente",
|
|
78
|
+
"user.not_found": "Usuario no encontrado",
|
|
79
|
+
"user.forbidden": "No tiene permiso para realizar esta acción",
|
|
80
|
+
"validation.required": "Este campo es obligatorio",
|
|
81
|
+
"validation.invalid_email": "Proporcione una dirección de correo electrónico válida",
|
|
82
|
+
"validation.too_short": "El valor es demasiado corto",
|
|
83
|
+
"validation.too_long": "El valor es demasiado largo",
|
|
84
|
+
"rate_limit.exceeded": "Demasiadas solicitudes. Inténtelo de nuevo más tarde",
|
|
85
|
+
"server.internal_error": "Se produjo un error interno del servidor",
|
|
86
|
+
"server.service_unavailable": "El servicio no está disponible temporalmente",
|
|
87
|
+
not_found: "El recurso solicitado no fue encontrado",
|
|
88
|
+
forbidden: "Acceso denegado",
|
|
89
|
+
unauthorized: "Se requiere autenticación",
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
/** French */
|
|
93
|
+
const fr: MessageCatalog = {
|
|
94
|
+
"auth.invalid_credentials": "Adresse e-mail ou mot de passe incorrect",
|
|
95
|
+
"auth.email_taken": "Un compte avec cette adresse e-mail existe déjà",
|
|
96
|
+
"auth.token_expired": "Le jeton d'authentification a expiré",
|
|
97
|
+
"auth.token_invalid": "Le jeton d'authentification est invalide",
|
|
98
|
+
"auth.account_locked": "Le compte est temporairement verrouillé en raison de trop de tentatives",
|
|
99
|
+
"auth.email_not_verified": "Veuillez vérifier votre adresse e-mail avant de vous connecter",
|
|
100
|
+
"auth.mfa_required": "L'authentification multifacteur est requise",
|
|
101
|
+
"auth.mfa_invalid": "Code MFA invalide",
|
|
102
|
+
"auth.refresh_token_invalid": "Le jeton de rafraîchissement est invalide ou a été révoqué",
|
|
103
|
+
"auth.password_too_weak": "Le mot de passe ne répond pas aux exigences de sécurité",
|
|
104
|
+
"auth.password_recently_used":
|
|
105
|
+
"Ce mot de passe a été utilisé récemment. Veuillez en choisir un autre",
|
|
106
|
+
"user.not_found": "Utilisateur introuvable",
|
|
107
|
+
"user.forbidden": "Vous n'avez pas la permission d'effectuer cette action",
|
|
108
|
+
"validation.required": "Ce champ est obligatoire",
|
|
109
|
+
"validation.invalid_email": "Veuillez fournir une adresse e-mail valide",
|
|
110
|
+
"validation.too_short": "La valeur est trop courte",
|
|
111
|
+
"validation.too_long": "La valeur est trop longue",
|
|
112
|
+
"rate_limit.exceeded": "Trop de requêtes. Veuillez réessayer plus tard",
|
|
113
|
+
"server.internal_error": "Une erreur interne du serveur s'est produite",
|
|
114
|
+
"server.service_unavailable": "Le service est temporairement indisponible",
|
|
115
|
+
not_found: "La ressource demandée est introuvable",
|
|
116
|
+
forbidden: "Accès refusé",
|
|
117
|
+
unauthorized: "Une authentification est requise",
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
/** German */
|
|
121
|
+
const de: MessageCatalog = {
|
|
122
|
+
"auth.invalid_credentials": "Ungültige E-Mail-Adresse oder Passwort",
|
|
123
|
+
"auth.email_taken": "Ein Konto mit dieser E-Mail-Adresse existiert bereits",
|
|
124
|
+
"auth.token_expired": "Das Authentifizierungstoken ist abgelaufen",
|
|
125
|
+
"auth.token_invalid": "Das Authentifizierungstoken ist ungültig",
|
|
126
|
+
"auth.account_locked": "Das Konto ist aufgrund zu vieler Fehlversuche vorübergehend gesperrt",
|
|
127
|
+
"auth.email_not_verified": "Bitte bestätigen Sie Ihre E-Mail-Adresse, bevor Sie sich anmelden",
|
|
128
|
+
"auth.mfa_required": "Multi-Faktor-Authentifizierung ist erforderlich",
|
|
129
|
+
"auth.mfa_invalid": "Ungültiger MFA-Code",
|
|
130
|
+
"auth.refresh_token_invalid": "Das Aktualisierungstoken ist ungültig oder wurde widerrufen",
|
|
131
|
+
"auth.password_too_weak": "Das Passwort erfüllt nicht die Sicherheitsanforderungen",
|
|
132
|
+
"auth.password_recently_used":
|
|
133
|
+
"Dieses Passwort wurde kürzlich verwendet. Bitte wählen Sie ein anderes",
|
|
134
|
+
"user.not_found": "Benutzer nicht gefunden",
|
|
135
|
+
"user.forbidden": "Sie haben keine Berechtigung, diese Aktion durchzuführen",
|
|
136
|
+
"validation.required": "Dieses Feld ist erforderlich",
|
|
137
|
+
"validation.invalid_email": "Bitte geben Sie eine gültige E-Mail-Adresse an",
|
|
138
|
+
"validation.too_short": "Der Wert ist zu kurz",
|
|
139
|
+
"validation.too_long": "Der Wert ist zu lang",
|
|
140
|
+
"rate_limit.exceeded": "Zu viele Anfragen. Bitte versuchen Sie es später erneut",
|
|
141
|
+
"server.internal_error": "Ein interner Serverfehler ist aufgetreten",
|
|
142
|
+
"server.service_unavailable": "Der Dienst ist vorübergehend nicht verfügbar",
|
|
143
|
+
not_found: "Die angeforderte Ressource wurde nicht gefunden",
|
|
144
|
+
forbidden: "Zugriff verweigert",
|
|
145
|
+
unauthorized: "Authentifizierung ist erforderlich",
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
/** Japanese */
|
|
149
|
+
const ja: MessageCatalog = {
|
|
150
|
+
"auth.invalid_credentials": "メールアドレスまたはパスワードが無効です",
|
|
151
|
+
"auth.email_taken": "このメールアドレスのアカウントは既に存在します",
|
|
152
|
+
"auth.token_expired": "認証トークンの有効期限が切れています",
|
|
153
|
+
"auth.token_invalid": "認証トークンが無効です",
|
|
154
|
+
"auth.account_locked": "ログイン失敗回数が多すぎるため、アカウントが一時的にロックされています",
|
|
155
|
+
"auth.email_not_verified": "ログインする前にメールアドレスを確認してください",
|
|
156
|
+
"auth.mfa_required": "多要素認証が必要です",
|
|
157
|
+
"auth.mfa_invalid": "無効なMFAコードです",
|
|
158
|
+
"auth.refresh_token_invalid": "リフレッシュトークンが無効、または取り消されています",
|
|
159
|
+
"auth.password_too_weak": "パスワードがセキュリティ要件を満たしていません",
|
|
160
|
+
"auth.password_recently_used":
|
|
161
|
+
"このパスワードは最近使用されました。別のパスワードを選択してください",
|
|
162
|
+
"user.not_found": "ユーザーが見つかりません",
|
|
163
|
+
"user.forbidden": "この操作を行う権限がありません",
|
|
164
|
+
"validation.required": "この項目は必須です",
|
|
165
|
+
"validation.invalid_email": "有効なメールアドレスを入力してください",
|
|
166
|
+
"validation.too_short": "値が短すぎます",
|
|
167
|
+
"validation.too_long": "値が長すぎます",
|
|
168
|
+
"rate_limit.exceeded": "リクエストが多すぎます。しばらくしてからもう一度お試しください",
|
|
169
|
+
"server.internal_error": "内部サーバーエラーが発生しました",
|
|
170
|
+
"server.service_unavailable": "サービスは一時的に利用できません",
|
|
171
|
+
not_found: "リクエストされたリソースが見つかりません",
|
|
172
|
+
forbidden: "アクセスが拒否されました",
|
|
173
|
+
unauthorized: "認証が必要です",
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
/** All supported locale catalogs */
|
|
177
|
+
const catalogs: Record<string, MessageCatalog> = {
|
|
178
|
+
en,
|
|
179
|
+
es,
|
|
180
|
+
fr,
|
|
181
|
+
de,
|
|
182
|
+
ja,
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Parse the Accept-Language header and return an ordered list of locale tags.
|
|
187
|
+
* Example: "fr-CH, fr;q=0.9, en;q=0.8, de;q=0.7" → ["fr", "en", "de"]
|
|
188
|
+
*/
|
|
189
|
+
export const parseAcceptLanguage = (header: string | null): readonly string[] => {
|
|
190
|
+
if (!header) return [];
|
|
191
|
+
|
|
192
|
+
return header
|
|
193
|
+
.split(",")
|
|
194
|
+
.map((part) => {
|
|
195
|
+
const [tag, ...params] = part.trim().split(";");
|
|
196
|
+
let q = 1;
|
|
197
|
+
for (const p of params) {
|
|
198
|
+
const match = p.trim().match(/^q=(\d+(?:\.\d+)?)$/);
|
|
199
|
+
if (match?.[1] !== undefined) {
|
|
200
|
+
q = Number.parseFloat(match[1]);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
// Normalize: "fr-CH" → "fr"
|
|
204
|
+
const lang = (tag ?? "").trim().split("-")[0]?.toLowerCase() ?? "";
|
|
205
|
+
return { lang, q };
|
|
206
|
+
})
|
|
207
|
+
.filter((e) => e.lang.length > 0 && e.q > 0)
|
|
208
|
+
.sort((a, b) => b.q - a.q)
|
|
209
|
+
.map((e) => e.lang);
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Resolve the best locale from Accept-Language given supported locales.
|
|
214
|
+
*/
|
|
215
|
+
export const resolveLocale = (
|
|
216
|
+
acceptLanguages: readonly string[],
|
|
217
|
+
supportedLocales: readonly string[],
|
|
218
|
+
defaultLocale: string,
|
|
219
|
+
): string => {
|
|
220
|
+
for (const lang of acceptLanguages) {
|
|
221
|
+
if (supportedLocales.includes(lang)) return lang;
|
|
222
|
+
}
|
|
223
|
+
return defaultLocale;
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Get a translated message by key for a given locale.
|
|
228
|
+
* Falls back to English if the locale or key is missing.
|
|
229
|
+
*/
|
|
230
|
+
export const t = (locale: string, key: MessageKey): string => {
|
|
231
|
+
const catalog = catalogs[locale] ?? catalogs["en"];
|
|
232
|
+
// biome-ignore lint/style/noNonNullAssertion: en catalog is always complete
|
|
233
|
+
return catalog![key] ?? en[key];
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Create an i18n context for a request.
|
|
238
|
+
* Resolves locale from Accept-Language and provides a bound `t` function.
|
|
239
|
+
*/
|
|
240
|
+
export const createI18nContext = (
|
|
241
|
+
req: Request,
|
|
242
|
+
supportedLocales: readonly string[],
|
|
243
|
+
defaultLocale: string,
|
|
244
|
+
) => {
|
|
245
|
+
const acceptLanguages = parseAcceptLanguage(req.headers.get("Accept-Language"));
|
|
246
|
+
const locale = resolveLocale(acceptLanguages, supportedLocales, defaultLocale);
|
|
247
|
+
|
|
248
|
+
return {
|
|
249
|
+
locale,
|
|
250
|
+
t: (key: MessageKey) => t(locale, key),
|
|
251
|
+
};
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
export type I18nContext = ReturnType<typeof createI18nContext>;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { ApiKeyService } from "../../application/services/api-key.service.js";
|
|
2
|
+
import type { AppError } from "../../core/errors/app-error.js";
|
|
3
|
+
import { unauthorized } from "../../core/errors/app-error.js";
|
|
4
|
+
import type { ApiKey } from "../../core/ports/api-key.js";
|
|
5
|
+
import { type Result, err } from "../../core/types/result.js";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Middleware: API Key Authentication via X-API-Key header.
|
|
9
|
+
* Falls back to Bearer token auth if no API key is provided.
|
|
10
|
+
*/
|
|
11
|
+
export const authenticateApiKey = async (
|
|
12
|
+
req: Request,
|
|
13
|
+
apiKeyService: ApiKeyService,
|
|
14
|
+
): Promise<Result<ApiKey, AppError>> => {
|
|
15
|
+
const apiKey = req.headers.get("x-api-key");
|
|
16
|
+
if (!apiKey) return err(unauthorized("Missing X-API-Key header"));
|
|
17
|
+
return apiKeyService.verify(apiKey);
|
|
18
|
+
};
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { UserRole } from "../../core/entities/user.entity.js";
|
|
2
|
+
import type { AppError } from "../../core/errors/app-error.js";
|
|
3
|
+
import { forbidden, unauthorized } from "../../core/errors/app-error.js";
|
|
4
|
+
import type { TokenPayload, TokenService } from "../../core/ports/token-service.js";
|
|
5
|
+
import { type Result, err } from "../../core/types/result.js";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Extracts and verifies the Bearer token from the Authorization header.
|
|
9
|
+
*/
|
|
10
|
+
export const authenticate = async (
|
|
11
|
+
req: Request,
|
|
12
|
+
tokenService: TokenService,
|
|
13
|
+
): Promise<Result<TokenPayload, AppError>> => {
|
|
14
|
+
const header = req.headers.get("authorization");
|
|
15
|
+
if (!header) return err(unauthorized("Missing Authorization header"));
|
|
16
|
+
|
|
17
|
+
const parts = header.split(" ");
|
|
18
|
+
if (parts.length !== 2 || parts[0] !== "Bearer") {
|
|
19
|
+
return err(unauthorized("Invalid Authorization header format"));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const token = parts[1];
|
|
23
|
+
if (!token) return err(unauthorized("Missing token"));
|
|
24
|
+
|
|
25
|
+
return tokenService.verify(token);
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Role guard — call after authentication.
|
|
30
|
+
*/
|
|
31
|
+
export const authorise = (
|
|
32
|
+
payload: TokenPayload,
|
|
33
|
+
allowedRoles: readonly UserRole[],
|
|
34
|
+
): Result<TokenPayload, AppError> => {
|
|
35
|
+
if (!allowedRoles.includes(payload.role)) {
|
|
36
|
+
return err(forbidden("Insufficient permissions"));
|
|
37
|
+
}
|
|
38
|
+
return { ok: true, value: payload };
|
|
39
|
+
};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { AppConfig } from "../../infrastructure/config/config.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* CORS handling — origin validation, preflight support.
|
|
5
|
+
* Returns headers to merge, or null if origin is rejected.
|
|
6
|
+
*/
|
|
7
|
+
export const corsHeaders = (
|
|
8
|
+
config: AppConfig,
|
|
9
|
+
requestOrigin: string | null,
|
|
10
|
+
_method: string,
|
|
11
|
+
): Record<string, string> | null => {
|
|
12
|
+
const allowedOrigins = config.cors.origins;
|
|
13
|
+
|
|
14
|
+
// If wildcard, allow everything
|
|
15
|
+
const isAllowed =
|
|
16
|
+
allowedOrigins.includes("*") ||
|
|
17
|
+
(requestOrigin !== null && allowedOrigins.includes(requestOrigin));
|
|
18
|
+
|
|
19
|
+
if (!isAllowed && requestOrigin !== null) return null;
|
|
20
|
+
|
|
21
|
+
const headers: Record<string, string> = {
|
|
22
|
+
"Access-Control-Allow-Origin": requestOrigin ?? "*",
|
|
23
|
+
"Access-Control-Allow-Credentials": "true",
|
|
24
|
+
"Access-Control-Allow-Methods": "GET, POST, PUT, PATCH, DELETE, OPTIONS",
|
|
25
|
+
"Access-Control-Allow-Headers": "Content-Type, Authorization, X-Request-Id",
|
|
26
|
+
"Access-Control-Max-Age": "86400",
|
|
27
|
+
Vary: "Origin",
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
return headers;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/** Returns a 204 preflight response or null if not a preflight */
|
|
34
|
+
export const handlePreflight = (config: AppConfig, req: Request): Response | null => {
|
|
35
|
+
if (req.method !== "OPTIONS") return null;
|
|
36
|
+
|
|
37
|
+
const origin = req.headers.get("origin");
|
|
38
|
+
const headers = corsHeaders(config, origin, req.method);
|
|
39
|
+
if (!headers) return new Response(null, { status: 403 });
|
|
40
|
+
return new Response(null, { status: 204, headers });
|
|
41
|
+
};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export { securityHeaders } from "./security-headers.js";
|
|
2
|
+
export { corsHeaders, handlePreflight } from "./cors.js";
|
|
3
|
+
export {
|
|
4
|
+
checkRateLimit,
|
|
5
|
+
rateLimitHeaders,
|
|
6
|
+
resetRateLimitStore,
|
|
7
|
+
type RateLimitResult,
|
|
8
|
+
} from "./rate-limit.js";
|
|
9
|
+
export { authenticate, authorise } from "./auth.js";
|
|
10
|
+
export { validateBody } from "./validate.js";
|
|
11
|
+
export { authenticateApiKey } from "./api-key.js";
|
|
12
|
+
export { addVersionHeaders, resolveApiVersion, normalizeVersionedPath } from "./versioning.js";
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { type AppError, rateLimited } from "../../core/errors/app-error.js";
|
|
2
|
+
import { type Result, err, ok } from "../../core/types/result.js";
|
|
3
|
+
import type { AppConfig } from "../../infrastructure/config/config.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Fixed-window rate limiter — in-memory, O(1) per check.
|
|
7
|
+
* Automatically prunes expired entries.
|
|
8
|
+
*/
|
|
9
|
+
interface RateLimitEntry {
|
|
10
|
+
count: number;
|
|
11
|
+
resetAt: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const store = new Map<string, RateLimitEntry>();
|
|
15
|
+
let lastPrune = Date.now();
|
|
16
|
+
|
|
17
|
+
const prune = (): void => {
|
|
18
|
+
const now = Date.now();
|
|
19
|
+
if (now - lastPrune < 60_000) return; // prune at most once per minute
|
|
20
|
+
lastPrune = now;
|
|
21
|
+
for (const [key, entry] of store) {
|
|
22
|
+
if (entry.resetAt <= now) store.delete(key);
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export interface RateLimitResult {
|
|
27
|
+
readonly remaining: number;
|
|
28
|
+
readonly resetAt: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export const checkRateLimit = (
|
|
32
|
+
config: AppConfig,
|
|
33
|
+
key: string,
|
|
34
|
+
): Result<RateLimitResult, AppError> => {
|
|
35
|
+
prune();
|
|
36
|
+
|
|
37
|
+
const now = Date.now();
|
|
38
|
+
const windowMs = config.rateLimit.windowMs;
|
|
39
|
+
const max = config.rateLimit.maxRequests;
|
|
40
|
+
|
|
41
|
+
let entry = store.get(key);
|
|
42
|
+
if (!entry || entry.resetAt <= now) {
|
|
43
|
+
entry = { count: 0, resetAt: now + windowMs };
|
|
44
|
+
store.set(key, entry);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
entry.count++;
|
|
48
|
+
|
|
49
|
+
if (entry.count > max) {
|
|
50
|
+
return err(rateLimited());
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return ok({ remaining: max - entry.count, resetAt: entry.resetAt });
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export const rateLimitHeaders = (result: RateLimitResult, max: number): Record<string, string> => ({
|
|
57
|
+
"X-RateLimit-Limit": String(max),
|
|
58
|
+
"X-RateLimit-Remaining": String(Math.max(0, result.remaining)),
|
|
59
|
+
"X-RateLimit-Reset": String(Math.ceil(result.resetAt / 1000)),
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
/** Reset for testing */
|
|
63
|
+
export const resetRateLimitStore = (): void => {
|
|
64
|
+
store.clear();
|
|
65
|
+
};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { AppConfig } from "../../infrastructure/config/config.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Security headers — equivalent to helmet but zero deps.
|
|
5
|
+
* Applied to every response.
|
|
6
|
+
*/
|
|
7
|
+
export const securityHeaders = (_config: AppConfig): Record<string, string> => ({
|
|
8
|
+
"X-Content-Type-Options": "nosniff",
|
|
9
|
+
"X-Frame-Options": "DENY",
|
|
10
|
+
"X-XSS-Protection": "0",
|
|
11
|
+
"Strict-Transport-Security": "max-age=63072000; includeSubDomains; preload",
|
|
12
|
+
"Content-Security-Policy": "default-src 'none'; frame-ancestors 'none'",
|
|
13
|
+
"Referrer-Policy": "strict-origin-when-cross-origin",
|
|
14
|
+
"Permissions-Policy": "camera=(), microphone=(), geolocation=()",
|
|
15
|
+
"Cache-Control": "no-store",
|
|
16
|
+
Pragma: "no-cache",
|
|
17
|
+
"X-Permitted-Cross-Domain-Policies": "none",
|
|
18
|
+
});
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { ZodSchema } from "zod";
|
|
2
|
+
import { type AppError, validation } from "../../core/errors/app-error.js";
|
|
3
|
+
import { type Result, err, ok } from "../../core/types/result.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Validate an unknown body against a Zod schema.
|
|
7
|
+
* Returns a typed Result — never throws.
|
|
8
|
+
*/
|
|
9
|
+
export const validateBody = <T>(schema: ZodSchema<T>, body: unknown): Result<T, AppError> => {
|
|
10
|
+
const result = schema.safeParse(body);
|
|
11
|
+
if (!result.success) {
|
|
12
|
+
const issues = result.error.flatten();
|
|
13
|
+
return err(validation(issues as unknown as Record<string, unknown>));
|
|
14
|
+
}
|
|
15
|
+
return ok(result.data);
|
|
16
|
+
};
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* API versioning middleware.
|
|
3
|
+
*
|
|
4
|
+
* Supports URL-based versioning (/api/v1/..., /api/v2/...) with:
|
|
5
|
+
* - Deprecation headers on v1 endpoints
|
|
6
|
+
* - API-Version response header
|
|
7
|
+
* - Sunset date for v1 (configurable)
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** Sunset date for v1 — 6 months from v2.0 release */
|
|
11
|
+
const V1_SUNSET = "2025-12-31";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Add API version headers to a response.
|
|
15
|
+
* - `API-Version`: which version served the request (v1 or v2)
|
|
16
|
+
* - `Deprecation`: added to v1 responses
|
|
17
|
+
* - `Sunset`: when v1 will be removed
|
|
18
|
+
* - `Link`: points to v2 equivalent for v1 calls
|
|
19
|
+
*/
|
|
20
|
+
export const addVersionHeaders = (
|
|
21
|
+
response: Response,
|
|
22
|
+
version: "v1" | "v2",
|
|
23
|
+
v2Path?: string,
|
|
24
|
+
): Response => {
|
|
25
|
+
response.headers.set("API-Version", version);
|
|
26
|
+
|
|
27
|
+
if (version === "v1") {
|
|
28
|
+
response.headers.set("Deprecation", "true");
|
|
29
|
+
response.headers.set("Sunset", V1_SUNSET);
|
|
30
|
+
if (v2Path) {
|
|
31
|
+
response.headers.set("Link", `<${v2Path}>; rel="successor-version"`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return response;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Resolve the effective API version from a request.
|
|
40
|
+
* Priority:
|
|
41
|
+
* 1. URL path (/api/v1/ vs /api/v2/)
|
|
42
|
+
* 2. Accept-Version header
|
|
43
|
+
* 3. Default: v2
|
|
44
|
+
*/
|
|
45
|
+
export const resolveApiVersion = (path: string, req: Request): "v1" | "v2" => {
|
|
46
|
+
if (path.startsWith("/api/v1/")) return "v1";
|
|
47
|
+
if (path.startsWith("/api/v2/")) return "v2";
|
|
48
|
+
|
|
49
|
+
const acceptVersion = req.headers.get("Accept-Version");
|
|
50
|
+
if (acceptVersion === "v1" || acceptVersion === "1") return "v1";
|
|
51
|
+
|
|
52
|
+
return "v2";
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Rewrite a v2 path to v1 for internal routing when handlers are shared.
|
|
57
|
+
* /api/v2/auth/login → /api/v1/auth/login
|
|
58
|
+
*/
|
|
59
|
+
export const normalizeVersionedPath = (
|
|
60
|
+
path: string,
|
|
61
|
+
): { normalized: string; version: "v1" | "v2" } => {
|
|
62
|
+
if (path.startsWith("/api/v2/")) {
|
|
63
|
+
return { normalized: `/api/v1/${path.substring(8)}`, version: "v2" };
|
|
64
|
+
}
|
|
65
|
+
if (path.startsWith("/api/v1/")) {
|
|
66
|
+
return { normalized: path, version: "v1" };
|
|
67
|
+
}
|
|
68
|
+
return { normalized: path, version: "v2" };
|
|
69
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { createRouter, type Router } from "./router.js";
|