mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 21:46:49 +00:00
refactor(db): JSON columns are decoded through one declared schema per column
decodeJson/decodeJsonResult/encodeJson read and write a JSON-in-TEXT column against the schema and fallback declared for it in db/json-columns.ts, and log a row that does not decode. reservations.metadata, the OAuth scope and redirect lists, consents, API token scopes, MFA backup codes, journey entry tags and pros/cons and poll options go through it. Readers that used to throw on a broken row (a hotel update, the ICS export, the price mirror, the OAuth token and session reads, a consent, a journey entry, a poll) now take the tolerant fallback the other readers of the same column already used.
This commit is contained in:
@@ -0,0 +1,90 @@
|
||||
import { z } from 'zod';
|
||||
import type { JsonColumn } from '../utils/json-column';
|
||||
|
||||
/**
|
||||
* The JSON-in-TEXT columns the services decode, one declaration each: the shape
|
||||
* the column holds and what a reader gets when a row is empty or broken. Read
|
||||
* them with `decodeJson`/`decodeJsonResult` and write server-built values with
|
||||
* `encodeJson` (`utils/json-column.ts`), never with a bare `JSON.parse` at the
|
||||
* call site.
|
||||
*
|
||||
* These are storage shapes, not API contracts: `reservations.metadata` goes out
|
||||
* as the stored string, and the scope lists are plain string arrays whose
|
||||
* values the OAuth layer checks against its own scope table.
|
||||
*/
|
||||
|
||||
/** The free-form booking details (times, carrier, legs, the mirrored price). Old rows can hold a JSON string of the JSON. */
|
||||
export const RESERVATION_METADATA: JsonColumn<Record<string, unknown>> = {
|
||||
column: 'reservations.metadata',
|
||||
schema: z.record(z.string(), z.unknown()),
|
||||
fallback: () => ({}),
|
||||
unwrapDoubleEncoded: true,
|
||||
};
|
||||
|
||||
const stringList = z.array(z.string());
|
||||
|
||||
/** The scopes an OAuth token was issued with. A broken row grants nothing. */
|
||||
export const OAUTH_TOKEN_SCOPES: JsonColumn<string[]> = {
|
||||
column: 'oauth_tokens.scopes',
|
||||
schema: stringList,
|
||||
fallback: () => [],
|
||||
};
|
||||
|
||||
/** The scopes an OAuth client may ask for. A broken row allows nothing. */
|
||||
export const OAUTH_CLIENT_ALLOWED_SCOPES: JsonColumn<string[]> = {
|
||||
column: 'oauth_clients.allowed_scopes',
|
||||
schema: stringList,
|
||||
fallback: () => [],
|
||||
};
|
||||
|
||||
/** The redirect URIs an OAuth client registered. A broken row matches none. */
|
||||
export const OAUTH_CLIENT_REDIRECT_URIS: JsonColumn<string[]> = {
|
||||
column: 'oauth_clients.redirect_uris',
|
||||
schema: stringList,
|
||||
fallback: () => [],
|
||||
};
|
||||
|
||||
/** The scopes a user consented to for a client. A broken row counts as consent to nothing, so the user is asked again. */
|
||||
export const OAUTH_CONSENT_SCOPES: JsonColumn<string[]> = {
|
||||
column: 'oauth_consents.scopes',
|
||||
schema: stringList,
|
||||
fallback: () => [],
|
||||
};
|
||||
|
||||
/** A limited API token's scopes; the reader keeps the strings it knows. */
|
||||
export const MCP_TOKEN_API_SCOPES: JsonColumn<unknown[]> = {
|
||||
column: 'mcp_tokens.api_scopes',
|
||||
schema: z.array(z.unknown()),
|
||||
fallback: () => [],
|
||||
};
|
||||
|
||||
/** The hashed MFA backup codes; the reader keeps the strings. A broken row holds no codes. */
|
||||
export const USER_MFA_BACKUP_CODES: JsonColumn<unknown[]> = {
|
||||
column: 'users.mfa_backup_codes',
|
||||
schema: z.array(z.unknown()),
|
||||
fallback: () => [],
|
||||
};
|
||||
|
||||
/** A journey entry's tags. */
|
||||
export const JOURNEY_ENTRY_TAGS: JsonColumn<string[]> = {
|
||||
column: 'journey_entries.tags',
|
||||
schema: z.array(z.string()),
|
||||
fallback: () => [],
|
||||
};
|
||||
|
||||
/**
|
||||
* A journey entry's pros and cons (`{ pros, cons }`). Checked as an object
|
||||
* only: the lists inside go out as they were stored.
|
||||
*/
|
||||
export const JOURNEY_ENTRY_PROS_CONS: JsonColumn<Record<string, unknown> | null> = {
|
||||
column: 'journey_entries.pros_cons',
|
||||
schema: z.record(z.string(), z.unknown()).nullable(),
|
||||
fallback: () => null,
|
||||
};
|
||||
|
||||
/** A poll's options: plain labels, or `{ label }` objects from older rows. Checked as a list only. */
|
||||
export const COLLAB_POLL_OPTIONS: JsonColumn<unknown[]> = {
|
||||
column: 'collab_polls.options',
|
||||
schema: z.array(z.unknown()),
|
||||
fallback: () => [],
|
||||
};
|
||||
@@ -24,6 +24,8 @@ import { BudgetItems } from '../../db/entities/BudgetItems.entity';
|
||||
import type { BudgetItemsRepository } from '../../db/repositories/BudgetItems.repository';
|
||||
import { carryViasWith, locatedStopIdsWith, reseatOwnStopWith, seatHolds, seatIndexAmong, seatIndexWith, type Night } from './night-seat';
|
||||
import type { User } from '../../types';
|
||||
import { RESERVATION_METADATA } from '../../db/json-columns';
|
||||
import { decodeJson, encodeJson } from '../../utils/json-column';
|
||||
|
||||
type Trip = TripAccess;
|
||||
|
||||
@@ -587,7 +589,7 @@ export class AccommodationsService {
|
||||
confirmation_number: confirmation || null,
|
||||
notes: notes || null,
|
||||
accommodation_id: legacyBoundIntegerText(newId),
|
||||
metadata: Object.keys(meta).length > 0 ? JSON.stringify(meta) : null,
|
||||
metadata: Object.keys(meta).length > 0 ? encodeJson(RESERVATION_METADATA, meta) : null,
|
||||
});
|
||||
|
||||
return { accommodationId: newId, mirror: await this.mirrorStay(newId, place_id ?? null, start_day_id, check_in) };
|
||||
@@ -646,11 +648,11 @@ export class AccommodationsService {
|
||||
// would silently leave the others on the old times.
|
||||
const linkedRes = await this.reservationsRepo.listIdMetadataByStay(existing.id);
|
||||
for (const res of linkedRes) {
|
||||
const meta = res.metadata ? JSON.parse(res.metadata) : {};
|
||||
const meta = decodeJson(RESERVATION_METADATA, res.metadata, `reservation ${res.id}`);
|
||||
if (newCheckIn) meta.check_in_time = newCheckIn;
|
||||
if (newCheckInEnd) meta.check_in_end_time = newCheckInEnd;
|
||||
if (newCheckOut) meta.check_out_time = newCheckOut;
|
||||
await this.reservationsRepo.setMetadataAndConfirmation(res.id, JSON.stringify(meta), newConfirmation || null);
|
||||
await this.reservationsRepo.setMetadataAndConfirmation(res.id, encodeJson(RESERVATION_METADATA, meta), newConfirmation || null);
|
||||
}
|
||||
return moved;
|
||||
});
|
||||
|
||||
@@ -8,6 +8,8 @@ import { ReservationEndpoints } from '../../db/entities/ReservationEndpoints.ent
|
||||
import { ReservationEndpointsRepository } from '../../db/repositories/ReservationEndpoints.repository';
|
||||
import { CronRegistrarService } from '../scheduling/cron-registrar.service';
|
||||
import { UnitOfWork } from '../database/unit-of-work';
|
||||
import { RESERVATION_METADATA } from '../../db/json-columns';
|
||||
import { decodeJsonResult, logJsonFailure } from '../../utils/json-column';
|
||||
|
||||
/**
|
||||
* The in-container face of the airport dataset, plus the flight-endpoint
|
||||
@@ -96,8 +98,13 @@ export class AirportsService implements OnApplicationBootstrap {
|
||||
/** One flight's endpoints, or its review flag. True when it was filled. */
|
||||
private async backfillOne(r: { id: number; metadata: string | null; reservation_time: string | null; reservation_end_time: string | null }): Promise<boolean> {
|
||||
if (!r.metadata) { await this.reservationsRepo.markNeedsReview(r.id); return false; }
|
||||
let meta: any;
|
||||
try { meta = JSON.parse(r.metadata); } catch { await this.reservationsRepo.markNeedsReview(r.id); return false; }
|
||||
const decoded = decodeJsonResult(RESERVATION_METADATA, r.metadata);
|
||||
if (!decoded.ok) {
|
||||
logJsonFailure(RESERVATION_METADATA, decoded.reason, `reservation ${r.id}`);
|
||||
await this.reservationsRepo.markNeedsReview(r.id);
|
||||
return false;
|
||||
}
|
||||
const meta = decoded.value;
|
||||
|
||||
const dep = meta.departure_airport ? findByIata(String(meta.departure_airport).slice(0, 3)) : null;
|
||||
const arr = meta.arrival_airport ? findByIata(String(meta.arrival_airport).slice(0, 3)) : null;
|
||||
|
||||
@@ -2,6 +2,8 @@ import bcrypt from 'bcryptjs';
|
||||
import crypto from 'crypto';
|
||||
import { User } from '../../types';
|
||||
import { utcSuffix } from '../../db/types';
|
||||
import { USER_MFA_BACKUP_CODES } from '../../db/json-columns';
|
||||
import { decodeJson } from '../../utils/json-column';
|
||||
import { decrypt_api_key } from '../common/crypto/apiKeyCrypto';
|
||||
|
||||
/**
|
||||
@@ -140,11 +142,5 @@ export function generateBackupCodes(count = MFA_BACKUP_CODE_COUNT): string[] {
|
||||
}
|
||||
|
||||
export function parseBackupCodeHashes(raw: string | null | undefined): string[] {
|
||||
if (!raw) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
return Array.isArray(parsed) ? parsed.filter(v => typeof v === 'string') : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
return decodeJson(USER_MFA_BACKUP_CODES, raw).filter((v): v is string => typeof v === 'string');
|
||||
}
|
||||
|
||||
@@ -26,6 +26,8 @@ import { Trips } from '../../db/entities/Trips.entity';
|
||||
import type { TripsRepository, TripAccess } from '../../db/repositories/Trips.repository';
|
||||
import { TripMembers } from '../../db/entities/TripMembers.entity';
|
||||
import type { TripMembersRepository } from '../../db/repositories/TripMembers.repository';
|
||||
import { RESERVATION_METADATA } from '../../db/json-columns';
|
||||
import { decodeJson, encodeJson } from '../../utils/json-column';
|
||||
|
||||
type Trip = TripAccess;
|
||||
|
||||
@@ -926,19 +928,16 @@ export class BudgetService {
|
||||
await this.syncReservationPrice(String(tripId), reservationId, total, socketId, allInTripCurrency ? null : codeOf(linked[0].currency) || null);
|
||||
}
|
||||
|
||||
/**
|
||||
* The counterpart to syncReservationPrice: take the mirrored total back off
|
||||
* the booking. Non-fatal, exactly like the writer.
|
||||
*/
|
||||
/** The counterpart to syncReservationPrice: take the mirrored total back off the booking. Non-fatal, like the writer. */
|
||||
private async clearReservationPrice(tripId: string | number, reservationId: number): Promise<void> {
|
||||
try {
|
||||
const reservation = await this.reservationsRepo.getIdAndMetadata(reservationId, tripId);
|
||||
if (!reservation?.metadata) return;
|
||||
const meta = JSON.parse(reservation.metadata);
|
||||
if (!meta || typeof meta !== 'object' || meta.price === undefined) return;
|
||||
const meta = decodeJson(RESERVATION_METADATA, reservation.metadata, `reservation ${reservation.id}`);
|
||||
if (meta.price === undefined) return;
|
||||
delete meta.price;
|
||||
delete meta.priceCurrency;
|
||||
await this.reservationsRepo.setMetadata(reservation.id, JSON.stringify(meta));
|
||||
await this.reservationsRepo.setMetadata(reservation.id, encodeJson(RESERVATION_METADATA, meta));
|
||||
const updatedRes = await this.reservationsRepo.getFull(reservation.id);
|
||||
this.realtime.broadcast(String(tripId), 'reservation:updated', { reservation: updatedRes }, undefined);
|
||||
} catch (err) {
|
||||
@@ -1637,7 +1636,7 @@ export class BudgetService {
|
||||
try {
|
||||
const reservation = await this.reservationsRepo.getIdAndMetadata(reservationId, tripId);
|
||||
if (!reservation) return;
|
||||
const meta = reservation.metadata ? JSON.parse(reservation.metadata) : {};
|
||||
const meta = decodeJson(RESERVATION_METADATA, reservation.metadata, `reservation ${reservation.id}`);
|
||||
// Cent-clean, so a booking never inherits float noise from the expense
|
||||
// it is linked to — and so a row stamped before #1964 heals on the next
|
||||
// edit. The panels print this string as it stands.
|
||||
@@ -1648,7 +1647,7 @@ export class BudgetService {
|
||||
if (currency) meta.priceCurrency = currency.toUpperCase();
|
||||
else delete meta.priceCurrency;
|
||||
}
|
||||
await this.reservationsRepo.setMetadata(reservation.id, JSON.stringify(meta));
|
||||
await this.reservationsRepo.setMetadata(reservation.id, encodeJson(RESERVATION_METADATA, meta));
|
||||
const updatedRes = await this.reservationsRepo.getFull(reservation.id);
|
||||
this.realtime.broadcast(tripId, 'reservation:updated', { reservation: updatedRes }, socketId);
|
||||
} catch (err) {
|
||||
|
||||
@@ -12,6 +12,8 @@ import { DayNotes } from '../../db/entities/DayNotes.entity';
|
||||
import type { DayNotesRepository } from '../../db/repositories/DayNotes.repository';
|
||||
import { Reservations } from '../../db/entities/Reservations.entity';
|
||||
import type { ReservationsRepository } from '../../db/repositories/Reservations.repository';
|
||||
import { RESERVATION_METADATA } from '../../db/json-columns';
|
||||
import { decodeJson } from '../../utils/json-column';
|
||||
|
||||
/** The VCALENDAR preamble every TREK calendar starts with, single-trip or merged. */
|
||||
export const CALENDAR_HEADER =
|
||||
@@ -613,7 +615,7 @@ export class CalendarService {
|
||||
interface LegTimes { from: string | null; to: string | null; label: string | null; confirmation: string | null; dep: { date: string; time: string; zone: string | null }; arr: { date: string; time: string; zone: string | null } | null }
|
||||
const legsOf = (r: any): LegTimes[] | null => {
|
||||
if (r.type !== 'flight' && r.type !== 'train' && r.type !== 'cruise') return null;
|
||||
const meta = r.metadata ? (typeof r.metadata === 'string' ? JSON.parse(r.metadata) : r.metadata) : {};
|
||||
const meta = decodeJson(RESERVATION_METADATA, r.metadata, `reservation ${r.id}`);
|
||||
const legs = Array.isArray(meta.legs) ? meta.legs : [];
|
||||
if (legs.length < 2) return null;
|
||||
const eps = endpointsMap.get(r.id);
|
||||
@@ -681,7 +683,7 @@ export class CalendarService {
|
||||
// lines: dropping the block must not drop the route or the confirmation with
|
||||
// it (#2068).
|
||||
function describeReservation(r: any): string {
|
||||
const meta = r.metadata ? (typeof r.metadata === 'string' ? JSON.parse(r.metadata) : r.metadata) : {};
|
||||
const meta = decodeJson(RESERVATION_METADATA, r.metadata, `reservation ${r.id}`);
|
||||
let desc = r.type ? `Type: ${r.type}` : '';
|
||||
if (r.confirmation_number) desc += `\nConfirmation: ${r.confirmation_number}`;
|
||||
if (meta.airline) desc += `\nAirline: ${meta.airline}`;
|
||||
|
||||
@@ -28,6 +28,8 @@ import { CollabMessages } from '../../db/entities/CollabMessages.entity';
|
||||
import type { CollabMessagesRepository, CollabMessageJoinRow } from '../../db/repositories/CollabMessages.repository';
|
||||
import { Trips } from '../../db/entities/Trips.entity';
|
||||
import type { TripsRepository } from '../../db/repositories/Trips.repository';
|
||||
import { COLLAB_POLL_OPTIONS } from '../../db/json-columns';
|
||||
import { decodeJson } from '../../utils/json-column';
|
||||
|
||||
type Trip = TripAccess;
|
||||
|
||||
@@ -366,7 +368,7 @@ export class CollabService {
|
||||
const poll = await this.pollsRepo.findWithUser(pollId);
|
||||
if (!poll) return null;
|
||||
|
||||
const options: (string | { label: string })[] = JSON.parse(poll.options);
|
||||
const options = decodeJson(COLLAB_POLL_OPTIONS, poll.options, `poll ${poll.id}`) as (string | { label: string })[];
|
||||
|
||||
const votes = await this.pollVotesRepo.listForPoll(poll.id);
|
||||
|
||||
@@ -419,7 +421,7 @@ export class CollabService {
|
||||
if (!poll) return { error: 'not_found' };
|
||||
if (poll.closed) return { error: 'closed' };
|
||||
|
||||
const options = JSON.parse(poll.options);
|
||||
const options = decodeJson(COLLAB_POLL_OPTIONS, poll.options, `poll ${poll.id}`);
|
||||
if (!Number.isInteger(optionIndex) || optionIndex < 0 || optionIndex >= options.length) {
|
||||
return { error: 'invalid_index' };
|
||||
}
|
||||
|
||||
@@ -14,6 +14,8 @@ import { AirtrailRequestError, type AirtrailFlightRaw } from './airtrail.client'
|
||||
import { AirtrailClient } from './airtrail.client';
|
||||
import { AirtrailService } from './airtrail.service';
|
||||
import { canonicalHash, mapFlightToReservation, mapFlightsToMultiLegReservation, normalizeFlight } from './airtrail.mapper';
|
||||
import { RESERVATION_METADATA } from '../../db/json-columns';
|
||||
import { decodeJson } from '../../utils/json-column';
|
||||
|
||||
interface ExistingFlightRow {
|
||||
id: number;
|
||||
@@ -165,12 +167,8 @@ export class AirtrailImportService {
|
||||
const linkedRows = await this.reservationsRepo.listAirtrailLinkedForTrip(tripIdNum);
|
||||
for (const row of linkedRows) {
|
||||
if (row.external_id) linkedIds.add(row.external_id);
|
||||
try {
|
||||
const ids = row.metadata ? JSON.parse(row.metadata).airtrail_ids : null;
|
||||
if (Array.isArray(ids)) for (const id of ids) linkedIds.add(String(id));
|
||||
} catch {
|
||||
/* malformed metadata — ignore */
|
||||
}
|
||||
const ids = decodeJson(RESERVATION_METADATA, row.metadata, `airtrail ${row.external_id}`).airtrail_ids;
|
||||
if (Array.isArray(ids)) for (const id of ids) linkedIds.add(String(id));
|
||||
}
|
||||
|
||||
const existing = await this.reservationsRepo.listFlightReservationsForTrip(tripIdNum);
|
||||
@@ -194,12 +192,7 @@ export class AirtrailImportService {
|
||||
|
||||
const existingSigs = new Set<string>();
|
||||
for (const row of existing) {
|
||||
let meta: Record<string, any> = {};
|
||||
try {
|
||||
meta = row.metadata ? JSON.parse(row.metadata) : {};
|
||||
} catch {
|
||||
/* malformed metadata — ignore */
|
||||
}
|
||||
const meta = decodeJson(RESERVATION_METADATA, row.metadata, `reservation ${row.id}`);
|
||||
const eps = endpointsByReservation.get(row.id) ?? [];
|
||||
const legs: any[] | null = Array.isArray(meta.legs) ? meta.legs : null;
|
||||
if (legs && legs.length > 1) {
|
||||
@@ -218,7 +211,7 @@ export class AirtrailImportService {
|
||||
} else {
|
||||
const from = eps[0]?.code ?? null;
|
||||
const to = eps.length > 1 ? eps[eps.length - 1].code : null;
|
||||
const sig = softSignature(depDate(row.reservation_time), meta.flight_number ?? null, from, to);
|
||||
const sig = softSignature(depDate(row.reservation_time), (meta.flight_number as string | null | undefined) ?? null, from, to);
|
||||
if (sig) existingSigs.add(sig);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,8 @@ import { AirtrailClient } from './airtrail.client';
|
||||
import { AirtrailService } from './airtrail.service';
|
||||
import { canonicalHash } from './airtrail.mapper';
|
||||
import { buildSavePayload } from './airtrail-sync.helpers';
|
||||
import { RESERVATION_METADATA } from '../../db/json-columns';
|
||||
import { decodeJson } from '../../utils/json-column';
|
||||
|
||||
/**
|
||||
* The AirTrail link lifecycle — the enablement gate, the detach policy, the
|
||||
@@ -70,12 +72,9 @@ export class AirtrailLinkService {
|
||||
* AirTrail flight to span the whole route (#1535).
|
||||
*/
|
||||
async hasLocalMultiLegShape(reservationId: number, metadataJson: string | null | undefined): Promise<boolean> {
|
||||
try {
|
||||
const meta = metadataJson ? JSON.parse(metadataJson) : {};
|
||||
if (Array.isArray(meta?.legs) && meta.legs.length > 1) return true;
|
||||
} catch {
|
||||
/* malformed metadata — fall through to the endpoint count */
|
||||
}
|
||||
// Malformed metadata falls through to the endpoint count.
|
||||
const legs = decodeJson(RESERVATION_METADATA, metadataJson, `reservation ${reservationId}`).legs;
|
||||
if (Array.isArray(legs) && legs.length > 1) return true;
|
||||
const n = await this.endpointsRepo.count({ reservation: reservationId });
|
||||
return n > 2;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { entityCode } from './airtrail.mapper';
|
||||
import { flightPassengers, ownPassenger } from './airtrail.client';
|
||||
import type { AirtrailFlightRaw, AirtrailPassengerWrite, AirtrailSavePayload } from './airtrail.client';
|
||||
import { RESERVATION_METADATA } from '../../db/json-columns';
|
||||
import { decodeJson } from '../../utils/json-column';
|
||||
|
||||
/**
|
||||
* The pure half of the AirTrail push: turning a TREK reservation plus the flight
|
||||
@@ -27,12 +29,7 @@ function splitLocal(dt: string | null | undefined): { date: string | null; time:
|
||||
* (#1240).
|
||||
*/
|
||||
export function buildSavePayload(reservation: any, existing: AirtrailFlightRaw): AirtrailSavePayload | null {
|
||||
let meta: Record<string, any>;
|
||||
try {
|
||||
meta = reservation.metadata ? JSON.parse(reservation.metadata) : {};
|
||||
} catch {
|
||||
meta = {};
|
||||
}
|
||||
const meta = decodeJson(RESERVATION_METADATA, reservation.metadata, `reservation ${reservation.id}`);
|
||||
const endpoints: any[] = reservation.endpoints || [];
|
||||
const fromEp = endpoints.find((e) => e.role === 'from');
|
||||
const toEp = endpoints.find((e) => e.role === 'to');
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import type { JourneyEntry } from '../../types';
|
||||
import { JOURNEY_ENTRY_PROS_CONS, JOURNEY_ENTRY_TAGS } from '../../db/json-columns';
|
||||
import { decodeJson } from '../../utils/json-column';
|
||||
|
||||
/**
|
||||
* A journey entry on the way out.
|
||||
@@ -39,8 +41,8 @@ export function decodeEntryRow(row: JourneyEntry): JourneyEntryWire {
|
||||
const { tags, pros_cons, stats_excluded, dismissed, is_draft, ...rest } = row;
|
||||
return {
|
||||
...rest,
|
||||
tags: tags ? JSON.parse(tags) : [],
|
||||
pros_cons: pros_cons ? JSON.parse(pros_cons) : null,
|
||||
tags: decodeJson(JOURNEY_ENTRY_TAGS, tags, `journey entry ${row.id}`),
|
||||
pros_cons: decodeJson(JOURNEY_ENTRY_PROS_CONS, pros_cons, `journey entry ${row.id}`) as JourneyEntryWire['pros_cons'],
|
||||
stats_excluded: !!stats_excluded,
|
||||
dismissed: !!dismissed,
|
||||
is_draft: !!is_draft,
|
||||
|
||||
@@ -6,6 +6,8 @@ import { getClientIp } from '../audit/client-ip';
|
||||
import { logWarn } from '../audit/audit-log.logger';
|
||||
import { AuditService } from '../audit/audit.service';
|
||||
import { Public } from '../auth/public.decorator';
|
||||
import { OAUTH_CLIENT_ALLOWED_SCOPES } from '../../db/json-columns';
|
||||
import { decodeJson } from '../../utils/json-column';
|
||||
|
||||
const MIN = 60_000;
|
||||
|
||||
@@ -112,7 +114,7 @@ export class OauthPublicController {
|
||||
res.status(400).json({ error: 'unauthorized_client', error_description: 'This client is not authorized for the client_credentials grant' });
|
||||
return;
|
||||
}
|
||||
const allowedScopes: string[] = JSON.parse(client.allowed_scopes);
|
||||
const allowedScopes = decodeJson(OAUTH_CLIENT_ALLOWED_SCOPES, client.allowed_scopes, `client ${client_id}`);
|
||||
let grantedScopes: string[];
|
||||
if (body.scope) {
|
||||
const requested = body.scope.split(' ').filter(Boolean);
|
||||
|
||||
@@ -10,6 +10,8 @@ import { classifyRedirectUri } from './oauth.helpers';
|
||||
import { AuditService } from '../audit/audit.service';
|
||||
import { ALL_SCOPES, DEFAULT_CLIENT_SCOPES } from '../../mcp/scopes';
|
||||
import { getMcpSafeUrl } from '../../app-config';
|
||||
import { OAUTH_CLIENT_ALLOWED_SCOPES, OAUTH_CLIENT_REDIRECT_URIS } from '../../db/json-columns';
|
||||
import { decodeJson } from '../../utils/json-column';
|
||||
|
||||
/**
|
||||
* TREK's adapters behind the MCP SDK's OAuth server interfaces, wrapping the
|
||||
@@ -48,8 +50,8 @@ function rowToInfo(row: NonNullable<Awaited<ReturnType<OauthService['getSdkClien
|
||||
return {
|
||||
client_id: row.client_id,
|
||||
client_name: row.name,
|
||||
redirect_uris: JSON.parse(row.redirect_uris) as string[],
|
||||
scope: (JSON.parse(row.allowed_scopes) as string[]).join(' '),
|
||||
redirect_uris: decodeJson(OAUTH_CLIENT_REDIRECT_URIS, row.redirect_uris, `client ${row.client_id}`),
|
||||
scope: decodeJson(OAUTH_CLIENT_ALLOWED_SCOPES, row.allowed_scopes, `client ${row.client_id}`).join(' '),
|
||||
token_endpoint_auth_method: row.is_public ? 'none' : 'client_secret_post',
|
||||
grant_types: ['authorization_code', 'refresh_token'],
|
||||
response_types: ['code'],
|
||||
|
||||
@@ -36,6 +36,13 @@ import {
|
||||
timingSafeEqualHex,
|
||||
} from './oauth.helpers';
|
||||
import { dbNow, parseDbTimestamp } from '../../db/types';
|
||||
import {
|
||||
OAUTH_CLIENT_ALLOWED_SCOPES,
|
||||
OAUTH_CLIENT_REDIRECT_URIS,
|
||||
OAUTH_CONSENT_SCOPES,
|
||||
OAUTH_TOKEN_SCOPES,
|
||||
} from '../../db/json-columns';
|
||||
import { decodeJson, decodeJsonResult, encodeJson, logJsonFailure } from '../../utils/json-column';
|
||||
import { AUTH_CODE_TTL_MS, PendingCodeStore, pendingCodesSlot, type PendingCode } from './oauth.pending-codes';
|
||||
|
||||
export type { PendingCode } from './oauth.pending-codes';
|
||||
@@ -129,8 +136,8 @@ export class OauthService {
|
||||
...r,
|
||||
is_public: Boolean(r.is_public),
|
||||
allows_client_credentials: Boolean(r.allows_client_credentials),
|
||||
redirect_uris: JSON.parse(r.redirect_uris),
|
||||
allowed_scopes: JSON.parse(r.allowed_scopes),
|
||||
redirect_uris: decodeJson(OAUTH_CLIENT_REDIRECT_URIS, r.redirect_uris, `client ${r.client_id}`),
|
||||
allowed_scopes: decodeJson(OAUTH_CLIENT_ALLOWED_SCOPES, r.allowed_scopes, `client ${r.client_id}`),
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -187,8 +194,8 @@ export class OauthService {
|
||||
name: name.trim(),
|
||||
client_id: clientId,
|
||||
client_secret_hash: secretHash,
|
||||
redirect_uris: JSON.stringify(redirectUris),
|
||||
allowed_scopes: JSON.stringify(allowedScopes),
|
||||
redirect_uris: encodeJson(OAUTH_CLIENT_REDIRECT_URIS, redirectUris),
|
||||
allowed_scopes: encodeJson(OAUTH_CLIENT_ALLOWED_SCOPES, allowedScopes),
|
||||
is_public: isPublic ? 1 : 0,
|
||||
created_via: createdVia,
|
||||
allows_client_credentials: isMachineClient ? 1 : 0,
|
||||
@@ -202,8 +209,8 @@ export class OauthService {
|
||||
user_id: row.user_id,
|
||||
name: row.name,
|
||||
client_id: row.client_id,
|
||||
redirect_uris: JSON.parse(row.redirect_uris),
|
||||
allowed_scopes: JSON.parse(row.allowed_scopes),
|
||||
redirect_uris: decodeJson(OAUTH_CLIENT_REDIRECT_URIS, row.redirect_uris, `client ${row.client_id}`),
|
||||
allowed_scopes: decodeJson(OAUTH_CLIENT_ALLOWED_SCOPES, row.allowed_scopes, `client ${row.client_id}`),
|
||||
created_at: row.created_at,
|
||||
is_public: Boolean(row.is_public),
|
||||
allows_client_credentials: Boolean(row.allows_client_credentials),
|
||||
@@ -281,14 +288,14 @@ export class OauthService {
|
||||
|
||||
async getConsent(clientId: string, userId: number): Promise<string[] | null> {
|
||||
const row = await this.consents.findScopes(clientId, userId);
|
||||
return row ? JSON.parse(row.scopes) : null;
|
||||
return row ? decodeJson(OAUTH_CONSENT_SCOPES, row.scopes, `client ${clientId} user ${userId}`) : null;
|
||||
}
|
||||
|
||||
async saveConsent(clientId: string, userId: number, scopes: string[], ip?: string | null): Promise<void> {
|
||||
// Union existing consent with newly approved scopes (M5: never narrow stored consent)
|
||||
const existing = (await this.getConsent(clientId, userId)) ?? [];
|
||||
const merged = Array.from(new Set([...existing, ...scopes]));
|
||||
await this.consents.upsertGrant(clientId, userId, JSON.stringify(merged));
|
||||
await this.consents.upsertGrant(clientId, userId, encodeJson(OAUTH_CONSENT_SCOPES, merged));
|
||||
await this.audit.writeAudit({ userId, action: 'oauth.consent.grant', details: { client_id: clientId, scopes: merged }, ip });
|
||||
}
|
||||
|
||||
@@ -327,7 +334,7 @@ export class OauthService {
|
||||
user_id: userId,
|
||||
access_token_hash: accessHash,
|
||||
refresh_token_hash: refreshHash,
|
||||
scopes: JSON.stringify(scopes),
|
||||
scopes: encodeJson(OAUTH_TOKEN_SCOPES, scopes),
|
||||
audience,
|
||||
access_token_expires_at: dbNow(accessExpiry),
|
||||
refresh_token_expires_at: dbNow(refreshExpiry),
|
||||
@@ -373,7 +380,7 @@ export class OauthService {
|
||||
user_id: userId,
|
||||
access_token_hash: accessHash,
|
||||
refresh_token_hash: placeholderHash,
|
||||
scopes: JSON.stringify(scopes),
|
||||
scopes: encodeJson(OAUTH_TOKEN_SCOPES, scopes),
|
||||
audience,
|
||||
access_token_expires_at: dbNow(accessExpiry),
|
||||
refresh_token_expires_at: dbNow(now),
|
||||
@@ -415,7 +422,7 @@ export class OauthService {
|
||||
|
||||
return {
|
||||
user: { id: row.user_id, username: row.username, email: row.email, role: row.role as 'admin' | 'user' },
|
||||
scopes: JSON.parse(row.scopes),
|
||||
scopes: decodeJson(OAUTH_TOKEN_SCOPES, row.scopes, `client ${row.client_id} user ${row.user_id}`),
|
||||
clientId: row.client_id,
|
||||
audience: row.audience ?? null,
|
||||
};
|
||||
@@ -489,7 +496,7 @@ export class OauthService {
|
||||
// to take the whole chain down with it. Issue a sibling pair off the same
|
||||
// parent so each client walks away with its own token.
|
||||
if (await this.isConcurrentRotation(row)) {
|
||||
const tokens = await this.issueTokens(clientId, row.user_id, JSON.parse(row.scopes), row.id, row.audience ?? null);
|
||||
const tokens = await this.issueTokens(clientId, row.user_id, decodeJson(OAUTH_TOKEN_SCOPES, row.scopes, `token ${row.id}`), row.id, row.audience ?? null);
|
||||
await this.audit.writeAudit({
|
||||
userId: row.user_id,
|
||||
action: 'oauth.token.refresh',
|
||||
@@ -529,7 +536,7 @@ export class OauthService {
|
||||
// to leave the old token revoked with no successor, logging the client out.
|
||||
const tokens = await this.uow.transactional(async () => {
|
||||
await this.tokens.revokeById(row.id);
|
||||
return await this.issueTokens(clientId, row.user_id, JSON.parse(row.scopes), row.id, row.audience ?? null);
|
||||
return await this.issueTokens(clientId, row.user_id, decodeJson(OAUTH_TOKEN_SCOPES, row.scopes, `token ${row.id}`), row.id, row.audience ?? null);
|
||||
});
|
||||
await this.audit.writeAudit({ userId: row.user_id, action: 'oauth.token.refresh', details: { client_id: clientId }, ip });
|
||||
|
||||
@@ -565,7 +572,7 @@ export class OauthService {
|
||||
|
||||
async listOAuthSessions(userId: number): Promise<Record<string, unknown>[]> {
|
||||
const rows = await this.tokens.listActiveByUser(userId);
|
||||
return rows.map(r => ({ ...r, ...expiriesAsIso(r), scopes: JSON.parse(r.scopes) }));
|
||||
return rows.map(r => ({ ...r, ...expiriesAsIso(r), scopes: decodeJson(OAUTH_TOKEN_SCOPES, r.scopes, `token ${r.id}`) }));
|
||||
}
|
||||
|
||||
async revokeSession(
|
||||
@@ -628,7 +635,7 @@ export class OauthService {
|
||||
return { valid: false, error: 'invalid_client', error_description: 'Unknown client_id' };
|
||||
}
|
||||
|
||||
const allowedUris: string[] = JSON.parse(client.redirect_uris);
|
||||
const allowedUris = decodeJson(OAUTH_CLIENT_REDIRECT_URIS, client.redirect_uris, `client ${client.client_id}`);
|
||||
// Exact match except for the loopback port, which RFC 8252 §7.3 leaves to
|
||||
// the OS, and which the SDK's authorize handler already relaxes, so a
|
||||
// native client got a 302 to consent and an invalid_redirect_uri from this
|
||||
@@ -658,7 +665,7 @@ export class OauthService {
|
||||
return { valid: false, error: 'invalid_scope', error_description: 'At least one scope is required' };
|
||||
}
|
||||
|
||||
const allowedScopes: string[] = JSON.parse(client.allowed_scopes);
|
||||
const allowedScopes = decodeJson(OAUTH_CLIENT_ALLOWED_SCOPES, client.allowed_scopes, `client ${client.client_id}`);
|
||||
// Narrow to the intersection: drop scopes the client isn't permitted for rather
|
||||
// than rejecting the whole request (per OAuth 2.0 §3.3 scope narrowing).
|
||||
const grantedScopes = requestedScopes.filter(s => allowedScopes.includes(s));
|
||||
@@ -728,12 +735,10 @@ export class OauthService {
|
||||
const rows = await this.tokens.listAllActiveWithClientAndUser();
|
||||
// One malformed row must not 500 the whole admin OAuth-sessions panel.
|
||||
return rows.map((r) => {
|
||||
let scopes: unknown;
|
||||
try {
|
||||
scopes = JSON.parse(r.scopes);
|
||||
} catch {
|
||||
scopes = null;
|
||||
}
|
||||
// The admin panel has always shown null for a row it cannot read.
|
||||
const decoded = decodeJsonResult(OAUTH_TOKEN_SCOPES, r.scopes);
|
||||
if (!decoded.ok && decoded.reason !== 'empty') logJsonFailure(OAUTH_TOKEN_SCOPES, decoded.reason, `token ${r.id}`);
|
||||
const scopes = decoded.ok ? decoded.value : null;
|
||||
return { ...r, ...expiriesAsIso(r), scopes };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { RESERVATION_METADATA } from '../../db/json-columns';
|
||||
import { decodeJsonResult, logJsonFailure } from '../../utils/json-column';
|
||||
|
||||
/**
|
||||
* Metadata keys on a reservation that the booking forms do not own.
|
||||
*
|
||||
@@ -14,15 +17,10 @@
|
||||
|
||||
/** Read a stored metadata column into an object, or null if it is not one. */
|
||||
function parseStored(stored: string | null | undefined): Record<string, unknown> | null {
|
||||
if (!stored) return null;
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(stored);
|
||||
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
|
||||
? (parsed as Record<string, unknown>)
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const decoded = decodeJsonResult(RESERVATION_METADATA, stored);
|
||||
if (decoded.ok) return decoded.value;
|
||||
if (decoded.reason !== 'empty') logJsonFailure(RESERVATION_METADATA, decoded.reason);
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -15,6 +15,8 @@ import { findByIata } from '../airports/airports.data';
|
||||
import type { EndpointInput } from './reservations.service';
|
||||
import { AssignmentsService } from '../assignments/assignments.service';
|
||||
import { transportLegInputSchema, reservationUrlSchema, type TransportLegInput } from '@trek/shared';
|
||||
import { RESERVATION_METADATA } from '../../db/json-columns';
|
||||
import { decodeJson } from '../../utils/json-column';
|
||||
|
||||
// What counts as a transport booking, for the update_transport gate. Every value
|
||||
// ReservationsPanel renders with a transport icon, so a stored `transit` row is
|
||||
@@ -138,14 +140,7 @@ type MetaRecord = Record<string, unknown>;
|
||||
* still has to accept a legs update.
|
||||
*/
|
||||
function parseStoredMetadata(raw: unknown): MetaRecord {
|
||||
if (typeof raw !== 'string' || !raw) return {};
|
||||
try {
|
||||
let parsed: unknown = JSON.parse(raw);
|
||||
if (typeof parsed === 'string') parsed = JSON.parse(parsed);
|
||||
return typeof parsed === 'object' && parsed !== null ? parsed as MetaRecord : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
return typeof raw === 'string' ? decodeJson(RESERVATION_METADATA, raw) : {};
|
||||
}
|
||||
|
||||
interface LegPlan {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@mikro-orm/nestjs';
|
||||
import type { TripAccess } from '../../db/repositories/Trips.repository';
|
||||
import type { TripAccess, TripsRepository } from '../../db/repositories/Trips.repository';
|
||||
import { UnitOfWork } from '../database/unit-of-work';
|
||||
import type { TrekWsPayload, TrekWsTripEventName } from '@trek/shared';
|
||||
import { RealtimeService } from '../realtime/realtime.service';
|
||||
@@ -36,8 +36,9 @@ import type { UsersRepository } from '../../db/repositories/Users.repository';
|
||||
import { Trips } from '../../db/entities/Trips.entity';
|
||||
import { BudgetItems } from '../../db/entities/BudgetItems.entity';
|
||||
import type { BudgetItemsRepository } from '../../db/repositories/BudgetItems.repository';
|
||||
import type { TripsRepository } from '../../db/repositories/Trips.repository';
|
||||
import { appClock } from '../common/timezoneService';
|
||||
import { RESERVATION_METADATA } from '../../db/json-columns';
|
||||
import { decodeJson } from '../../utils/json-column';
|
||||
|
||||
type Trip = TripAccess;
|
||||
type BudgetEntry = { total_price?: number; category?: string; currency?: string | null; exchange_rate?: number } | undefined;
|
||||
@@ -787,7 +788,7 @@ export class ReservationsService {
|
||||
// whose accommodation was just auto-created above never received its
|
||||
// metadata check-in/out times or confirmation.
|
||||
if (resolvedAccommodationId && metadata) {
|
||||
const meta = (typeof metadata === 'string' ? JSON.parse(metadata) : metadata) as AccommodationTimesMeta;
|
||||
const meta = decodeJson(RESERVATION_METADATA, metadata) as AccommodationTimesMeta;
|
||||
if (meta.check_in_time || meta.check_in_end_time || meta.check_out_time) {
|
||||
await this.dayAccommodationsRepo.patchTimes(
|
||||
resolvedAccommodationId, meta.check_in_time || null, meta.check_in_end_time || null, meta.check_out_time || null,
|
||||
@@ -1036,9 +1037,9 @@ export class ReservationsService {
|
||||
}
|
||||
|
||||
// Sync check-in/out to accommodation if linked
|
||||
const resolvedMeta = nextMetadata !== undefined ? nextMetadata : (current.metadata ? JSON.parse(current.metadata as string) : null);
|
||||
const resolvedMeta = nextMetadata !== undefined ? nextMetadata : (current.metadata ? decodeJson(RESERVATION_METADATA, current.metadata, `reservation ${idNum}`) : null);
|
||||
if (resolvedAccId && resolvedMeta) {
|
||||
const meta = (typeof resolvedMeta === 'string' ? JSON.parse(resolvedMeta) : resolvedMeta) as AccommodationTimesMeta;
|
||||
const meta = decodeJson(RESERVATION_METADATA, resolvedMeta) as AccommodationTimesMeta;
|
||||
const resolvedAccIdNum = accIdForRead(resolvedAccId)!;
|
||||
if (meta.check_in_time || meta.check_in_end_time || meta.check_out_time) {
|
||||
await this.dayAccommodationsRepo.patchTimes(
|
||||
|
||||
@@ -17,6 +17,8 @@ import { toRowId } from '../common/row-id';
|
||||
// every consumer of this module — a nest→mcp→nest module cycle.
|
||||
import { revokeUserSessions } from '../../mcp/sessionManager';
|
||||
import { User } from '../../types';
|
||||
import { MCP_TOKEN_API_SCOPES } from '../../db/json-columns';
|
||||
import { decodeJson } from '../../utils/json-column';
|
||||
|
||||
/**
|
||||
* What a token is allowed to drive. Stored on the row so each surface can accept
|
||||
@@ -321,13 +323,7 @@ function sanitizeScopes(scopes: readonly string[] | undefined): PublicApiScope[]
|
||||
*/
|
||||
function resolveGrant(mode: string | null, raw: string | null): PublicApiGrant {
|
||||
if (mode !== 'limited') return { mode: 'all', scopes: [...PUBLIC_API_SCOPES] };
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = raw ? JSON.parse(raw) : null;
|
||||
} catch {
|
||||
parsed = null;
|
||||
}
|
||||
if (!Array.isArray(parsed)) return { mode: 'limited', scopes: [] };
|
||||
const parsed = decodeJson(MCP_TOKEN_API_SCOPES, raw);
|
||||
const kept = sanitizeScopes(parsed.filter((value): value is string => typeof value === 'string'));
|
||||
return { mode: 'limited', scopes: kept ?? [] };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import type { ZodType } from 'zod';
|
||||
import { logWarn } from '../nest/audit/audit-log.logger';
|
||||
|
||||
/**
|
||||
* One JSON-in-TEXT column: where it lives, the shape it holds, and what a reader
|
||||
* gets when the stored text is missing or broken.
|
||||
*
|
||||
* The storage model keeps JSON in TEXT (see server/CLAUDE.md, "SQL dialect"), and
|
||||
* the service boundary turns it into a value. Declaring the column once keeps
|
||||
* every reader of it on the same answer: one schema, one fallback, one log line
|
||||
* when a row does not hold what it should, instead of a `JSON.parse` per call
|
||||
* site that throws in one place and shrugs in the next.
|
||||
*/
|
||||
export interface JsonColumn<T> {
|
||||
/** `table.column`, for the log line. */
|
||||
readonly column: string;
|
||||
readonly schema: ZodType<T>;
|
||||
/** What a reader gets for an empty column or a value that does not decode. A function, so a mutable default is fresh per call. */
|
||||
readonly fallback: () => T;
|
||||
/**
|
||||
* The stored text may be a JSON string of the JSON (an old double-encoding
|
||||
* bug). When set, one level of that is unwrapped before the schema check.
|
||||
*/
|
||||
readonly unwrapDoubleEncoded?: boolean;
|
||||
}
|
||||
|
||||
export type JsonDecodeFailure = 'empty' | 'invalid-json' | 'schema';
|
||||
|
||||
export type JsonDecodeResult<T> =
|
||||
| { ok: true; value: T; reason?: never }
|
||||
| { ok: false; value?: never; reason: JsonDecodeFailure };
|
||||
|
||||
/**
|
||||
* Decode a stored value without a fallback, for the readers that react to a
|
||||
* broken row themselves (flag it for review, skip it). A value a driver already
|
||||
* handed back parsed (an object rather than text) is checked as it is.
|
||||
*/
|
||||
export function decodeJsonResult<T>(col: JsonColumn<T>, raw: unknown): JsonDecodeResult<T> {
|
||||
if (raw === null || raw === undefined || raw === '') return { ok: false, reason: 'empty' };
|
||||
let value: unknown = raw;
|
||||
if (typeof raw === 'string') {
|
||||
try {
|
||||
value = JSON.parse(raw);
|
||||
if (col.unwrapDoubleEncoded && typeof value === 'string') value = JSON.parse(value);
|
||||
} catch {
|
||||
return { ok: false, reason: 'invalid-json' };
|
||||
}
|
||||
}
|
||||
const parsed = col.schema.safeParse(value);
|
||||
return parsed.success ? { ok: true, value: parsed.data } : { ok: false, reason: 'schema' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a stored value, falling back to the column's default. An empty column
|
||||
* is ordinary and falls back quietly; text that is not JSON, or JSON of the
|
||||
* wrong shape, falls back too and is logged with `context` (a row id, say), so
|
||||
* broken data shows up in trek.log instead of disappearing.
|
||||
*/
|
||||
export function decodeJson<T>(col: JsonColumn<T>, raw: unknown, context?: string): T {
|
||||
const result = decodeJsonResult(col, raw);
|
||||
if (result.ok) return result.value;
|
||||
if (result.reason !== 'empty') logJsonFailure(col, result.reason, context);
|
||||
return col.fallback();
|
||||
}
|
||||
|
||||
/** Log a row whose JSON did not decode; for callers of {@link decodeJsonResult} that handle the failure themselves. */
|
||||
export function logJsonFailure(col: JsonColumn<unknown>, reason: JsonDecodeFailure, context?: string): void {
|
||||
logWarn(`[json] ${col.column}${context ? ` (${context})` : ''}: stored value ${reason === 'schema' ? 'has the wrong shape' : 'is not valid JSON'}, using the fallback`);
|
||||
}
|
||||
|
||||
/** The text to store for a value, checked against the column's shape first (a mismatch throws: it is a bug in the writer). */
|
||||
export function encodeJson<T>(col: JsonColumn<T>, value: T): string {
|
||||
return JSON.stringify(col.schema.parse(value));
|
||||
}
|
||||
Reference in New Issue
Block a user