feat: auto title/subtitle, Nunito default, position-preserving sync, draggable font widget

- Default font changed to Nunito (id 6)
- Containers auto-inject "Title" + "Text here" subtitle on draw (grouped)
- MCP create_element defaults to title/subtitle card layout for containers
- Sync preserves element geometry (x/y/width/height) across refreshes
- normalizeForBackend preserves native bound text instead of collapsing to label.text
- Rate limits raised to 500 req/15min general, 30 req/min sync writes
- Draggable "Font px" widget with localStorage position persistence
- Left panel widened for full Opacity visibility
- Updated rate-limit tests to match new limits (30 write, 500 general)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
newblacc
2026-03-30 11:56:59 +02:00
co-authored by Claude Opus 4.6
parent 1e535a5e31
commit a605a15282
7 changed files with 380 additions and 38 deletions
+57
View File
@@ -108,6 +108,63 @@
position: relative;
}
/* Widen Excalidraw left properties panel */
.App-menu_left .Island {
min-width: 260px;
}
/* Draggable font size widget */
.custom-font-size-widget {
position: fixed;
z-index: 100;
background: var(--island-bg-color, #232329);
border-radius: 8px;
padding: 6px 10px;
box-shadow: 0 2px 8px rgba(0,0,0,0.4);
display: flex;
align-items: center;
gap: 6px;
cursor: grab;
user-select: none;
}
.custom-font-size-widget.dragging {
cursor: grabbing;
opacity: 0.9;
}
.custom-font-size-widget label {
font-size: 12px;
color: #aaa;
white-space: nowrap;
cursor: grab;
}
.custom-font-size-widget input[type="number"] {
width: 52px;
padding: 4px 6px;
font-size: 13px;
border: 1px solid #555;
border-radius: 4px;
background: #1a1a2e;
color: #fff;
text-align: center;
cursor: text;
}
.custom-font-size-widget input[type="number"]:focus {
outline: none;
border-color: #4a6cf7;
}
.custom-font-size-widget button {
padding: 4px 10px;
font-size: 12px;
border: none;
border-radius: 4px;
background: #4a6cf7;
color: #fff;
cursor: pointer;
}
.custom-font-size-widget button:hover {
background: #3a5ce5;
}
.api-panel {
position: fixed;
+222 -21
View File
@@ -87,6 +87,21 @@ function App(): JSX.Element {
const DEBOUNCE_MS = 3000
// Track known container IDs to auto-inject title on new shapes
const knownContainerIdsRef = useRef<Set<string>>(new Set())
const CONTAINER_TYPES = new Set(['rectangle', 'ellipse', 'diamond'])
// Custom font size input state
const [customFontSize, setCustomFontSize] = useState<string>('')
// Draggable widget state — default near top menu
const [widgetPos, setWidgetPos] = useState<{x: number, y: number}>(() => {
const saved = localStorage.getItem('font-widget-pos')
return saved ? JSON.parse(saved) : { x: window.innerWidth * 0.55, y: 90 }
})
const [isDragging, setIsDragging] = useState(false)
const dragOffset = useRef<{x: number, y: number}>({x: 0, y: 0})
// Tenant state
const [activeTenant, setActiveTenant] = useState<TenantInfo | null>(null)
const activeTenantIdRef = useRef<string | null>(null)
@@ -157,9 +172,117 @@ function App(): JSX.Element {
}
}, [])
// Apply custom font size to selected elements
const applyCustomFontSize = (size: number): void => {
const api = excalidrawAPIRef.current
if (!api || !size || size < 1) return
const appState = api.getAppState()
const selectedIds = appState.selectedElementIds || {}
const scene = api.getSceneElements()
const updated = scene.map((el: any) => {
if (selectedIds[el.id] && (el.type === 'text' || (el as any).fontSize !== undefined)) {
return { ...el, fontSize: size }
}
return el
})
api.updateScene({ elements: updated, captureUpdate: CaptureUpdateAction.IMMEDIATELY })
}
// Pending title injection — deferred to avoid updateScene inside onChange
const pendingTitleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
// Trailing debounce: resets on every change, fires after user is idle.
// Only active when auto-save is on.
const handleCanvasChange = (): void => {
// Auto-inject title into new containers (rectangle, ellipse, diamond)
// Deferred: collect candidates, inject after onChange completes
if (pendingTitleTimerRef.current) clearTimeout(pendingTitleTimerRef.current)
pendingTitleTimerRef.current = setTimeout(() => {
const api = excalidrawAPIRef.current
if (!api) return
const elements = api.getSceneElements()
const newContainers: typeof elements[number][] = []
for (const el of elements) {
if (
CONTAINER_TYPES.has(el.type) &&
!el.isDeleted &&
!knownContainerIdsRef.current.has(el.id) &&
el.width > 30 && el.height > 30
) {
const hasBoundText = (el as any).boundElements?.some((b: any) => b.type === 'text')
if (!hasBoundText) {
newContainers.push(el)
}
knownContainerIdsRef.current.add(el.id)
}
}
if (newContainers.length > 0) {
const scene = api.getSceneElements()
const updated = [...scene] as any[]
for (const container of newContainers) {
const groupId = `${container.id}_group`
const textId = `${container.id}_title`
const subtitleId = `${container.id}_subtitle`
// Center-based positioning — works for all shapes
const cx = container.x + container.width / 2
const cy = container.y + container.height / 2
// Title — 15% above center
const titleConverted = convertToExcalidrawElements([{
type: 'text' as const,
id: textId,
x: cx,
y: cy - container.height * 0.15,
text: 'Title',
fontSize: 24,
fontFamily: 6,
textAlign: 'center' as const,
strokeColor: '#1e1e1e',
}], { regenerateIds: false })
const titleText = titleConverted.map((el: any) => ({
...el,
groupIds: [groupId],
}))
// Subtitle — 10% below center
const subtitleConverted = convertToExcalidrawElements([{
type: 'text' as const,
id: subtitleId,
x: cx,
y: cy + container.height * 0.10,
text: 'Text here',
fontSize: 16,
fontFamily: 6,
textAlign: 'center' as const,
strokeColor: '#868e96',
}], { regenerateIds: false })
const subtitleText = subtitleConverted.map((el: any) => ({
...el,
groupIds: [groupId],
}))
// Add group to container
const idx = updated.findIndex((e: any) => e.id === container.id)
if (idx >= 0) {
const existingGroups = (updated[idx] as any).groupIds || []
updated[idx] = {
...updated[idx],
groupIds: [...existingGroups, groupId]
}
}
updated.push(...titleText, ...subtitleText)
}
api.updateScene({ elements: updated, captureUpdate: CaptureUpdateAction.IMMEDIATELY })
}
}, 300) // 300ms delay — fires after drawing finishes
if (!autoSave) return
if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current)
@@ -167,8 +290,8 @@ function App(): JSX.Element {
debounceTimerRef.current = setTimeout(() => {
if (!excalidrawAPI || isSyncingRef.current) return
const elements = excalidrawAPI.getSceneElements()
const hash = computeElementHash(elements)
const currentElements = excalidrawAPI.getSceneElements()
const hash = computeElementHash(currentElements)
if (hash === lastSyncedHashRef.current) return
syncToBackend()
@@ -243,18 +366,38 @@ function App(): JSX.Element {
return
}
const cleanedElements = result.elements.map(cleanElementForExcalidraw)
// Save original geometry for all DB elements — convertToExcalidrawElements
// recalculates metrics and shifts positions for text, containers, and arrows
const originalGeometry = new Map<string, { x: number; y: number; width: number; height: number }>()
for (const el of cleanedElements) {
if (el.x != null && el.y != null) {
originalGeometry.set(el.id, { x: el.x, y: el.y, width: (el as any).width ?? 0, height: (el as any).height ?? 0 })
}
}
// Expand server-format label.text into native Excalidraw bound text
// elements so labels survive round-trips through the DB.
const expandedElements = expandLabelsToNative(cleanedElements)
const hasNativeFormat = expandedElements.some((el: any) => el.containerId)
if (hasNativeFormat) {
const validated = validateAndFixBindings(expandedElements)
excalidrawAPI?.updateScene({ elements: validated as any })
} else {
const convertedElements = convertElementsPreservingImageProps(expandedElements)
excalidrawAPI?.updateScene({ elements: convertedElements })
// Convert through Excalidraw to get proper element objects
// (with seed, version, versionNonce, etc.)
const convertedElements = convertElementsPreservingImageProps(expandedElements)
// Restore original geometry for elements that existed in the DB
// (skip synthetic elements created by expandLabelsToNative)
const finalElements = convertedElements.map((el: any) => {
const orig = originalGeometry.get(el.id)
if (orig) {
return { ...el, x: orig.x, y: orig.y, width: orig.width, height: orig.height }
}
return el
})
// Seed known containers BEFORE updateScene so onChange doesn't re-inject titles
for (const el of finalElements) {
if (CONTAINER_TYPES.has((el as any).type)) {
knownContainerIdsRef.current.add((el as any).id)
}
}
excalidrawAPI?.updateScene({ elements: finalElements })
// Populate sync baseline so deletions are detected on next sync
const baselineMap = new Map<string, ServerElement>()
for (const el of result.elements) {
@@ -475,6 +618,12 @@ function App(): JSX.Element {
const cleanedElements = data.elements.map(cleanElementForExcalidraw)
const validatedElements = validateAndFixBindings(cleanedElements)
const convertedElements = convertElementsPreservingImageProps(validatedElements)
// Seed known containers before updateScene
for (const el of convertedElements) {
if (CONTAINER_TYPES.has((el as any).type)) {
knownContainerIdsRef.current.add((el as any).id)
}
}
api.updateScene({
elements: convertedElements,
captureUpdate: CaptureUpdateAction.NEVER
@@ -522,6 +671,11 @@ function App(): JSX.Element {
const cleanedElements = data.elements.map(cleanElementForExcalidraw)
const validatedElements = validateAndFixBindings(cleanedElements)
const convertedElements = convertElementsPreservingImageProps(validatedElements)
for (const el of convertedElements) {
if (CONTAINER_TYPES.has((el as any).type)) {
knownContainerIdsRef.current.add((el as any).id)
}
}
api.updateScene({
elements: convertedElements,
captureUpdate: CaptureUpdateAction.NEVER
@@ -952,18 +1106,14 @@ function App(): JSX.Element {
const result: ServerElement[] = []
for (const el of elements) {
if (boundTextIds.has(el.id)) continue // skip bound text — merged into container
// Keep bound text elements as-is — store native Excalidraw format
// so x/y/width/height survive round-trips without recalculation
const out: any = { ...el }
// If this container has bound text, put it back as label.text
const merged = containerTextMap.get(el.id)
if (merged && merged.text) {
out.label = { text: merged.text }
if (merged.fontSize) out.fontSize = merged.fontSize
if (merged.fontFamily) out.fontFamily = merged.fontFamily
// Clean up Excalidraw-internal binding metadata
delete out.boundElements
// Strip label.text from containers that have native bound text,
// so the load path doesn't double-create text elements
if (containerTextMap.has(el.id)) {
delete out.label
}
// Normalize arrow bindings from Excalidraw format back to MCP format
@@ -1112,11 +1262,15 @@ function App(): JSX.Element {
merged = merged.filter(el => el.id !== sc.id)
} else if (sc.element) {
const cleaned = cleanElementForExcalidraw(sc.element)
const converted = convertToExcalidrawElements([cleaned], { regenerateIds: false })
const idx = merged.findIndex(el => el.id === sc.id)
if (idx >= 0) {
merged[idx] = converted[0]!
// Existing element: spread-merge to preserve geometry and
// Excalidraw internals (seed, version, versionNonce)
merged[idx] = { ...merged[idx], ...cleaned } as any
} else {
// New element from MCP/other tab: must convert to get proper internals
const converted = convertToExcalidrawElements([cleaned], { regenerateIds: false })
merged.push(...converted)
}
}
@@ -1333,6 +1487,53 @@ function App(): JSX.Element {
</div>
)}
{/* Draggable font size widget */}
<div
className={`custom-font-size-widget${isDragging ? ' dragging' : ''}`}
style={{ left: widgetPos.x, top: widgetPos.y }}
onMouseDown={e => {
// Don't drag when clicking input or button
if ((e.target as HTMLElement).tagName === 'INPUT' || (e.target as HTMLElement).tagName === 'BUTTON') return
setIsDragging(true)
dragOffset.current = { x: e.clientX - widgetPos.x, y: e.clientY - widgetPos.y }
const onMove = (ev: MouseEvent) => {
const newPos = { x: ev.clientX - dragOffset.current.x, y: ev.clientY - dragOffset.current.y }
setWidgetPos(newPos)
}
const onUp = () => {
setIsDragging(false)
setWidgetPos(prev => {
localStorage.setItem('font-widget-pos', JSON.stringify(prev))
return prev
})
window.removeEventListener('mousemove', onMove)
window.removeEventListener('mouseup', onUp)
}
window.addEventListener('mousemove', onMove)
window.addEventListener('mouseup', onUp)
}}
>
<label>Font px</label>
<input
type="number"
min="1"
max="200"
placeholder="size"
value={customFontSize}
onChange={e => setCustomFontSize(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter') {
const size = parseInt(customFontSize, 10)
if (size > 0) applyCustomFontSize(size)
}
}}
/>
<button onClick={() => {
const size = parseInt(customFontSize, 10)
if (size > 0) applyCustomFontSize(size)
}}>Set</button>
</div>
{/* Canvas Container */}
<div className="canvas-container">
<Excalidraw
+1 -1
View File
@@ -9,5 +9,5 @@
{ "id": 9, "name": "Liberation Sans", "label": "Liberation Sans", "aliases": ["liberation sans"], "legacy": true },
{ "id": 1, "name": "Virgil", "label": "Virgil (legacy)", "aliases": ["virgil"], "legacy": true }
],
"defaultFontFamily": 5
"defaultFontFamily": 6
}
+94 -10
View File
@@ -335,6 +335,14 @@ const ElementSchema = z.object({
elbowed: z.boolean().optional(),
startElementId: z.string().optional(),
endElementId: z.string().optional(),
textAlign: z.string().optional(),
verticalAlign: z.string().optional(),
title: z.string().optional(),
titleFontSize: z.number().optional(),
titleFontFamily: z.union([z.string(), z.number()]).optional(),
subtitle: z.string().optional(),
subtitleFontSize: z.number().optional(),
subtitleFontFamily: z.union([z.string(), z.number()]).optional(),
endArrowhead: z.string().optional(),
startArrowhead: z.string().optional(),
fileId: z.string().optional(),
@@ -470,7 +478,7 @@ const DIAGRAM_DESIGN_GUIDE = `# Excalidraw Diagram Design Guide
const tools: Tool[] = [
{
name: 'create_element',
description: 'Create a new Excalidraw element. For arrows, use startElementId/endElementId to bind to shapes (auto-routes to edges).',
description: 'Create a new Excalidraw element. For arrows, use startElementId/endElementId to bind to shapes (auto-routes to edges). For containers (rectangle, ellipse, diamond): use title+subtitle for a card layout with independent font styling — both are grouped so they move together.',
inputSchema: {
type: 'object',
properties: {
@@ -489,9 +497,17 @@ const tools: Tool[] = [
strokeStyle: { type: 'string', description: 'Stroke style: solid, dashed, dotted' },
roughness: { type: 'number' },
opacity: { type: 'number' },
text: { type: 'string' },
text: { type: 'string', description: 'Simple label text (use title+subtitle instead for card layout)' },
fontSize: { type: 'number' },
fontFamily: { type: ['string', 'number'], description: FONT_FAMILY_DESCRIPTION },
textAlign: { type: 'string', description: 'Text horizontal alignment: left, center, right (default: center)' },
verticalAlign: { type: 'string', description: 'Text vertical alignment: top, middle (default: top for containers)' },
title: { type: 'string', description: 'Title text for card layout (bound to container, moves with it)' },
titleFontSize: { type: 'number', description: 'Title font size (default: 24)' },
titleFontFamily: { type: ['string', 'number'], description: 'Title font family (default: Nunito). ' + FONT_FAMILY_DESCRIPTION },
subtitle: { type: 'string', description: 'Subtitle/paragraph text (grouped with container, moves together)' },
subtitleFontSize: { type: 'number', description: 'Subtitle font size (default: 16)' },
subtitleFontFamily: { type: ['string', 'number'], description: 'Subtitle font family (default: Nunito). ' + FONT_FAMILY_DESCRIPTION },
startElementId: { type: 'string', description: 'For arrows: ID of the element to bind the arrow start to. Arrow auto-routes to element edge.' },
endElementId: { type: 'string', description: 'For arrows: ID of the element to bind the arrow end to. Arrow auto-routes to element edge.' },
endArrowhead: { type: 'string', description: 'Arrowhead style at end: arrow, bar, dot, triangle, or null' },
@@ -1038,8 +1054,11 @@ function convertTextToLabel(element: ServerElement): ServerElement {
// Standalone text elements keep text as a direct property
if (element.type === 'text') return element;
// All container/shape/arrow elements: map text → label.text (empty string clears it)
// Default containers to top-center alignment for title/subtitle layout
const isArrow = element.type === 'arrow' || element.type === 'line';
return {
...rest,
verticalAlign: (rest as any).verticalAlign ?? (isArrow ? 'middle' : 'top'),
label: { text }
} as ServerElement;
}
@@ -1055,15 +1074,35 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
const params = ElementSchema.parse(args);
logger.info('Creating element via MCP', { type: params.type });
const { startElementId, endElementId, id: customId, ...elementProps } = params;
const {
startElementId, endElementId, id: customId,
title, titleFontSize, titleFontFamily,
subtitle, subtitleFontSize, subtitleFontFamily,
...elementProps
} = params;
const id = customId || generateId();
const normalizedFont = normalizeFontFamily(elementProps.fontFamily);
// Auto-populate title+subtitle for container types unless text is explicitly set
const CONTAINER_TYPES = new Set(['rectangle', 'ellipse', 'diamond']);
const isContainer = CONTAINER_TYPES.has(params.type);
const hasExplicitText = elementProps.text !== undefined;
const effectiveTitle = title ?? (isContainer && !hasExplicitText ? 'Title' : undefined);
const effectiveSubtitle = subtitle ?? (isContainer && !hasExplicitText && effectiveTitle ? 'Description' : undefined);
const effectiveText = effectiveTitle ?? elementProps.text;
const effectiveFontSize = effectiveTitle ? (titleFontSize ?? 24) : (elementProps.fontSize ?? USER_PREFS.fontSize);
const effectiveFontFamily = effectiveTitle
? (normalizeFontFamily(titleFontFamily) ?? USER_PREFS.fontFamily)
: (normalizedFont ?? USER_PREFS.fontFamily);
const element: ServerElement = {
id,
...elementProps,
fontFamily: normalizedFont ?? USER_PREFS.fontFamily,
text: effectiveText,
fontFamily: effectiveFontFamily,
roughness: elementProps.roughness ?? USER_PREFS.roughness,
fontSize: elementProps.fontSize ?? USER_PREFS.fontSize,
fontSize: effectiveFontSize,
strokeWidth: elementProps.strokeWidth ?? USER_PREFS.strokeWidth,
points: elementProps.points ? normalizePoints(elementProps.points) : undefined,
...(startElementId ? { start: { id: startElementId } } : {}),
@@ -1081,18 +1120,59 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
// Convert text to label format for Excalidraw
const excalidrawElement = convertTextToLabel(element);
// Create element directly on HTTP server (no local storage)
// Card layout: title (bound) + subtitle (grouped standalone text)
const groupId = (effectiveTitle && effectiveSubtitle && isContainer) ? generateId() : undefined;
// Add groupId to container if using card layout
if (groupId) {
(excalidrawElement as any).groupIds = [groupId];
}
// Create the container element
const canvasResponse = await createElementOnCanvas(excalidrawElement);
if (!canvasResponse) {
throw new Error('Failed to create element: HTTP server unavailable');
}
let subtitleResponse: any = null;
// Create subtitle as a grouped standalone text element
if (effectiveSubtitle && isContainer && groupId) {
const containerWidth = elementProps.width ?? 200;
const containerHeight = elementProps.height ?? 100;
const subtitleId = generateId();
const resolvedSubtitleFont = normalizeFontFamily(subtitleFontFamily) ?? USER_PREFS.fontFamily;
const resolvedSubtitleSize = subtitleFontSize ?? 16;
const subtitleElement: ServerElement = {
id: subtitleId,
type: 'text' as any,
x: element.x + 10,
y: element.y + (containerHeight * 0.45),
width: containerWidth - 20,
height: containerHeight * 0.5,
text: effectiveSubtitle,
fontSize: resolvedSubtitleSize,
fontFamily: resolvedSubtitleFont,
strokeColor: elementProps.strokeColor ?? '#1e1e1e',
opacity: elementProps.opacity ?? 100,
roughness: elementProps.roughness ?? USER_PREFS.roughness,
groupIds: [groupId],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
version: 1
} as any;
subtitleResponse = await createElementOnCanvas(subtitleElement);
}
const synced = canvasResponse.syncedToCanvas ?? false;
logger.info('Element created via MCP', {
id: excalidrawElement.id,
type: excalidrawElement.type,
synced,
hasSubtitle: !!subtitleResponse,
canvasStatus: canvasResponse.canvasStatus
});
@@ -1101,10 +1181,14 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
? 'Synced to canvas and confirmed by browser'
: `Canvas sync not confirmed (${canvasResponse.canvasStatus?.reason ?? 'unknown'})`;
const subtitleInfo = subtitleResponse
? `\n\nSubtitle element: ${subtitleResponse.element?.id ?? 'created'} (grouped)`
: '';
return {
content: [{
type: 'text',
text: `Element created successfully!\n\n${JSON.stringify(canvasResponse.element ?? excalidrawElement, null, 2)}\n\n${statusEmoji} ${statusText}`
text: `Element created successfully!\n\n${JSON.stringify(canvasResponse.element ?? excalidrawElement, null, 2)}${subtitleInfo}\n\n${statusEmoji} ${statusText}`
}]
};
}
@@ -2366,7 +2450,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
base.fontSize = rest.fontSize ?? USER_PREFS.fontSize;
base.fontFamily = rest.fontFamily ?? USER_PREFS.fontFamily;
base.textAlign = rest.textAlign ?? 'center';
base.verticalAlign = rest.verticalAlign ?? 'middle';
base.verticalAlign = rest.verticalAlign ?? (rest.containerId ? 'top' : 'middle');
base.autoResize = rest.autoResize ?? true;
base.lineHeight = rest.lineHeight ?? 1.25;
base.containerId = rest.containerId ?? null;
@@ -2460,8 +2544,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
originalText: labelText,
fontSize: isArrow ? 14 : (rest.fontSize ?? USER_PREFS.fontSize),
fontFamily: rest.fontFamily ?? USER_PREFS.fontFamily,
textAlign: 'center',
verticalAlign: 'middle',
textAlign: rest.textAlign ?? 'center',
verticalAlign: rest.verticalAlign ?? (isArrow ? 'middle' : 'top'),
autoResize: true,
lineHeight: 1.25,
containerId: base.id
+2 -2
View File
@@ -137,7 +137,7 @@ export function validateMermaidInput(req: Request, res: Response, next: NextFunc
// General limit for all /api routes.
export const generalRateLimit = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: getEnvInt('EXCALIDRAW_RATE_LIMIT_GENERAL_MAX', 100),
max: getEnvInt('EXCALIDRAW_RATE_LIMIT_GENERAL_MAX', 500),
standardHeaders: 'draft-7',
legacyHeaders: false,
message: { success: false, error: 'Too many requests, please try again later.' },
@@ -155,7 +155,7 @@ export const destructiveRateLimit = rateLimit({
// Stricter limit for write-heavy sync operations.
export const writeBurstLimit = rateLimit({
windowMs: 60 * 1000, // 1 minute
max: getEnvInt('EXCALIDRAW_RATE_LIMIT_WRITE_BURST_MAX', 10),
max: getEnvInt('EXCALIDRAW_RATE_LIMIT_WRITE_BURST_MAX', 30),
standardHeaders: 'draft-7',
legacyHeaders: false,
message: { success: false, error: 'Too many sync operations, please slow down.' },
+1 -1
View File
@@ -51,7 +51,7 @@ describe('Middleware order', () => {
it('401 with bad API key is still rate-limited', async () => {
const ip = '10.20.0.3';
for (let i = 0; i < 100; i++) {
for (let i = 0; i < 500; i++) {
await request(app)
.get('/api/elements')
.set('X-API-Key', 'wrong-key')
+3 -3
View File
@@ -105,7 +105,7 @@ describe('Rate limiting — destructive endpoints', () => {
describe('Rate limiting — sync endpoints', () => {
it('returns 429 after exceeding /api/elements/sync write-burst limit', async () => {
const ip = '10.10.0.1';
for (let i = 0; i < 10; i++) {
for (let i = 0; i < 30; i++) {
await request(app)
.post('/api/elements/sync')
.set('X-Forwarded-For', ip)
@@ -122,7 +122,7 @@ describe('Rate limiting — sync endpoints', () => {
it('returns 429 after exceeding /api/elements/sync/v2 write-burst limit', async () => {
const ip = '10.10.0.2';
for (let i = 0; i < 10; i++) {
for (let i = 0; i < 30; i++) {
await request(app)
.post('/api/elements/sync/v2')
.set('X-Forwarded-For', ip)
@@ -139,7 +139,7 @@ describe('Rate limiting — sync endpoints', () => {
it('sync 429 responses include rate-limit headers', async () => {
const ip = '10.10.0.3';
for (let i = 0; i < 10; i++) {
for (let i = 0; i < 30; i++) {
await request(app)
.post('/api/elements/sync')
.set('X-Forwarded-For', ip)