fix(api): bind daily and synastry charts to stored profiles
Independent Staging Quality Gate / validate (push) Has been cancelled
Independent Staging Quality Gate / publish (push) Has been cancelled

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:
Jesse_Chen
2026-08-19 19:39:01 +08:00
co-authored by Cursor
parent 40a595045e
commit c38f11dbd3
16 changed files with 548 additions and 39 deletions
@@ -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),
+61
View File
@@ -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;
}