Skip to content
Open
Show file tree
Hide file tree
Changes from 17 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
5f75125
refactor: decouple error classes from i18n and logging
goastler Jun 4, 2026
8843210
refactor: complete error/i18n decoupling
goastler Jun 4, 2026
dec3ae4
fix(locale): repair TranslationKey type and getLeafFieldPath runtime bug
goastler Jun 5, 2026
635e16c
fix(locale): add missing translation keys to en/translation.json
goastler Jun 5, 2026
f5d8eaf
feat: enforce TranslationKey type in ProsopoBaseError constructors
goastler Jun 5, 2026
c67f744
docs: document static-analysis assumption in VitePluginRemoveUnusedTr…
goastler Jun 5, 2026
661ecc3
refactor: split loadI18next into named frontend/backend functions
goastler Jun 5, 2026
bba2517
feat: implement hybrid error pattern with typed keys and i18n race co…
goastler Jun 9, 2026
8077f22
refactor: add type-safe error key constants with compile-time validation
goastler Jun 9, 2026
c903ae0
refactor: add JSON-level type checking for error keys
goastler Jun 9, 2026
d9d2bcf
Merge main into refactor/decouple-error-i18n - resolve conflicts
goastler Jun 9, 2026
d26c344
Merge remote-tracking branch 'origin/main' into refactor/decouple-err…
goastler Jun 11, 2026
288b056
refactor: complete error/i18n decoupling and sync locale keys
goastler Jun 11, 2026
8a280bb
style: apply biome formatting to errorKeys, locale index, bundle vite…
goastler Jun 11, 2026
b2a2045
fix(ci): sync tsconfig refs/deps, drop demo logger option, expect err…
goastler Jun 11, 2026
b466636
fix(provider-mock): drop obsolete logLevel error option
goastler Jun 11, 2026
6e26e96
refactor(common): ProsopoApiError accepts TranslationKey only, not Error
goastler Jun 12, 2026
04a55e7
refactor(common): uniform error model — required TranslationKey + opt…
goastler Jun 12, 2026
46b2538
fix: address PR review comments
goastler Jun 12, 2026
1dffa97
fix: address PR review comments
goastler Jun 12, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .changeset/decouple-error-i18n.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
---
"@prosopo/api-express-router": patch
"@prosopo/procaptcha-frictionless": patch
"@prosopo/procaptcha-bundle": patch
"@prosopo/procaptcha-react": patch
"@prosopo/types-database": patch
"@prosopo/procaptcha-pow": patch
"@prosopo/database": patch
"@prosopo/provider": patch
"@prosopo/common": patch
"@prosopo/locale": patch
"@prosopo/env": patch
"@prosopo/cli": patch
---

Decouple error classes from i18n and logging, and move translations into a conventional i18n structure.

