fix(api): bind daily and synastry charts to stored profiles
Stop accepting client-supplied birth data on those paths, and cap session writes plus location lookups so a logged-in caller cannot farm compute. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -7,9 +7,14 @@ import {
|
||||
} from "./consultation-agent-events.ts";
|
||||
import { consultationDomainSchema, type ConsultationDomain } from "./consultation-domain-registry.ts";
|
||||
|
||||
export const CHAT_SESSION_MAX_MESSAGES = 200;
|
||||
export const CHAT_SESSION_MAX_MESSAGE_CHARS = 16_000;
|
||||
export const CHAT_SESSION_MAX_TOTAL_MESSAGE_CHARS = 200_000;
|
||||
export const CHAT_SESSION_MAX_BODY_CHARS = 500_000;
|
||||
|
||||
const chatMessageSchema = z.object({
|
||||
role: z.enum(["user", "assistant"]),
|
||||
text: z.string().max(100_000),
|
||||
text: z.string().max(CHAT_SESSION_MAX_MESSAGE_CHARS),
|
||||
// Nothing writes suggestions since the follow-up chips were removed, but this schema
|
||||
// is strict and a client running the previous bundle still sends them; rejecting the
|
||||
// whole write would lose that user's message rather than a dead field.
|
||||
@@ -19,19 +24,53 @@ const chatMessageSchema = z.object({
|
||||
workflowReceipt: workflowReceiptSchema.optional(),
|
||||
}).strict();
|
||||
|
||||
export const chatSessionWriteSchema = z.object({
|
||||
const chatSessionWriteObjectSchema = z.object({
|
||||
title: z.string().trim().min(1).max(160),
|
||||
theme: consultationDomainSchema,
|
||||
model_id: z.string().trim().min(1).max(64),
|
||||
messages: z.array(chatMessageSchema).max(500),
|
||||
messages: z.array(chatMessageSchema).max(CHAT_SESSION_MAX_MESSAGES),
|
||||
session_type: z.enum(["consultation", "birth_time_rectification"]),
|
||||
rectification_case_id: z.string().uuid().nullable(),
|
||||
updated_at: z.string().datetime(),
|
||||
}).strict();
|
||||
|
||||
export const chatSessionCreateSchema = chatSessionWriteSchema.extend({
|
||||
id: z.string().uuid(),
|
||||
}).strict();
|
||||
function limitTranscriptSize<Schema extends z.ZodType<{ messages: Array<{ text: string }> }>>(schema: Schema) {
|
||||
return schema.superRefine((value, context) => {
|
||||
const totalChars = value.messages.reduce((sum, message) => sum + message.text.length, 0);
|
||||
if (totalChars > CHAT_SESSION_MAX_TOTAL_MESSAGE_CHARS) {
|
||||
context.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["messages"],
|
||||
message: "聊天记录过长",
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export const chatSessionWriteSchema = limitTranscriptSize(chatSessionWriteObjectSchema);
|
||||
export const chatSessionCreateSchema = limitTranscriptSize(
|
||||
chatSessionWriteObjectSchema.extend({
|
||||
id: z.string().uuid(),
|
||||
}).strict(),
|
||||
);
|
||||
|
||||
export class ChatSessionBodyTooLargeError extends Error {
|
||||
constructor() {
|
||||
super("聊天记录过长");
|
||||
this.name = "ChatSessionBodyTooLargeError";
|
||||
}
|
||||
}
|
||||
|
||||
export async function readChatSessionJson(request: Request): Promise<unknown> {
|
||||
const raw = await request.text().catch(() => "");
|
||||
if (raw.length > CHAT_SESSION_MAX_BODY_CHARS) throw new ChatSessionBodyTooLargeError();
|
||||
if (!raw) return null;
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export const chatSessionModelPatchSchema = z.object({
|
||||
model_id: z.string().trim().min(1).max(64),
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
export type RequestRateLimitStore = Map<string, number[]>;
|
||||
|
||||
export type RequestRateLimitDecision =
|
||||
| { readonly ok: true }
|
||||
| { readonly ok: false; readonly retryAfterSeconds: number };
|
||||
|
||||
export type RequestRateLimitInput = Readonly<{
|
||||
key: string;
|
||||
limit: number;
|
||||
windowMs: number;
|
||||
now?: number;
|
||||
store?: RequestRateLimitStore;
|
||||
}>;
|
||||
|
||||
const state = globalThis as typeof globalThis & {
|
||||
jyotishaRequestRateLimits?: RequestRateLimitStore;
|
||||
};
|
||||
|
||||
export const requestRateLimits = {
|
||||
locationSearch: { limit: 20, windowMs: 60_000 },
|
||||
locationTimezone: { limit: 30, windowMs: 60_000 },
|
||||
dailyStarlanguage: { limit: 10, windowMs: 60_000 },
|
||||
synastry: { limit: 8, windowMs: 60 * 60_000 },
|
||||
sessionWrite: { limit: 40, windowMs: 60_000 },
|
||||
} as const;
|
||||
|
||||
export type RequestRateLimitBucket = keyof typeof requestRateLimits;
|
||||
|
||||
function defaultStore(): RequestRateLimitStore {
|
||||
state.jyotishaRequestRateLimits ??= new Map();
|
||||
return state.jyotishaRequestRateLimits;
|
||||
}
|
||||
|
||||
export function consumeRequestRateLimit(input: RequestRateLimitInput): RequestRateLimitDecision {
|
||||
const now = input.now ?? Date.now();
|
||||
const store = input.store ?? defaultStore();
|
||||
const windowStart = now - input.windowMs;
|
||||
const recent = (store.get(input.key) ?? []).filter((timestamp) => timestamp > windowStart);
|
||||
if (recent.length >= input.limit) {
|
||||
const retryAfterSeconds = Math.max(1, Math.ceil((recent[0]! + input.windowMs - now) / 1000));
|
||||
store.set(input.key, recent);
|
||||
return { ok: false, retryAfterSeconds };
|
||||
}
|
||||
recent.push(now);
|
||||
store.set(input.key, recent);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export function consumeUserRequestRateLimit(
|
||||
bucket: RequestRateLimitBucket,
|
||||
userId: string,
|
||||
now?: number,
|
||||
): RequestRateLimitDecision {
|
||||
const spec = requestRateLimits[bucket];
|
||||
return consumeRequestRateLimit({
|
||||
key: `${bucket}:${userId}`,
|
||||
limit: spec.limit,
|
||||
windowMs: spec.windowMs,
|
||||
now,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import type { GlobalBirthProfile } from "./global-birth-payloads.ts";
|
||||
|
||||
const usableActiveStatuses = new Set(["accepted", "confirmed"]);
|
||||
|
||||
export type AccountBirthRow = Readonly<{
|
||||
name?: unknown;
|
||||
birth_date?: unknown;
|
||||
reported_birth_time?: unknown;
|
||||
active_birth_time?: unknown;
|
||||
birth_time?: unknown;
|
||||
birth_time_status?: unknown;
|
||||
country_code?: unknown;
|
||||
province_code?: unknown;
|
||||
city_code?: unknown;
|
||||
district_code?: unknown;
|
||||
latitude?: unknown;
|
||||
longitude?: unknown;
|
||||
timezone_offset?: unknown;
|
||||
timezone_id?: unknown;
|
||||
}>;
|
||||
|
||||
function text(value: unknown): string | undefined {
|
||||
if (typeof value !== "string") return undefined;
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : undefined;
|
||||
}
|
||||
|
||||
function calendarDate(value: unknown): string | undefined {
|
||||
if (value instanceof Date && Number.isFinite(value.getTime())) {
|
||||
return value.toISOString().slice(0, 10);
|
||||
}
|
||||
const raw = text(value)?.slice(0, 10);
|
||||
return raw && /^\d{4}-\d{2}-\d{2}$/.test(raw) ? raw : undefined;
|
||||
}
|
||||
|
||||
function clock(value: unknown): string | undefined {
|
||||
const raw = text(value);
|
||||
const match = raw ? /^(\d{1,2}):(\d{2})/.exec(raw) : null;
|
||||
if (!match) return undefined;
|
||||
const hour = Number.parseInt(match[1], 10);
|
||||
const minute = Number.parseInt(match[2], 10);
|
||||
if (hour < 0 || hour > 23 || minute < 0 || minute > 59) return undefined;
|
||||
return `${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function finiteNumber(value: unknown): number | undefined {
|
||||
if (typeof value === "number" && Number.isFinite(value)) return value;
|
||||
if (typeof value === "string" && value.trim()) {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function selectedClock(row: AccountBirthRow): string | undefined {
|
||||
const status = text(row.birth_time_status) ?? "";
|
||||
const active = clock(row.active_birth_time) ?? clock(row.birth_time);
|
||||
const reported = clock(row.reported_birth_time);
|
||||
if (usableActiveStatuses.has(status) && active) return active;
|
||||
return reported ?? active;
|
||||
}
|
||||
|
||||
export function globalBirthProfileFromAccountRow(row: AccountBirthRow): GlobalBirthProfile & {
|
||||
birthTimeStatus?: string;
|
||||
} {
|
||||
return {
|
||||
name: text(row.name),
|
||||
date: calendarDate(row.birth_date),
|
||||
time: selectedClock(row),
|
||||
countryCode: text(row.country_code),
|
||||
provinceCode: text(row.province_code),
|
||||
cityCode: text(row.city_code),
|
||||
districtCode: text(row.district_code),
|
||||
latitude: finiteNumber(row.latitude) ?? null,
|
||||
longitude: finiteNumber(row.longitude) ?? null,
|
||||
timezoneOffset: finiteNumber(row.timezone_offset) ?? null,
|
||||
timezoneId: text(row.timezone_id),
|
||||
birthTimeStatus: text(row.birth_time_status),
|
||||
};
|
||||
}
|
||||
|
||||
export function globalBirthProfileFromStoredChart(value: unknown): GlobalBirthProfile | null {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
||||
const record = value as Record<string, unknown>;
|
||||
const profile = globalBirthProfileFromAccountRow({
|
||||
name: record.name,
|
||||
birth_date: record.date ?? record.birth_date,
|
||||
reported_birth_time: record.reportedTime ?? record.reported_birth_time,
|
||||
active_birth_time: record.time ?? record.active_birth_time,
|
||||
birth_time: record.birth_time,
|
||||
birth_time_status: record.birthTimeStatus ?? record.birth_time_status,
|
||||
country_code: record.countryCode ?? record.country_code,
|
||||
province_code: record.provinceCode ?? record.province_code,
|
||||
city_code: record.cityCode ?? record.city_code,
|
||||
district_code: record.districtCode ?? record.district_code,
|
||||
latitude: record.latitude,
|
||||
longitude: record.longitude,
|
||||
timezone_offset: record.timezoneOffset ?? record.timezone_offset,
|
||||
timezone_id: record.timezoneId ?? record.timezone_id,
|
||||
});
|
||||
if (!profile.date || !profile.time) return null;
|
||||
return profile;
|
||||
}
|
||||
Reference in New Issue
Block a user