Merge branch 'deep-ketch' into development
CI/CD Pipeline / i18n Catalog Check (push) Successful in 38s
CI/CD Pipeline / Build and Push App Docker Image (push) Successful in 3m11s
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 14s
CI/CD Pipeline / Deploy to Kubernetes (push) Successful in 8m31s
CI/CD Pipeline / i18n Catalog Check (push) Successful in 38s
CI/CD Pipeline / Build and Push App Docker Image (push) Successful in 3m11s
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 14s
CI/CD Pipeline / Deploy to Kubernetes (push) Successful in 8m31s
This commit is contained in:
+18
-3
@@ -32,6 +32,8 @@ export function resolveLocale(candidate: string | null | undefined): AppLocale {
|
||||
: DEFAULT_LOCALE;
|
||||
}
|
||||
|
||||
const inflightLoads: Partial<Record<AppLocale, Promise<Messages>>> = {};
|
||||
|
||||
export async function ensureMessages(locale: AppLocale): Promise<Messages> {
|
||||
const cached = messageCache[locale];
|
||||
if (cached) return cached;
|
||||
@@ -39,11 +41,24 @@ export async function ensureMessages(locale: AppLocale): Promise<Messages> {
|
||||
const loader = catalogLoaders[`/src/locales/${locale}/messages.po`];
|
||||
if (!loader) return messageCache[DEFAULT_LOCALE]!;
|
||||
|
||||
const { messages } = await loader();
|
||||
messageCache[locale] = messages;
|
||||
return messages;
|
||||
const pending = (inflightLoads[locale] ??= loader()
|
||||
.then(({ messages }) => {
|
||||
messageCache[locale] = messages;
|
||||
return messages;
|
||||
})
|
||||
.finally(() => {
|
||||
delete inflightLoads[locale];
|
||||
}));
|
||||
return pending;
|
||||
}
|
||||
|
||||
export function primeMessages(locale: AppLocale, messages: Messages) {
|
||||
messageCache[locale] ??= messages;
|
||||
}
|
||||
|
||||
export const i18nMessagesQueryKey = (locale: AppLocale) =>
|
||||
["i18n-messages", locale] as const;
|
||||
|
||||
export function getCachedMessages(locale: AppLocale): Messages {
|
||||
return messageCache[locale] ?? messageCache[DEFAULT_LOCALE]!;
|
||||
}
|
||||
|
||||
+28
-10
@@ -1,11 +1,14 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { I18nProvider } from "@lingui/react";
|
||||
import type { Messages } from "@lingui/core";
|
||||
import { playerQueries } from "@/features/players/queries";
|
||||
import {
|
||||
createI18n,
|
||||
ensureMessages,
|
||||
hasCachedMessages,
|
||||
i18nMessagesQueryKey,
|
||||
primeMessages,
|
||||
resolveLocale,
|
||||
} from "@/lib/i18n";
|
||||
|
||||
@@ -29,27 +32,42 @@ export const LinguiProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const locale = resolveLocale(data?.metadata?.locale);
|
||||
const [catalogVersion, setCatalogVersion] = useState(0);
|
||||
|
||||
// Fresh instance per locale (and per SSR request tree). __root beforeLoad
|
||||
// preloads the catalog on authed routes; on public routes (login) it may
|
||||
// not be cached yet — createI18n falls back to en until the effect below
|
||||
// loads it and bumps catalogVersion.
|
||||
const i18n = useMemo(
|
||||
() => createI18n(locale),
|
||||
() => {
|
||||
if (!hasCachedMessages(locale)) {
|
||||
const seeded = queryClient.getQueryData<Messages>(
|
||||
i18nMessagesQueryKey(locale)
|
||||
);
|
||||
if (seeded) primeMessages(locale, seeded);
|
||||
}
|
||||
return createI18n(locale);
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[locale, catalogVersion]
|
||||
[locale, catalogVersion, queryClient]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasCachedMessages(locale)) return;
|
||||
let cancelled = false;
|
||||
ensureMessages(locale).then(() => {
|
||||
if (!cancelled) setCatalogVersion((v) => v + 1);
|
||||
});
|
||||
let retryTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
const load = () => {
|
||||
ensureMessages(locale).then(
|
||||
() => {
|
||||
if (!cancelled) setCatalogVersion((v) => v + 1);
|
||||
},
|
||||
() => {
|
||||
if (!cancelled) retryTimer = setTimeout(load, 3000);
|
||||
}
|
||||
);
|
||||
};
|
||||
load();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (retryTimer) clearTimeout(retryTimer);
|
||||
};
|
||||
}, [locale]);
|
||||
|
||||
|
||||
@@ -19,15 +19,30 @@ class PocketBaseAdminClient {
|
||||
this.pb = new PocketBase(process.env.POCKETBASE_URL);
|
||||
|
||||
this.pb.beforeSend = async (url, options) => {
|
||||
await this.authPromise;
|
||||
|
||||
if (this.pb.authStore.isValid && this.isTokenExpiringSoon()) {
|
||||
// Auth requests skip the gate: awaiting authPromise here would deadlock them.
|
||||
if (!url.includes("/collections/_superusers/auth-")) {
|
||||
try {
|
||||
await this.refreshAuth();
|
||||
} catch (error) {
|
||||
console.error('Failed to refresh admin token, re-authenticating:', error);
|
||||
await this.authenticate();
|
||||
await this.authPromise;
|
||||
} catch {}
|
||||
|
||||
if (!this.pb.authStore.isValid) {
|
||||
this.authPromise = this.authenticate();
|
||||
await this.authPromise;
|
||||
this.startTokenRefresh();
|
||||
} else if (this.isTokenExpiringSoon()) {
|
||||
try {
|
||||
await this.refreshAuth();
|
||||
} catch (error) {
|
||||
console.error('Failed to refresh admin token, re-authenticating:', error);
|
||||
this.authPromise = this.authenticate();
|
||||
await this.authPromise;
|
||||
}
|
||||
}
|
||||
|
||||
options.headers = {
|
||||
...options.headers,
|
||||
Authorization: this.pb.authStore.token,
|
||||
};
|
||||
}
|
||||
|
||||
options.cache = "no-store";
|
||||
@@ -54,9 +69,16 @@ class PocketBaseAdminClient {
|
||||
Object.assign(this, createPushService(this.pb));
|
||||
|
||||
this.authPromise = this.authenticate();
|
||||
this.authPromise.then(() => {
|
||||
this.startTokenRefresh();
|
||||
});
|
||||
this.authPromise
|
||||
.then(() => {
|
||||
this.startTokenRefresh();
|
||||
})
|
||||
.catch(() => {
|
||||
console.error(
|
||||
"PocketBase admin authentication failed - is PocketBase running and are " +
|
||||
"POCKETBASE_URL / POCKETBASE_ADMIN_EMAIL / POCKETBASE_ADMIN_PASSWORD set?"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
private async authenticate() {
|
||||
@@ -98,6 +120,8 @@ class PocketBaseAdminClient {
|
||||
}
|
||||
|
||||
private startTokenRefresh() {
|
||||
if (this.refreshInterval) return;
|
||||
|
||||
this.refreshInterval = setInterval(async () => {
|
||||
try {
|
||||
await this.refreshAuth();
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import PocketBase from "pocketbase";
|
||||
import { PlayerInfo } from "@/features/players/types";
|
||||
import { pbFilter } from "../util/filter";
|
||||
import { likePattern } from "../util/like-pattern";
|
||||
|
||||
export interface Activity {
|
||||
id: string;
|
||||
@@ -61,15 +63,15 @@ export function createActivitiesService(pb: PocketBase) {
|
||||
const filters: string[] = [];
|
||||
|
||||
if (name) {
|
||||
filters.push(`name ~ "${name}"`);
|
||||
filters.push(pbFilter(pb, "name ~ {:name}", { name: likePattern(name) }));
|
||||
}
|
||||
|
||||
if (player) {
|
||||
filters.push(`player = "${player}"`);
|
||||
filters.push(pbFilter(pb, "player = {:player}", { player }));
|
||||
}
|
||||
|
||||
if (success !== undefined) {
|
||||
filters.push(`success = ${success}`);
|
||||
filters.push(pbFilter(pb, "success = {:success}", { success }));
|
||||
}
|
||||
|
||||
const filterString = filters.length > 0 ? filters.join(" && ") : "";
|
||||
@@ -98,7 +100,7 @@ export function createActivitiesService(pb: PocketBase) {
|
||||
|
||||
async getActivitiesByUser(userId: string, limit: number = 50): Promise<Activity[]> {
|
||||
const result = await pb.collection("activities").getList<Activity>(1, limit, {
|
||||
filter: `player = "${userId}"`,
|
||||
filter: pbFilter(pb, "player = {:userId}", { userId }),
|
||||
sort: "-created",
|
||||
});
|
||||
return result.items;
|
||||
@@ -106,7 +108,7 @@ export function createActivitiesService(pb: PocketBase) {
|
||||
|
||||
async getActivitiesByFunction(functionName: string, limit: number = 50): Promise<Activity[]> {
|
||||
const result = await pb.collection("activities").getList<Activity>(1, limit, {
|
||||
filter: `name = "${functionName}"`,
|
||||
filter: pbFilter(pb, "name = {:functionName}", { functionName }),
|
||||
sort: "-created",
|
||||
});
|
||||
return result.items;
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import PocketBase from "pocketbase";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { pbFilter } from "./filter";
|
||||
|
||||
// Real SDK, not a stub: the escaping under test lives in PocketBase's own
|
||||
// replaceAll.
|
||||
const pb = new PocketBase("http://pocketbase.test");
|
||||
|
||||
const quotedLiteral = (expression: string) => {
|
||||
const open = expression.indexOf("'");
|
||||
let literal = "";
|
||||
for (let i = open + 1; i < expression.length; i++) {
|
||||
if (expression[i] === "\\") {
|
||||
literal += expression[i + 1];
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (expression[i] === "'") return literal;
|
||||
literal += expression[i];
|
||||
}
|
||||
throw new Error(`unterminated literal in ${expression}`);
|
||||
};
|
||||
|
||||
describe("pbFilter", () => {
|
||||
it.each([
|
||||
["a match-substitution pattern", "a$&b"],
|
||||
["a preceding-input pattern", "a$`b"],
|
||||
["a following-input pattern", "a$'b"],
|
||||
["an escaped-dollar pattern", "a$$b"],
|
||||
["a bare dollar", "a$b"],
|
||||
["nothing but a dollar", "$"],
|
||||
["a dollar beside a metacharacter", "50$%"],
|
||||
])("substitutes %s as a literal", (_label, term) => {
|
||||
const expression = pbFilter(pb, "first_name ~ {:query}", { query: term });
|
||||
|
||||
expect(quotedLiteral(expression)).toBe(term);
|
||||
expect(expression).not.toContain("{:query}");
|
||||
});
|
||||
|
||||
it("keeps both dollars of an escaped-dollar pattern", () => {
|
||||
expect(pbFilter(pb, "f ~ {:q}", { q: "a$$b" })).toBe("f ~ 'a$$b'");
|
||||
});
|
||||
|
||||
it("splices no part of the surrounding expression into the literal", () => {
|
||||
const expression = pbFilter(pb, "first_name ~ {:query}", {
|
||||
query: "a$`b",
|
||||
});
|
||||
|
||||
expect(expression).toBe("first_name ~ 'a$`b'");
|
||||
expect(quotedLiteral(expression)).not.toContain("first_name");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["a single quote", "o'brien"],
|
||||
["a trailing backslash", "ada\\"],
|
||||
["a percent sign", "50%"],
|
||||
])("escapes %s exactly as the SDK does", (_label, term) => {
|
||||
expect(pbFilter(pb, "f ~ {:q}", { q: term })).toBe(
|
||||
pb.filter("f ~ {:q}", { q: term })
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["a number", 42],
|
||||
["a boolean", true],
|
||||
["null", null],
|
||||
["a date", new Date(Date.UTC(2024, 0, 2, 3, 4, 5, 678))],
|
||||
])("renders %s identically to pb.filter", (_label, value) => {
|
||||
expect(pbFilter(pb, "f = {:v}", { v: value })).toBe(
|
||||
pb.filter("f = {:v}", { v: value })
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves a placeholder with no matching parameter verbatim", () => {
|
||||
expect(pbFilter(pb, "a = {:missing}", {})).toBe("a = {:missing}");
|
||||
expect(pbFilter(pb, "a = {:missing}", {})).toBe(
|
||||
pb.filter("a = {:missing}", {})
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["a hyphen", "user-id"],
|
||||
["a dot", "meta.user"],
|
||||
["a digit and underscore", "auth_id2"],
|
||||
])("resolves a key containing %s the way the SDK does", (_label, key) => {
|
||||
const raw = `a = {:${key}}`;
|
||||
|
||||
expect(pbFilter(pb, raw, { [key]: "v" })).toBe("a = 'v'");
|
||||
expect(pbFilter(pb, raw, { [key]: "v" })).toBe(pb.filter(raw, { [key]: "v" }));
|
||||
});
|
||||
|
||||
it("does not absorb another parameter's value into a literal", () => {
|
||||
expect(
|
||||
pbFilter(pb, "a = {:x} && b = {:y}", { x: "%{:y}%", y: "SECRET" })
|
||||
).toBe("a = '%{:y}%' && b = 'SECRET'");
|
||||
});
|
||||
|
||||
it("substitutes every occurrence of a repeated placeholder", () => {
|
||||
expect(
|
||||
pbFilter(pb, "(first_name ~ {:q} || last_name ~ {:q})", { q: "%ada%" })
|
||||
).toBe("(first_name ~ '%ada%' || last_name ~ '%ada%')");
|
||||
});
|
||||
});
|
||||
|
||||
// Pins the SDK behaviour the doubling in `quote` compensates for. If an upgrade
|
||||
// fixes the expansion upstream, these fail rather than the doubling silently
|
||||
// becoming a double-escape.
|
||||
describe("the pocketbase SDK substitution this works around", () => {
|
||||
it("still mis-expands a dollar pattern in a parameter value", () => {
|
||||
expect(pb.filter("a ~ {:q}", { q: "x$&y" })).toContain("{:q}");
|
||||
});
|
||||
|
||||
it("still splices a later parameter into an earlier literal", () => {
|
||||
expect(pb.filter("a = {:x} && b = {:y}", { x: "%{:y}%", y: "SECRET" })).toBe(
|
||||
"a = '%'SECRET'%' && b = 'SECRET'"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import type PocketBase from "pocketbase";
|
||||
|
||||
export type FilterParam = string | number | boolean | Date | null;
|
||||
|
||||
const PLACEHOLDER = /\{:([^}]+)\}/g;
|
||||
|
||||
// pb.filter $-expands string replacements; doubling `$` keeps the value literal.
|
||||
const quote = (pb: PocketBase, value: FilterParam) =>
|
||||
pb.filter("{:v}", {
|
||||
v: typeof value === "string" ? value.replaceAll("$", () => "$$") : value,
|
||||
});
|
||||
|
||||
export const pbFilter = (
|
||||
pb: PocketBase,
|
||||
raw: string,
|
||||
params: Record<string, FilterParam>
|
||||
) =>
|
||||
raw.replace(PLACEHOLDER, (token, key: string) =>
|
||||
Object.hasOwn(params, key) ? quote(pb, params[key]) : token
|
||||
);
|
||||
@@ -0,0 +1,69 @@
|
||||
import PocketBase from "pocketbase";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { literalTerminates } from "@/test/pb-filter";
|
||||
import { pbFilter } from "./filter";
|
||||
import { likePattern } from "./like-pattern";
|
||||
|
||||
const compose = (term: string) =>
|
||||
pbFilter(new PocketBase("http://pocketbase.test"), "first_name ~ {:query}", {
|
||||
query: likePattern(term),
|
||||
});
|
||||
|
||||
describe("likePattern", () => {
|
||||
it("wraps a plain term so ~ matches a substring", () => {
|
||||
expect(likePattern("ada")).toBe("%ada%");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["a percent sign", "50%", "%50\\%%"],
|
||||
["an underscore", "_", "%\\_%"],
|
||||
["a backslash", "ada\\", "%ada\\\\%"],
|
||||
])("escapes %s so it matches literally", (_label, term, expected) => {
|
||||
expect(likePattern(term)).toBe(expected);
|
||||
});
|
||||
|
||||
it("always ends the operand with an unescaped wildcard", () => {
|
||||
// An odd run of backslashes before the final % would mean the % is itself
|
||||
// escaped, which is the shape that swallows the closing quote.
|
||||
for (const term of ["ada", "ada\\", "\\", "50%", "_", "o'brien\\"]) {
|
||||
const pattern = likePattern(term);
|
||||
const trailingSlashes = /(\\*)%$/.exec(pattern)?.[1] ?? "";
|
||||
|
||||
expect(pattern.endsWith("%")).toBe(true);
|
||||
expect(trailingSlashes.length % 2).toBe(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("likePattern composed through the real SDK", () => {
|
||||
it.each(["ada\\", "\\", "a_b\\", "o'brien\\", "ada\\\\"])(
|
||||
"keeps the filter expression parseable for %j",
|
||||
(term) => {
|
||||
expect(literalTerminates(compose(term))).toBe(true);
|
||||
}
|
||||
);
|
||||
|
||||
it("detects the unterminated literal a raw term produces", () => {
|
||||
const raw = new PocketBase("http://pocketbase.test").filter(
|
||||
"first_name ~ {:query}",
|
||||
{ query: "ada\\" }
|
||||
);
|
||||
|
||||
expect(raw).toBe("first_name ~ 'ada\\'");
|
||||
expect(literalTerminates(raw)).toBe(false);
|
||||
});
|
||||
|
||||
it("leaves a term without metacharacters exactly as before", () => {
|
||||
expect(compose("ada")).toBe("first_name ~ '%ada%'");
|
||||
});
|
||||
|
||||
it.each(["a$&b", "a$`b", "50$%", "a$&_b", "$"])(
|
||||
"carries %j through both escaping layers intact",
|
||||
(term) => {
|
||||
const composed = compose(term);
|
||||
|
||||
expect(composed).toContain(likePattern(term));
|
||||
expect(literalTerminates(composed)).toBe(true);
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
// Escapes LIKE metacharacters and wraps in `%` for a literal substring match.
|
||||
export const likePattern = (term: string) =>
|
||||
`%${term.replace(/[\\%_]/g, (char) => `\\${char}`)}%`;
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { redactValue, SENSITIVE_KEY } from "./redact";
|
||||
|
||||
// Shared scrubber for logs + audit rows - a regression leaks PII from both.
|
||||
describe("redactValue", () => {
|
||||
it("redacts sensitive keys and preserves benign ones", () => {
|
||||
expect(
|
||||
redactValue({ phone: "+17135550142", first_name: "Ada", token: "abc" })
|
||||
).toEqual({ phone: "[redacted]", first_name: "Ada", token: "[redacted]" });
|
||||
});
|
||||
|
||||
it("redacts recursively through nested objects and arrays", () => {
|
||||
expect(
|
||||
redactValue({
|
||||
user: { first_name: "Ada", password: "hunter2" },
|
||||
items: [{ authToken: "x" }, { label: "ok" }],
|
||||
})
|
||||
).toEqual({
|
||||
user: { first_name: "Ada", password: "[redacted]" },
|
||||
items: [{ authToken: "[redacted]" }, { label: "ok" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("passes primitives through untouched (a bare string is not assumed secret)", () => {
|
||||
expect(redactValue("hello")).toBe("hello");
|
||||
expect(redactValue(42)).toBe(42);
|
||||
expect(redactValue(null)).toBeNull();
|
||||
});
|
||||
|
||||
it("covers the documented sensitive keys", () => {
|
||||
for (const key of ["token", "secret", "password", "phone", "otp", "code", "auth", "key"]) {
|
||||
expect(SENSITIVE_KEY.test(key)).toBe(true);
|
||||
}
|
||||
expect(SENSITIVE_KEY.test("first_name")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
export const SENSITIVE_KEY = /token|secret|password|phone|otp|code|auth|key/i;
|
||||
|
||||
const REDACTED = "[redacted]";
|
||||
|
||||
export const redactValue = (value: unknown): unknown => {
|
||||
if (Array.isArray(value)) return value.map(redactValue);
|
||||
if (value && typeof value === "object") {
|
||||
if (value instanceof Error) {
|
||||
return { name: value.name, message: value.message, stack: value.stack };
|
||||
}
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [key, v] of Object.entries(value as Record<string, unknown>)) {
|
||||
out[key] = SENSITIVE_KEY.test(key) ? REDACTED : redactValue(v);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
return value;
|
||||
};
|
||||
@@ -1,6 +1,5 @@
|
||||
import { logger } from "../../logger";
|
||||
import { ErrorType, ServerError, ServerResult } from "../types";
|
||||
import { getRequest } from "@tanstack/react-start/server";
|
||||
import { isRedirect } from "@tanstack/react-router";
|
||||
|
||||
export const createServerError = (
|
||||
@@ -20,54 +19,15 @@ export const createServerError = (
|
||||
export const toServerResult = async <T>(
|
||||
serverFn: () => Promise<T>
|
||||
): Promise<ServerResult<T>> => {
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
const data = await serverFn();
|
||||
return { success: true, data };
|
||||
} catch (error) {
|
||||
if (isRedirect(error) || error instanceof Response) throw error;
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
logger.error('Server Fn Error', error);
|
||||
|
||||
const mappedError = mapKnownError(error);
|
||||
|
||||
let fnName = 'unknown';
|
||||
try {
|
||||
const request = getRequest();
|
||||
const url = new URL(request.url);
|
||||
|
||||
const functionId = url.searchParams.get('_serverFnId') || url.pathname;
|
||||
|
||||
if (functionId.includes('--')) {
|
||||
const match = functionId.match(/--([^_]+)_/);
|
||||
fnName = match?.[1] || functionId.split('--')[1]?.split('_')[0] || 'unknown';
|
||||
} else {
|
||||
fnName = serverFn.name || 'unknown';
|
||||
}
|
||||
} catch {
|
||||
fnName = serverFn.name || 'unknown';
|
||||
}
|
||||
|
||||
import("../../pocketbase/client")
|
||||
.then(async ({ pbAdmin }) => {
|
||||
await pbAdmin.authPromise;
|
||||
await pbAdmin.createActivity({
|
||||
name: fnName,
|
||||
duration,
|
||||
success: false,
|
||||
error: mappedError.message,
|
||||
arguments: {
|
||||
errorType: mappedError.code,
|
||||
statusCode: mappedError.statusCode,
|
||||
userMessage: mappedError.userMessage,
|
||||
},
|
||||
});
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
return { success: false, error: mappedError };
|
||||
return { success: false, error: mapKnownError(error) };
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user