- `ProsopoBaseError` and its subclasses no longer translate or log at construction time. They carry a `translationKey` and a fallback `message`; translation happens at the presentation layer (UI render or HTTP response via `unwrapError`).
- Removed the `i18n`, `logger` and `logLevel` constructor options from the error classes; callers log explicitly via their own logger.
- Error keys are validated against the translation files at compile time (`TranslationKey`), and the curated backend error-key registry (`BACKEND_ERROR_KEYS_ARRAY`) is preserved in the frontend bundle.
- Added the translation keys referenced by backend errors to every locale so the locale key sets stay in sync.
1 change: 0 additions & 1 deletion demos/client-example-server/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,6 @@ async function main() {
if (!process.env.PROSOPO_SITE_PRIVATE_KEY) {
const mnemonicError = new ProsopoEnvError("GENERAL.MNEMONIC_UNDEFINED", {
context: { missingParams: ["PROSOPO_SITE_PRIVATE_KEY"] },
logger,
});

logger.error(() => ({ err: mnemonicError }));
Expand Down
1 change: 0 additions & 1 deletion demos/provider-mock/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,6 @@ export function prosopoRouter(): Router {
return next(
new ProsopoApiError("CAPTCHA.PARSE_ERROR", {
context: { error: err, code: 400 },
logLevel: "info",
}),
);
}
Expand Down
1 change: 0 additions & 1 deletion demos/provider-mock/src/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,6 @@ export class JA4Database extends MongoDatabase {
if (!this.tables) {
throw new ProsopoDBError("DATABASE.TABLES_UNDEFINED", {
context: { failedFuncName: this.getTables.name },
logger: this.logger,
});
}
return this.tables;
Expand Down
18 changes: 16 additions & 2 deletions dev/config/src/vite/vite-plugin-remove-unused-translations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,10 +63,22 @@ export const unflatten = (
return result;
};

// This plugin uses static string scanning: it scans each source file for the
// presence of each known translation key as a literal substring. It only works
// for keys that appear verbatim in the bundled source — keys constructed at
// runtime (e.g. template literals, variable lookups, or keys from API responses)
// will be treated as unused and stripped. Add such keys to translationKeys
// statically, or they will be missing from the bundle.
//
// Backend error keys are always preserved in the bundle since they're returned
// by the server and not found in frontend source code.
export default function VitePluginRemoveUnusedTranslations(
translationKeys: string[],
jsonPattern: string,
backendErrorKeys?: string[],
): Plugin {
const backendKeys = new Set(backendErrorKeys || []);

return {
name: "remove-unused-translations",
transform(code: string) {
Expand Down Expand Up @@ -94,9 +106,11 @@ export default function VitePluginRemoveUnusedTranslations(
const jsonData = JSON.parse(content);
const jsonDataFlattened = flatten(jsonData);

// Remove keys that are not in `used`
// Keep keys that are either used in frontend code or are backend error keys
const filteredData = Object.fromEntries(
Object.entries(jsonDataFlattened).filter(([key]) => used.has(key)),
Object.entries(jsonDataFlattened).filter(
([key]) => used.has(key) || backendKeys.has(key),
),
);

const unflattened = unflatten(filteredData);
Expand Down
9 changes: 4 additions & 5 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 0 additions & 1 deletion packages/api-express-router/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,6 @@
"@prosopo/common": "3.1.38",
"@prosopo/logger": "1.0.2",
"@prosopo/env": "3.5.10",
"@prosopo/locale": "3.2.4",
"@prosopo/types": "4.4.0",
"@prosopo/util-crypto": "13.5.29",
"dotenv": "16.4.5",
Expand Down
3 changes: 2 additions & 1 deletion packages/api-express-router/src/errorHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@ export const handleErrors = (
response: Response,
next: NextFunction,
) => {
const { code, statusMessage, jsonError } = unwrapError(err, request.i18n);
request.logger.error(() => ({ err }));
const { code, statusMessage, jsonError } = unwrapError(err);
response.statusMessage = statusMessage;
response.set("content-type", "application/json");
response.status(code);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ export const authMiddleware = (

res.status(401).json({
error: new ProsopoEnvError(error || "API.UNAUTHORIZED", {
context: { i18n: req.i18n, code: 401 },
context: { code: 401 },
}),
});
return;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,18 +13,17 @@
// limitations under the License.

import { ProsopoApiError, ProsopoEnvError } from "@prosopo/common";
import { loadI18next } from "@prosopo/locale";
import type { NextFunction, Request, Response } from "express";
import { describe, expect, it, vi } from "vitest";
import { ZodError } from "zod";
import { handleErrors } from "../../errorHandler.js";

describe("handleErrors", async () => {
const i18n = await loadI18next(true);
await i18n.changeLanguage("en");
describe("handleErrors", () => {
const mockRequest = {
logger: { error: vi.fn() },
} as unknown as Request;

it("should handle ProsopoApiError", async () => {
const mockRequest = { i18n } as unknown as Request;
it("should handle ProsopoApiError", () => {
const mockResponse = {
writeHead: vi.fn().mockReturnThis(),
set: vi.fn().mockReturnThis(),
Expand All @@ -36,9 +35,7 @@ describe("handleErrors", async () => {

const error = new ProsopoApiError("CONTRACT.INVALID_DATA_FORMAT", {
context: { code: 400 },
i18n,
});
console.log(error);

handleErrors(error, mockRequest, mockResponse, mockNext);

Expand All @@ -50,15 +47,14 @@ describe("handleErrors", async () => {
error: {
code: 400,
key: "CONTRACT.INVALID_DATA_FORMAT",
message: "Invalid data format",
message: "CONTRACT.INVALID_DATA_FORMAT",
},
});
expect(mockResponse.status).toHaveBeenCalledWith(400);
expect(mockResponse.end).toHaveBeenCalled();
});

it("should not return SyntaxError", async () => {
const mockRequest = { i18n } as unknown as Request;
it("should not return SyntaxError", () => {
const mockResponse = {
writeHead: vi.fn().mockReturnThis(),
set: vi.fn().mockReturnThis(),
Expand Down Expand Up @@ -89,7 +85,6 @@ describe("handleErrors", async () => {
});

it("should handle ZodError", () => {
const mockRequest = { i18n } as unknown as Request;
const mockResponse = {
writeHead: vi.fn().mockReturnThis(),
set: vi.fn().mockReturnThis(),
Expand Down Expand Up @@ -119,8 +114,7 @@ describe("handleErrors", async () => {
expect(mockResponse.end).toHaveBeenCalled();
});

it("should unwrap nested ProsopoBaseError", async () => {
const mockRequest = { i18n } as unknown as Request;
it("should unwrap nested ProsopoBaseError", () => {
const mockResponse = {
writeHead: vi.fn().mockReturnThis(),
set: vi.fn().mockReturnThis(),
Expand All @@ -131,9 +125,11 @@ describe("handleErrors", async () => {
const mockNext = vi.fn() as unknown as NextFunction;

const envError = new ProsopoEnvError("GENERAL.ENVIRONMENT_NOT_READY", {
i18n,
context: { code: 500 },
});
const apiError = new ProsopoApiError("API.UNKNOWN", {
context: { error: envError },
});
const apiError = new ProsopoApiError(envError);

handleErrors(apiError, mockRequest, mockResponse, mockNext);

Expand All @@ -146,14 +142,13 @@ describe("handleErrors", async () => {
error: {
code: 500,
key: "GENERAL.ENVIRONMENT_NOT_READY",
message: "Environment not ready",
message: "GENERAL.ENVIRONMENT_NOT_READY",
},
});
expect(mockResponse.end).toHaveBeenCalled();
});

it("should unwrap nested ProsopoBaseErrors but not an Error that is nested inside them", async () => {
const mockRequest = { i18n } as unknown as Request;
it("should unwrap nested ProsopoBaseErrors but not an Error that is nested inside them", () => {
const mockResponse = {
writeHead: vi.fn().mockReturnThis(),
set: vi.fn().mockReturnThis(),
Expand All @@ -168,7 +163,6 @@ describe("handleErrors", async () => {
const error = new Error("Some error");
const apiError = new ProsopoApiError(key, {
context: { code, error },
i18n,
});

handleErrors(apiError, mockRequest, mockResponse, mockNext);
Expand All @@ -182,7 +176,7 @@ describe("handleErrors", async () => {
error: {
code,
key,
message: "Unknown API error",
message: "API.UNKNOWN",
},
});
expect(mockResponse.end).toHaveBeenCalled();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@ describe("authMiddleware", () => {
expect(mockRes.status).toHaveBeenCalledWith(401);
expect(mockRes.json).toHaveBeenCalledWith({
error: new ProsopoEnvError("API.UNAUTHORIZED", {
context: { i18n: mockReq.i18n, code: 401 },
context: { code: 401 },
}),
});
});
Expand Down Expand Up @@ -176,7 +176,7 @@ describe("authMiddleware", () => {
expect(mockRes.status).toHaveBeenCalledWith(401);
expect(mockRes.json).toHaveBeenCalledWith({
error: new ProsopoEnvError("API.UNAUTHORIZED", {
context: { i18n: mockReq.i18n, code: 401 },
context: { code: 401 },
}),
});
});
Expand Down
3 changes: 0 additions & 3 deletions packages/api-express-router/tsconfig.cjs.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,6 @@
{
"path": "../env/tsconfig.cjs.json"
},
{
"path": "../locale/tsconfig.cjs.json"
},
{
"path": "../types/tsconfig.cjs.json"
},
Expand Down
3 changes: 0 additions & 3 deletions packages/api-express-router/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,6 @@
{
"path": "../env"
},
{
"path": "../locale"
},
{
"path": "../types"
},
Expand Down
4 changes: 2 additions & 2 deletions packages/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
import process from "node:process";
import { loadEnv } from "@prosopo/dotenv";
import { getPair } from "@prosopo/keyring";
import { loadI18next } from "@prosopo/locale";
import { loadI18nextBackend } from "@prosopo/locale";
import { LogLevel, getLogger } from "@prosopo/logger";
import type { ProsopoConfigOutput } from "@prosopo/types";
import { isMain } from "@prosopo/util";
Expand Down Expand Up @@ -59,7 +59,7 @@ async function main() {

//if main process
if (isMain(import.meta.url, "provider")) {
loadI18next(true).then(() => {
loadI18nextBackend().then(() => {
main()
.then(() => {
log.info(() => ({ msg: "Running main process..." }));
Expand Down
3 changes: 1 addition & 2 deletions packages/common/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,12 +34,11 @@
"license": "Apache-2.0",
"dependencies": {
"@prosopo/locale": "3.2.4",
"@prosopo/logger": "1.0.2",
"i18next": "24.1.0",
"zod": "3.23.8"
},
"devDependencies": {
"@prosopo/config": "3.3.1",
"@prosopo/logger": "1.0.2",
"@types/node": "22.10.2",
"@prosopo/types": "4.4.0",
"@vitest/coverage-v8": "3.2.4",
Expand Down
Loading
Loading