refactor(backend): modular server split with new routes, persistence and agent layer
Browse files- Introduce create-app factory to compose routes, middleware and Hocuspocus in isolation
- Extract route modules (auth, chat, publish) and move storage logic into persistence.ts
- Harden auth + HF storage integration (user token fallback, secure cookie in production)
- Add embed studio AI pipeline (embed-chat, embed-tools, system prompt) and shared stream handler
- Cover the new surface with api-routes, persistence and agent-chat test suites
- Ship .env.example and .gitignore so new contributors can boot without guesswork
Made-with: Cursor
- backend/.env.example +7 -0
- backend/.gitignore +2 -0
- backend/src/agent/chat.ts +20 -67
- backend/src/agent/embed-chat.ts +25 -0
- backend/src/agent/embed-system-prompt.ts +220 -0
- backend/src/agent/embed-tools.ts +60 -0
- backend/src/agent/stream-handler.ts +76 -0
- backend/src/auth.ts +15 -6
- backend/src/create-app.ts +219 -0
- backend/src/persistence.ts +90 -0
- backend/src/routes/auth.ts +67 -0
- backend/src/routes/chat.ts +14 -0
- backend/src/routes/publish.ts +339 -0
- backend/src/server.ts +4 -468
- backend/src/utils.ts +16 -2
- backend/tests/agent-chat.test.ts +128 -0
- backend/tests/api-routes.test.ts +156 -0
- backend/tests/persistence.test.ts +110 -0
backend/.env.example
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# OAuth - Create an app at https://huggingface.co/settings/connected-applications
|
| 2 |
+
# Set redirect URI to: http://localhost:8080/auth/callback
|
| 3 |
+
OAUTH_CLIENT_ID=
|
| 4 |
+
OAUTH_CLIENT_SECRET=
|
| 5 |
+
|
| 6 |
+
# AI model routing
|
| 7 |
+
OPENROUTER_API_KEY=
|
backend/.gitignore
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.e2e-data-*
|
| 2 |
+
test-results/
|
backend/src/agent/chat.ts
CHANGED
|
@@ -1,74 +1,27 @@
|
|
| 1 |
-
import { streamText, convertToModelMessages } from "ai";
|
| 2 |
-
import { createOpenRouter } from "@openrouter/ai-sdk-provider";
|
| 3 |
import { editorTools } from "./tools.js";
|
| 4 |
import { SYSTEM_PROMPT, buildMessages } from "./system-prompt.js";
|
|
|
|
| 5 |
import type { Request, Response } from "express";
|
| 6 |
|
| 7 |
-
const
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
}
|
| 14 |
-
|
| 15 |
-
}
|
| 16 |
|
| 17 |
export async function handleChat(req: Request, res: Response) {
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
const contextBlock = buildMessages(context?.document, context?.selection, context?.frontmatter);
|
| 30 |
-
const systemMessages = contextBlock
|
| 31 |
-
? `${SYSTEM_PROMPT}\n\n## Current context\n\n${contextBlock}`
|
| 32 |
-
: SYSTEM_PROMPT;
|
| 33 |
-
|
| 34 |
-
const modelMessages = await convertToModelMessages(messages);
|
| 35 |
-
|
| 36 |
-
const result = streamText({
|
| 37 |
-
model: provider.chat(modelId),
|
| 38 |
-
system: systemMessages,
|
| 39 |
-
messages: modelMessages,
|
| 40 |
-
tools: editorTools,
|
| 41 |
-
});
|
| 42 |
-
|
| 43 |
-
const webResponse = result.toUIMessageStreamResponse({
|
| 44 |
-
onError: (error) => {
|
| 45 |
-
console.error("[chat] stream error:", error);
|
| 46 |
-
return error instanceof Error ? error.message : "Stream error";
|
| 47 |
-
},
|
| 48 |
-
});
|
| 49 |
-
|
| 50 |
-
res.writeHead(
|
| 51 |
-
webResponse.status,
|
| 52 |
-
Object.fromEntries(webResponse.headers.entries()),
|
| 53 |
-
);
|
| 54 |
-
const reader = webResponse.body!.getReader();
|
| 55 |
-
const pump = async (): Promise<void> => {
|
| 56 |
-
const { done, value } = await reader.read();
|
| 57 |
-
if (done) {
|
| 58 |
-
res.end();
|
| 59 |
-
return;
|
| 60 |
-
}
|
| 61 |
-
res.write(value);
|
| 62 |
-
return pump();
|
| 63 |
-
};
|
| 64 |
-
await pump();
|
| 65 |
-
} catch (error: unknown) {
|
| 66 |
-
const message =
|
| 67 |
-
error instanceof Error ? error.message : "Internal server error";
|
| 68 |
-
console.error("[chat] error:", message);
|
| 69 |
-
|
| 70 |
-
if (!res.headersSent) {
|
| 71 |
-
res.status(500).json({ error: message });
|
| 72 |
-
}
|
| 73 |
-
}
|
| 74 |
}
|
|
|
|
|
|
|
|
|
|
| 1 |
import { editorTools } from "./tools.js";
|
| 2 |
import { SYSTEM_PROMPT, buildMessages } from "./system-prompt.js";
|
| 3 |
+
import { streamChatResponse } from "./stream-handler.js";
|
| 4 |
import type { Request, Response } from "express";
|
| 5 |
|
| 6 |
+
export const AVAILABLE_MODELS = [
|
| 7 |
+
{ id: "google/gemini-2.5-flash", label: "Gemini 2.5 Flash", context: "1M", cost: "$" },
|
| 8 |
+
{ id: "google/gemini-2.5-pro", label: "Gemini 2.5 Pro", context: "1M", cost: "$$" },
|
| 9 |
+
{ id: "anthropic/claude-sonnet-4", label: "Claude Sonnet 4", context: "200K", cost: "$$$" },
|
| 10 |
+
{ id: "anthropic/claude-3.5-haiku", label: "Claude 3.5 Haiku", context: "200K", cost: "$" },
|
| 11 |
+
{ id: "openai/gpt-4.1-mini", label: "GPT-4.1 Mini", context: "1M", cost: "$" },
|
| 12 |
+
{ id: "openai/gpt-4.1", label: "GPT-4.1", context: "1M", cost: "$$" },
|
| 13 |
+
];
|
|
|
|
| 14 |
|
| 15 |
export async function handleChat(req: Request, res: Response) {
|
| 16 |
+
const { context } = req.body;
|
| 17 |
+
const contextBlock = buildMessages(context?.document, context?.selection, context?.frontmatter);
|
| 18 |
+
const systemPrompt = contextBlock
|
| 19 |
+
? `${SYSTEM_PROMPT}\n\n## Current context\n\n${contextBlock}`
|
| 20 |
+
: SYSTEM_PROMPT;
|
| 21 |
+
|
| 22 |
+
return streamChatResponse(req, res, {
|
| 23 |
+
systemPrompt,
|
| 24 |
+
tools: editorTools,
|
| 25 |
+
logPrefix: "chat",
|
| 26 |
+
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
}
|
backend/src/agent/embed-chat.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import {
|
| 2 |
+
EMBED_SYSTEM_PROMPT,
|
| 3 |
+
BANNER_SYSTEM_PROMPT,
|
| 4 |
+
buildEmbedContext,
|
| 5 |
+
} from "./embed-system-prompt.js";
|
| 6 |
+
import { embedTools } from "./embed-tools.js";
|
| 7 |
+
import { streamChatResponse } from "./stream-handler.js";
|
| 8 |
+
import type { Request, Response } from "express";
|
| 9 |
+
|
| 10 |
+
export async function handleEmbedChat(req: Request, res: Response) {
|
| 11 |
+
const { context } = req.body;
|
| 12 |
+
const contextBlock = buildEmbedContext(context?.embedHtml);
|
| 13 |
+
const isBanner = Boolean(context?.isBanner);
|
| 14 |
+
|
| 15 |
+
const parts = [EMBED_SYSTEM_PROMPT];
|
| 16 |
+
if (isBanner) parts.push(BANNER_SYSTEM_PROMPT);
|
| 17 |
+
if (contextBlock) parts.push(`## Current chart\n\n${contextBlock}`);
|
| 18 |
+
const systemPrompt = parts.join("\n\n");
|
| 19 |
+
|
| 20 |
+
return streamChatResponse(req, res, {
|
| 21 |
+
systemPrompt,
|
| 22 |
+
tools: embedTools,
|
| 23 |
+
logPrefix: "embed-chat",
|
| 24 |
+
});
|
| 25 |
+
}
|
backend/src/agent/embed-system-prompt.ts
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* System prompt for the Embed Studio AI.
|
| 3 |
+
*
|
| 4 |
+
* Injected only when the Embed Studio panel is open, keeping the main
|
| 5 |
+
* article chat lightweight.
|
| 6 |
+
*/
|
| 7 |
+
|
| 8 |
+
export const EMBED_SYSTEM_PROMPT = `You are a D3.js data visualization expert embedded in a chart editor.
|
| 9 |
+
You help users create, edit, and improve interactive data visualizations.
|
| 10 |
+
|
| 11 |
+
## Tools
|
| 12 |
+
|
| 13 |
+
- **createEmbed**: Create or fully replace the chart HTML. Use for new charts or extensive rewrites.
|
| 14 |
+
- **patchEmbed**: Surgically edit a specific part of the chart. Preferred for small changes.
|
| 15 |
+
The search string must be an exact verbatim copy from the current HTML.
|
| 16 |
+
Call readEmbed first if you're unsure of the exact content.
|
| 17 |
+
- **readEmbed**: Read the full current HTML. Call before patching.
|
| 18 |
+
|
| 19 |
+
## Chart conventions
|
| 20 |
+
|
| 21 |
+
Every chart is a self-contained HTML fragment with:
|
| 22 |
+
1. A single root \`<div class="d3-<name>">\`
|
| 23 |
+
2. A scoped \`<style>\` block (all rules under the root class)
|
| 24 |
+
3. An IIFE \`<script>\` that mounts the chart
|
| 25 |
+
|
| 26 |
+
### Structure template
|
| 27 |
+
|
| 28 |
+
\`\`\`html
|
| 29 |
+
<div class="d3-yourname"></div>
|
| 30 |
+
<style>
|
| 31 |
+
.d3-yourname { /* scoped styles */ }
|
| 32 |
+
</style>
|
| 33 |
+
<script>
|
| 34 |
+
(() => {
|
| 35 |
+
const ensureD3 = (cb) => {
|
| 36 |
+
if (window.d3 && typeof window.d3.select === 'function') return cb();
|
| 37 |
+
let s = document.getElementById('d3-cdn-script');
|
| 38 |
+
if (!s) { s = document.createElement('script'); s.id = 'd3-cdn-script'; s.src = 'https://cdn.jsdelivr.net/npm/d3@7/dist/d3.min.js'; document.head.appendChild(s); }
|
| 39 |
+
const onReady = () => { if (window.d3 && typeof window.d3.select === 'function') cb(); };
|
| 40 |
+
s.addEventListener('load', onReady, { once: true });
|
| 41 |
+
if (window.d3) onReady();
|
| 42 |
+
};
|
| 43 |
+
|
| 44 |
+
const bootstrap = () => {
|
| 45 |
+
const scriptEl = document.currentScript;
|
| 46 |
+
let container = scriptEl ? scriptEl.previousElementSibling : null;
|
| 47 |
+
if (!(container && container.classList.contains('d3-yourname'))) {
|
| 48 |
+
const cs = Array.from(document.querySelectorAll('.d3-yourname'))
|
| 49 |
+
.filter(el => el.dataset.mounted !== 'true');
|
| 50 |
+
container = cs[cs.length - 1] || null;
|
| 51 |
+
}
|
| 52 |
+
if (!container) return;
|
| 53 |
+
if (container.dataset.mounted === 'true') return;
|
| 54 |
+
container.dataset.mounted = 'true';
|
| 55 |
+
|
| 56 |
+
// Chart code here...
|
| 57 |
+
};
|
| 58 |
+
|
| 59 |
+
if (document.readyState === 'loading') {
|
| 60 |
+
document.addEventListener('DOMContentLoaded', () => ensureD3(bootstrap), { once: true });
|
| 61 |
+
} else {
|
| 62 |
+
ensureD3(bootstrap);
|
| 63 |
+
}
|
| 64 |
+
})();
|
| 65 |
+
</script>
|
| 66 |
+
\`\`\`
|
| 67 |
+
|
| 68 |
+
### Colors & theming (MANDATORY)
|
| 69 |
+
- Get colors from \`window.ColorPalettes\`:
|
| 70 |
+
- \`ColorPalettes.getColors('categorical', 8)\` - 8-color categorical palette
|
| 71 |
+
- \`ColorPalettes.getColors('sequential', 8)\` - sequential blues
|
| 72 |
+
- \`ColorPalettes.getColors('diverging', 9)\` - red-blue diverging
|
| 73 |
+
- \`ColorPalettes.getPrimary()\` - current primary accent color
|
| 74 |
+
- Use CSS variables for theming: \`--primary-color\`, \`--text-color\`, \`--muted-color\`, \`--surface-bg\`, \`--border-color\`
|
| 75 |
+
- Axis/tick/grid: \`--axis-color\`, \`--tick-color\`, \`--grid-color\`
|
| 76 |
+
- NEVER hardcode color arrays
|
| 77 |
+
|
| 78 |
+
### Layout rules
|
| 79 |
+
- SVG for chart primitives (marks, axes, gridlines) only
|
| 80 |
+
- Legends, controls, tooltips: HTML elements (not SVG)
|
| 81 |
+
- Legend: visible title "Legend", swatch 14x14px, border-radius 3px
|
| 82 |
+
- Controls: plain HTML selects/inputs, styled consistently
|
| 83 |
+
- Tooltip: single \`.d3-tooltip\` absolutely positioned inside container
|
| 84 |
+
|
| 85 |
+
### Background (MANDATORY)
|
| 86 |
+
- The embed root \`<div>\` MUST be **transparent**: no \`background\`,
|
| 87 |
+
\`background-color\`, \`background-image\` or gradient, no opaque fill.
|
| 88 |
+
- The embed sits on top of the article surface and inherits its colour
|
| 89 |
+
in both light and dark mode. Adding a background creates a visible
|
| 90 |
+
rectangle that breaks the flow of the page.
|
| 91 |
+
- Do NOT set a background on \`body\`, \`html\` or wrapper containers.
|
| 92 |
+
- The ONLY exception: the user explicitly asks for one (e.g. "add a
|
| 93 |
+
light grey background", "give it a card look", "highlight the chart
|
| 94 |
+
area"). Even then, prefer \`var(--surface-bg)\` /
|
| 95 |
+
\`var(--surface-elevated-bg)\` over hardcoded colours so it adapts to
|
| 96 |
+
the theme.
|
| 97 |
+
|
| 98 |
+
### Responsiveness
|
| 99 |
+
- Compute width from \`container.clientWidth\`
|
| 100 |
+
- Height derived from width (e.g. \`width / 3\`), with minimum
|
| 101 |
+
- Use \`ResizeObserver\` on the container
|
| 102 |
+
- Recompute scales/axes on every render
|
| 103 |
+
|
| 104 |
+
### Mounting
|
| 105 |
+
- Gate with \`data-mounted\` to avoid double initialization
|
| 106 |
+
- Select closest previous sibling with root class, fallback to last unmounted
|
| 107 |
+
|
| 108 |
+
### Data
|
| 109 |
+
- For inline data, embed it directly in the script
|
| 110 |
+
- For CSV loading, implement \`fetchFirstAvailable(['/data/file.csv', './assets/data/file.csv'])\`
|
| 111 |
+
- Handle errors gracefully with a red \`<pre>\` message
|
| 112 |
+
|
| 113 |
+
## Guidelines
|
| 114 |
+
|
| 115 |
+
1. **Act immediately.** When the user asks for a chart, create it right away with createEmbed.
|
| 116 |
+
2. **Use patchEmbed for edits.** Read the current chart first, then apply surgical patches.
|
| 117 |
+
3. **Follow conventions strictly.** Every chart must have: root div, scoped style, IIFE script, ColorPalettes, mount guard, responsive layout.
|
| 118 |
+
4. **Be creative with design.** Make charts visually appealing with clean typography, proper spacing, and smooth transitions.
|
| 119 |
+
5. **Keep it concise.** Don't explain what you're doing unless asked.
|
| 120 |
+
|
| 121 |
+
## Context
|
| 122 |
+
|
| 123 |
+
The current chart HTML (if any) is provided between <embed> tags.
|
| 124 |
+
Use this to understand the existing chart structure before making edits.`;
|
| 125 |
+
|
| 126 |
+
export function buildEmbedContext(embedHtml?: string): string {
|
| 127 |
+
if (!embedHtml) return "";
|
| 128 |
+
return `<embed>\n${embedHtml}\n</embed>`;
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
/**
|
| 132 |
+
* Extra instructions layered on top of the base system prompt when the
|
| 133 |
+
* chart being edited is the article banner (top-of-article hero).
|
| 134 |
+
*
|
| 135 |
+
* Banners are visual mood-setters with a fixed aspect-ratio container
|
| 136 |
+
* (5:2, max-width 980px). They should fill the container edge-to-edge,
|
| 137 |
+
* drop most UI chrome (legend, controls, tooltip) and lean abstract.
|
| 138 |
+
*/
|
| 139 |
+
export const BANNER_SYSTEM_PROMPT = `## Banner mode
|
| 140 |
+
|
| 141 |
+
You are creating or editing the **article banner** (the hero visual at the
|
| 142 |
+
top of the article). Banners have their own rules on top of the general
|
| 143 |
+
chart conventions:
|
| 144 |
+
|
| 145 |
+
### Host environment
|
| 146 |
+
|
| 147 |
+
Banners are rendered inside an iframe configured in **fullBleed mode**:
|
| 148 |
+
|
| 149 |
+
- \`html, body\` already have \`height: 100%; width: 100%;\`.
|
| 150 |
+
- \`body\` has \`padding: 0\` (no inner margins). Your root \`<div>\` hits
|
| 151 |
+
the viewport edges directly.
|
| 152 |
+
- \`body > :first-child\` is forced to \`width: 100%; height: 100%;\` by
|
| 153 |
+
the host so a properly-styled root fills the iframe automatically.
|
| 154 |
+
- **No height-reporter runs**, so you MUST NOT rely on the parent
|
| 155 |
+
resizing the iframe based on body scrollHeight. The iframe size is
|
| 156 |
+
fixed by the outer layout.
|
| 157 |
+
|
| 158 |
+
### Container
|
| 159 |
+
|
| 160 |
+
- The outer container has a **fixed aspect ratio of 5:2** and a
|
| 161 |
+
**max-width of 980px** (natural height 980 * 2 / 5 = **392px**).
|
| 162 |
+
On narrow screens the aspect-ratio is relaxed, so ALWAYS read the
|
| 163 |
+
actual size from the container at runtime.
|
| 164 |
+
- Your root element MUST set \`width: 100%; height: 100%; overflow: hidden;\`.
|
| 165 |
+
- Compute BOTH \`width = container.clientWidth\` AND
|
| 166 |
+
\`height = container.clientHeight\`. Do NOT derive height from width
|
| 167 |
+
(that is only for regular embeds that can grow vertically).
|
| 168 |
+
- The SVG (or canvas) must have \`width: 100%; height: 100%; display: block\`
|
| 169 |
+
and use a \`viewBox\` that matches the measured \`w x h\` so shapes
|
| 170 |
+
scale correctly on resize.
|
| 171 |
+
|
| 172 |
+
### Style
|
| 173 |
+
- Banners are **decorative**, not analytical. No legend, no metric select,
|
| 174 |
+
no tooltip unless the brief explicitly asks for them.
|
| 175 |
+
- Prefer abstract / generative visuals: particle fields, flow fields,
|
| 176 |
+
animated noise, wave forms, constellations, isotype grids, topographic
|
| 177 |
+
lines, neural meshes. Data can be synthetic.
|
| 178 |
+
- **Background**: the global "transparent embed" rule applies to banners
|
| 179 |
+
TOO. The root \`<div>\` MUST be transparent (no \`background\`,
|
| 180 |
+
\`background-color\` or gradient fill on the container or on
|
| 181 |
+
\`html\`/\`body\`). Banners inherit the article hero surface so they
|
| 182 |
+
blend with the page in light and dark mode. Carry visual weight through
|
| 183 |
+
shapes, lines, motion and colour - not through filled rectangles.
|
| 184 |
+
- Use a **small margin** (e.g. 0 or 8px) so the visual bleeds to the edges.
|
| 185 |
+
- Lean on \`--primary-color\`, \`--text-color\` and
|
| 186 |
+
\`window.ColorPalettes\` for palette coherence with the rest of the
|
| 187 |
+
article.
|
| 188 |
+
- Subtle motion is welcome (gentle drift, slow rotation) but nothing
|
| 189 |
+
distracting or seizure-inducing. Respect \`prefers-reduced-motion\`.
|
| 190 |
+
|
| 191 |
+
### Responsiveness
|
| 192 |
+
- Observe the container with \`ResizeObserver\` and re-render. The banner
|
| 193 |
+
must look good at 980x392 AND at narrow mobile widths (~360x360).
|
| 194 |
+
|
| 195 |
+
### Example scaffolding
|
| 196 |
+
|
| 197 |
+
\`\`\`js
|
| 198 |
+
const bootstrap = () => {
|
| 199 |
+
// ... standard mount guard + container selection ...
|
| 200 |
+
container.style.width = '100%';
|
| 201 |
+
container.style.height = '100%';
|
| 202 |
+
container.style.overflow = 'hidden';
|
| 203 |
+
|
| 204 |
+
const svg = d3.select(container).append('svg')
|
| 205 |
+
.attr('width', '100%')
|
| 206 |
+
.attr('height', '100%')
|
| 207 |
+
.style('display', 'block');
|
| 208 |
+
|
| 209 |
+
function render() {
|
| 210 |
+
const w = container.clientWidth || 980;
|
| 211 |
+
const h = container.clientHeight || 392;
|
| 212 |
+
svg.attr('viewBox', \`0 0 \${w} \${h}\`);
|
| 213 |
+
// draw full-bleed using w and h directly
|
| 214 |
+
}
|
| 215 |
+
|
| 216 |
+
render();
|
| 217 |
+
if (window.ResizeObserver) new ResizeObserver(render).observe(container);
|
| 218 |
+
};
|
| 219 |
+
\`\`\`
|
| 220 |
+
`;
|
backend/src/agent/embed-tools.ts
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { tool } from "ai";
|
| 2 |
+
import { z } from "zod";
|
| 3 |
+
|
| 4 |
+
/**
|
| 5 |
+
* Tools for the Embed Studio AI. These return instructions that the
|
| 6 |
+
* frontend executes on the EmbedStore (Y.Map). The backend never
|
| 7 |
+
* touches the Yjs document directly.
|
| 8 |
+
*/
|
| 9 |
+
|
| 10 |
+
export const embedTools = {
|
| 11 |
+
createEmbed: tool({
|
| 12 |
+
description:
|
| 13 |
+
"Create or fully replace an HTML embed chart. Use when building a new chart from scratch " +
|
| 14 |
+
"or when changes are so extensive that a full rewrite is cleaner than patching. " +
|
| 15 |
+
"The HTML must be a self-contained fragment following D3 embed conventions " +
|
| 16 |
+
"(root div + scoped style + IIFE script).",
|
| 17 |
+
inputSchema: z.object({
|
| 18 |
+
html: z
|
| 19 |
+
.string()
|
| 20 |
+
.describe(
|
| 21 |
+
"Complete self-contained HTML fragment (root div + scoped style + IIFE script)",
|
| 22 |
+
),
|
| 23 |
+
title: z
|
| 24 |
+
.string()
|
| 25 |
+
.optional()
|
| 26 |
+
.describe("Short descriptive title for the chart"),
|
| 27 |
+
source: z
|
| 28 |
+
.string()
|
| 29 |
+
.optional()
|
| 30 |
+
.describe("Data source attribution"),
|
| 31 |
+
}),
|
| 32 |
+
}),
|
| 33 |
+
|
| 34 |
+
patchEmbed: tool({
|
| 35 |
+
description:
|
| 36 |
+
"Replace a specific block of code in the current chart HTML. " +
|
| 37 |
+
"Use for targeted edits (color change, label update, data tweak, bug fix). " +
|
| 38 |
+
"Always prefer this over createEmbed when modifying an existing chart. " +
|
| 39 |
+
"The search string must be an exact verbatim excerpt from the current HTML " +
|
| 40 |
+
"(whitespace included). Call readEmbed first if unsure of the exact content.",
|
| 41 |
+
inputSchema: z.object({
|
| 42 |
+
search: z
|
| 43 |
+
.string()
|
| 44 |
+
.describe(
|
| 45 |
+
"Exact verbatim excerpt from the current chart HTML to find and replace. " +
|
| 46 |
+
"Must be long enough to be unique (at least 3-5 lines of context).",
|
| 47 |
+
),
|
| 48 |
+
replace: z
|
| 49 |
+
.string()
|
| 50 |
+
.describe("The new code that replaces the search block"),
|
| 51 |
+
}),
|
| 52 |
+
}),
|
| 53 |
+
|
| 54 |
+
readEmbed: tool({
|
| 55 |
+
description:
|
| 56 |
+
"Read the full current chart HTML. Use before calling patchEmbed " +
|
| 57 |
+
"to verify the exact content, or to understand the current structure before editing.",
|
| 58 |
+
inputSchema: z.object({}),
|
| 59 |
+
}),
|
| 60 |
+
};
|
backend/src/agent/stream-handler.ts
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { streamText, convertToModelMessages } from "ai";
|
| 2 |
+
import { createOpenRouter } from "@openrouter/ai-sdk-provider";
|
| 3 |
+
import type { Request, Response } from "express";
|
| 4 |
+
|
| 5 |
+
export const DEFAULT_MODEL = "google/gemini-2.5-flash";
|
| 6 |
+
|
| 7 |
+
export function getProvider() {
|
| 8 |
+
const apiKey = process.env.OPENROUTER_API_KEY;
|
| 9 |
+
if (!apiKey) {
|
| 10 |
+
throw new Error("OPENROUTER_API_KEY environment variable is required");
|
| 11 |
+
}
|
| 12 |
+
return createOpenRouter({ apiKey });
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
interface StreamChatOptions {
|
| 16 |
+
systemPrompt: string;
|
| 17 |
+
tools: Parameters<typeof streamText>[0]["tools"];
|
| 18 |
+
logPrefix: string;
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
export async function streamChatResponse(
|
| 22 |
+
req: Request,
|
| 23 |
+
res: Response,
|
| 24 |
+
{ systemPrompt, tools, logPrefix }: StreamChatOptions,
|
| 25 |
+
) {
|
| 26 |
+
try {
|
| 27 |
+
const { messages, context, model } = req.body;
|
| 28 |
+
|
| 29 |
+
if (!messages || !Array.isArray(messages)) {
|
| 30 |
+
res.status(400).json({ error: "messages array is required" });
|
| 31 |
+
return;
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
const provider = getProvider();
|
| 35 |
+
const modelId = model || process.env.OPENROUTER_MODEL || DEFAULT_MODEL;
|
| 36 |
+
const modelMessages = await convertToModelMessages(messages);
|
| 37 |
+
|
| 38 |
+
const result = streamText({
|
| 39 |
+
model: provider.chat(modelId),
|
| 40 |
+
system: systemPrompt,
|
| 41 |
+
messages: modelMessages,
|
| 42 |
+
tools,
|
| 43 |
+
});
|
| 44 |
+
|
| 45 |
+
const webResponse = result.toUIMessageStreamResponse({
|
| 46 |
+
onError: (error) => {
|
| 47 |
+
console.error(`[${logPrefix}] stream error:`, error);
|
| 48 |
+
return error instanceof Error ? error.message : "Stream error";
|
| 49 |
+
},
|
| 50 |
+
});
|
| 51 |
+
|
| 52 |
+
res.writeHead(
|
| 53 |
+
webResponse.status,
|
| 54 |
+
Object.fromEntries(webResponse.headers.entries()),
|
| 55 |
+
);
|
| 56 |
+
const reader = webResponse.body!.getReader();
|
| 57 |
+
const pump = async (): Promise<void> => {
|
| 58 |
+
const { done, value } = await reader.read();
|
| 59 |
+
if (done) {
|
| 60 |
+
res.end();
|
| 61 |
+
return;
|
| 62 |
+
}
|
| 63 |
+
res.write(value);
|
| 64 |
+
return pump();
|
| 65 |
+
};
|
| 66 |
+
await pump();
|
| 67 |
+
} catch (error: unknown) {
|
| 68 |
+
const message =
|
| 69 |
+
error instanceof Error ? error.message : "Internal server error";
|
| 70 |
+
console.error(`[${logPrefix}] error:`, message);
|
| 71 |
+
|
| 72 |
+
if (!res.headersSent) {
|
| 73 |
+
res.status(500).json({ error: message });
|
| 74 |
+
}
|
| 75 |
+
}
|
| 76 |
+
}
|
backend/src/auth.ts
CHANGED
|
@@ -19,15 +19,24 @@ const OPENID_PROVIDER_URL = process.env.OPENID_PROVIDER_URL || "https://huggingf
|
|
| 19 |
|
| 20 |
const COOKIE_NAME = "hf_access_token";
|
| 21 |
|
|
|
|
|
|
|
| 22 |
export function isOAuthEnabled(): boolean {
|
| 23 |
-
return Boolean(
|
| 24 |
}
|
| 25 |
|
| 26 |
function getRedirectUri(): string {
|
| 27 |
if (SPACE_HOST) return `https://${SPACE_HOST}/auth/callback`;
|
|
|
|
| 28 |
return "http://localhost:8080/auth/callback";
|
| 29 |
}
|
| 30 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
// In-memory state store for CSRF protection (short-lived)
|
| 32 |
const pendingStates = new Map<string, number>();
|
| 33 |
|
|
@@ -95,13 +104,13 @@ export async function handleOAuthCallback(req: Request, res: Response) {
|
|
| 95 |
|
| 96 |
res.cookie(COOKIE_NAME, tokenData.access_token, {
|
| 97 |
httpOnly: true,
|
| 98 |
-
secure:
|
| 99 |
-
sameSite: "none",
|
| 100 |
maxAge,
|
| 101 |
path: "/",
|
| 102 |
});
|
| 103 |
|
| 104 |
-
res.redirect(
|
| 105 |
} catch (err) {
|
| 106 |
console.error("[auth] callback error:", err);
|
| 107 |
res.status(500).send("OAuth callback error");
|
|
@@ -136,8 +145,8 @@ export async function resolveUser(
|
|
| 136 |
const fullName = info.fullname || name;
|
| 137 |
const avatarUrl = info.avatarUrl || "";
|
| 138 |
|
| 139 |
-
const canEdit = await checkWriteAccess(accessToken, name);
|
| 140 |
-
console.log(`[auth] user=${name} canEdit=${canEdit}`);
|
| 141 |
|
| 142 |
return { name, fullName, avatarUrl, canEdit };
|
| 143 |
} catch (err) {
|
|
|
|
| 19 |
|
| 20 |
const COOKIE_NAME = "hf_access_token";
|
| 21 |
|
| 22 |
+
const IS_DEV = !SPACE_ID;
|
| 23 |
+
|
| 24 |
export function isOAuthEnabled(): boolean {
|
| 25 |
+
return Boolean(OAUTH_CLIENT_ID && OAUTH_CLIENT_SECRET);
|
| 26 |
}
|
| 27 |
|
| 28 |
function getRedirectUri(): string {
|
| 29 |
if (SPACE_HOST) return `https://${SPACE_HOST}/auth/callback`;
|
| 30 |
+
// In dev, the callback goes through the Vite proxy (port 5678) -> backend
|
| 31 |
return "http://localhost:8080/auth/callback";
|
| 32 |
}
|
| 33 |
|
| 34 |
+
function getPostLoginRedirect(): string {
|
| 35 |
+
if (SPACE_HOST) return "/editor";
|
| 36 |
+
// In dev, redirect to Vite dev server
|
| 37 |
+
return "http://localhost:5678/";
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
// In-memory state store for CSRF protection (short-lived)
|
| 41 |
const pendingStates = new Map<string, number>();
|
| 42 |
|
|
|
|
| 104 |
|
| 105 |
res.cookie(COOKIE_NAME, tokenData.access_token, {
|
| 106 |
httpOnly: true,
|
| 107 |
+
secure: !IS_DEV,
|
| 108 |
+
sameSite: IS_DEV ? "lax" : "none",
|
| 109 |
maxAge,
|
| 110 |
path: "/",
|
| 111 |
});
|
| 112 |
|
| 113 |
+
res.redirect(getPostLoginRedirect());
|
| 114 |
} catch (err) {
|
| 115 |
console.error("[auth] callback error:", err);
|
| 116 |
res.status(500).send("OAuth callback error");
|
|
|
|
| 145 |
const fullName = info.fullname || name;
|
| 146 |
const avatarUrl = info.avatarUrl || "";
|
| 147 |
|
| 148 |
+
const canEdit = IS_DEV ? true : await checkWriteAccess(accessToken, name);
|
| 149 |
+
console.log(`[auth] user=${name} canEdit=${canEdit}${IS_DEV ? " (dev mode)" : ""}`);
|
| 150 |
|
| 151 |
return { name, fullName, avatarUrl, canEdit };
|
| 152 |
} catch (err) {
|
backend/src/create-app.ts
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import express from "express";
|
| 2 |
+
import { createServer } from "http";
|
| 3 |
+
import { WebSocketServer } from "ws";
|
| 4 |
+
import { Hocuspocus } from "@hocuspocus/server";
|
| 5 |
+
import { Database } from "@hocuspocus/extension-database";
|
| 6 |
+
import { readFileSync, writeFileSync, mkdirSync, existsSync } from "fs";
|
| 7 |
+
import { join } from "path";
|
| 8 |
+
import * as Y from "yjs";
|
| 9 |
+
import { extractToken, isOAuthEnabled } from "./auth.js";
|
| 10 |
+
import { isHfStorageEnabled, setUserToken, pullDocument } from "./hf-storage.js";
|
| 11 |
+
import { citationsRouter } from "./citations.js";
|
| 12 |
+
import { getDataDir, docPath, sanitizeName } from "./utils.js";
|
| 13 |
+
import { debouncedSave, ensurePublishedRestored } from "./persistence.js";
|
| 14 |
+
import { createAuthRouter, createRequireEditor } from "./routes/auth.js";
|
| 15 |
+
import { createChatRouter } from "./routes/chat.js";
|
| 16 |
+
import { createPublishRouter } from "./routes/publish.js";
|
| 17 |
+
import { createUploadRouter } from "./routes/upload.js";
|
| 18 |
+
|
| 19 |
+
export { debouncedSave, resetSaveTimers, resetPublishedRestored } from "./persistence.js";
|
| 20 |
+
|
| 21 |
+
const DEFAULT_DOC_NAME = "default";
|
| 22 |
+
|
| 23 |
+
function sendLoginPage(res: express.Response) {
|
| 24 |
+
res.status(200).send(`<!DOCTYPE html>
|
| 25 |
+
<html lang="en">
|
| 26 |
+
<head>
|
| 27 |
+
<meta charset="utf-8" />
|
| 28 |
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
| 29 |
+
<title>Research Article Template Editor</title>
|
| 30 |
+
<style>
|
| 31 |
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
| 32 |
+
body { display: flex; align-items: center; justify-content: center; height: 100vh;
|
| 33 |
+
font-family: system-ui, -apple-system, sans-serif; background: #0a0a0a; color: #aaa; }
|
| 34 |
+
.card { text-align: center; max-width: 420px; padding: 2rem; }
|
| 35 |
+
h1 { color: #fff; font-size: 1.5rem; margin-bottom: 0.5rem; }
|
| 36 |
+
p { margin-bottom: 1.5rem; line-height: 1.5; }
|
| 37 |
+
a.btn { display: inline-block; padding: 0.6rem 1.5rem; border-radius: 8px;
|
| 38 |
+
background: #958DF1; color: #fff; text-decoration: none; font-weight: 500;
|
| 39 |
+
transition: opacity 0.15s; }
|
| 40 |
+
a.btn:hover { opacity: 0.85; }
|
| 41 |
+
</style>
|
| 42 |
+
</head>
|
| 43 |
+
<body>
|
| 44 |
+
<div class="card">
|
| 45 |
+
<h1>This article is not yet published</h1>
|
| 46 |
+
<p>Log in with your Hugging Face account to access the editor.</p>
|
| 47 |
+
<a class="btn" href="/oauth/authorize" target="_blank">Sign in with Hugging Face</a>
|
| 48 |
+
</div>
|
| 49 |
+
</body>
|
| 50 |
+
</html>`);
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
export function createApp() {
|
| 54 |
+
const DATA_DIR = getDataDir();
|
| 55 |
+
mkdirSync(DATA_DIR, { recursive: true });
|
| 56 |
+
|
| 57 |
+
const oauthEnabled = isOAuthEnabled();
|
| 58 |
+
|
| 59 |
+
// ---------- Hocuspocus (Y.js collaboration server) ----------
|
| 60 |
+
|
| 61 |
+
const hocuspocus = new Hocuspocus({
|
| 62 |
+
async onAuthenticate({ token, context }: { token: string; context: any }) {
|
| 63 |
+
if (!oauthEnabled) return;
|
| 64 |
+
|
| 65 |
+
const { resolveUser } = await import("./auth.js");
|
| 66 |
+
const authToken = token || context?.token;
|
| 67 |
+
const user = await resolveUser(authToken);
|
| 68 |
+
if (!user || !user.canEdit) {
|
| 69 |
+
throw new Error("Unauthorized: no write access");
|
| 70 |
+
}
|
| 71 |
+
if (authToken) setUserToken(authToken);
|
| 72 |
+
return { user };
|
| 73 |
+
},
|
| 74 |
+
extensions: [
|
| 75 |
+
new Database({
|
| 76 |
+
fetch: async ({ documentName }: { documentName: string }) => {
|
| 77 |
+
try {
|
| 78 |
+
const p = docPath(documentName);
|
| 79 |
+
if (existsSync(p)) {
|
| 80 |
+
const buf = readFileSync(p);
|
| 81 |
+
console.log(`[persist] fetch "${documentName}" from disk: ${buf.length} bytes`);
|
| 82 |
+
return buf;
|
| 83 |
+
}
|
| 84 |
+
console.log(`[persist] fetch "${documentName}": no file on disk`);
|
| 85 |
+
|
| 86 |
+
if (isHfStorageEnabled()) {
|
| 87 |
+
const data = await pullDocument(documentName);
|
| 88 |
+
if (data) {
|
| 89 |
+
writeFileSync(p, data);
|
| 90 |
+
console.log(`[persist] pulled ${documentName} from HF`);
|
| 91 |
+
return Buffer.from(data);
|
| 92 |
+
}
|
| 93 |
+
}
|
| 94 |
+
} catch (err) {
|
| 95 |
+
console.error(`[persist] fetch "${documentName}" failed:`, (err as Error).message);
|
| 96 |
+
}
|
| 97 |
+
return null;
|
| 98 |
+
},
|
| 99 |
+
store: async () => {},
|
| 100 |
+
}),
|
| 101 |
+
{
|
| 102 |
+
async onChange({ documentName, document }: { documentName: string; document: any }) {
|
| 103 |
+
console.log(`[persist] onChange "${documentName}"`);
|
| 104 |
+
debouncedSave(documentName, document);
|
| 105 |
+
},
|
| 106 |
+
async afterLoadDocument({ documentName }: { documentName: string }) {
|
| 107 |
+
console.log(`[persist] loaded "${documentName}"`);
|
| 108 |
+
},
|
| 109 |
+
} as any,
|
| 110 |
+
],
|
| 111 |
+
});
|
| 112 |
+
|
| 113 |
+
// ---------- Express app ----------
|
| 114 |
+
|
| 115 |
+
const app = express();
|
| 116 |
+
const httpServer = createServer(app);
|
| 117 |
+
|
| 118 |
+
app.use(express.json({ limit: "1mb" }));
|
| 119 |
+
|
| 120 |
+
const authCtx = { oauthEnabled };
|
| 121 |
+
const requireEditor = createRequireEditor(authCtx);
|
| 122 |
+
|
| 123 |
+
app.use(createAuthRouter(authCtx));
|
| 124 |
+
app.use(createChatRouter(requireEditor));
|
| 125 |
+
app.use("/api/citations", citationsRouter);
|
| 126 |
+
app.use(createPublishRouter({ oauthEnabled, hocuspocus }));
|
| 127 |
+
app.use(createUploadRouter());
|
| 128 |
+
|
| 129 |
+
// ---------- Collab WebSocket ----------
|
| 130 |
+
|
| 131 |
+
const wss = new WebSocketServer({ noServer: true });
|
| 132 |
+
|
| 133 |
+
httpServer.on("upgrade", (req, socket, head) => {
|
| 134 |
+
const url = req.url || "";
|
| 135 |
+
if (url === "/collab" || url.startsWith("/collab/") || url.startsWith("/collab?")) {
|
| 136 |
+
console.log(`[ws] upgrade request for ${url}`);
|
| 137 |
+
wss.handleUpgrade(req, socket, head, (ws) => {
|
| 138 |
+
ws.setMaxListeners(Infinity);
|
| 139 |
+
ws.on("error", (error) => {
|
| 140 |
+
console.error("[ws] socket error:", error.message);
|
| 141 |
+
});
|
| 142 |
+
if (process.env.NODE_ENV !== "production") {
|
| 143 |
+
ws.on("message", (data: Buffer, isBinary: boolean) => {
|
| 144 |
+
const buf = Buffer.isBuffer(data) ? data : Buffer.from(data as any);
|
| 145 |
+
console.log(`[ws-debug] msg ${buf.length}B binary=${isBinary} first20=${buf.slice(0, 20).toString("hex")}`);
|
| 146 |
+
});
|
| 147 |
+
}
|
| 148 |
+
const token = extractToken(req.headers.cookie);
|
| 149 |
+
hocuspocus.handleConnection(ws, req, { token });
|
| 150 |
+
});
|
| 151 |
+
} else {
|
| 152 |
+
console.log(`[ws] rejected upgrade for ${url}`);
|
| 153 |
+
socket.destroy();
|
| 154 |
+
}
|
| 155 |
+
});
|
| 156 |
+
|
| 157 |
+
// ---------- Static assets ----------
|
| 158 |
+
|
| 159 |
+
app.use("/uploads", express.static(join(DATA_DIR, "uploads")));
|
| 160 |
+
app.use("/published", express.static(join(DATA_DIR, "published")));
|
| 161 |
+
|
| 162 |
+
const staticDir =
|
| 163 |
+
process.env.NODE_ENV === "production"
|
| 164 |
+
? join(DATA_DIR, "..", "frontend-dist")
|
| 165 |
+
: join(DATA_DIR, "..", "..", "frontend", "dist");
|
| 166 |
+
|
| 167 |
+
function getPublishedPath(docName: string): string {
|
| 168 |
+
return join(DATA_DIR, "published", docName, "index.html");
|
| 169 |
+
}
|
| 170 |
+
|
| 171 |
+
if (existsSync(staticDir)) {
|
| 172 |
+
app.use(express.static(staticDir, { index: false }));
|
| 173 |
+
|
| 174 |
+
app.get("/editor", async (req, res) => {
|
| 175 |
+
if (oauthEnabled) {
|
| 176 |
+
const { resolveUser } = await import("./auth.js");
|
| 177 |
+
const token = extractToken(req.headers.cookie);
|
| 178 |
+
const user = await resolveUser(token);
|
| 179 |
+
|
| 180 |
+
if (!user || !user.canEdit) {
|
| 181 |
+
res.status(200).send(`<!DOCTYPE html>
|
| 182 |
+
<html lang="en">
|
| 183 |
+
<head><meta charset="utf-8"/><meta name="viewport" content="width=device-width,initial-scale=1"/>
|
| 184 |
+
<title>Sign in required</title>
|
| 185 |
+
<style>*{margin:0;padding:0;box-sizing:border-box}body{display:flex;align-items:center;justify-content:center;height:100vh;font-family:system-ui,-apple-system,sans-serif;background:#0a0a0a;color:#aaa}.card{text-align:center;max-width:420px;padding:2rem}h1{color:#fff;font-size:1.5rem;margin-bottom:.5rem}p{margin-bottom:1.5rem;line-height:1.5}a.btn{display:inline-block;padding:.6rem 1.5rem;border-radius:8px;background:#958DF1;color:#fff;text-decoration:none;font-weight:500;transition:opacity .15s}a.btn:hover{opacity:.85}</style></head>
|
| 186 |
+
<body><div class="card">
|
| 187 |
+
<h1>Editor access</h1>
|
| 188 |
+
<p>Sign in with your Hugging Face account to start editing.</p>
|
| 189 |
+
<a class="btn" href="/oauth/authorize" target="_blank">Sign in with Hugging Face</a>
|
| 190 |
+
<p style="margin-top:1rem;font-size:.85rem;color:#666">After signing in, come back and <a href="/editor" style="color:#958DF1">refresh this page</a>.</p>
|
| 191 |
+
</div></body></html>`);
|
| 192 |
+
return;
|
| 193 |
+
}
|
| 194 |
+
}
|
| 195 |
+
|
| 196 |
+
res.sendFile(join(staticDir, "index.html"));
|
| 197 |
+
});
|
| 198 |
+
|
| 199 |
+
app.get("*", async (req, res) => {
|
| 200 |
+
if (!oauthEnabled) {
|
| 201 |
+
res.sendFile(join(staticDir, "index.html"));
|
| 202 |
+
return;
|
| 203 |
+
}
|
| 204 |
+
|
| 205 |
+
const visitorToken = extractToken(req.headers.cookie) ?? undefined;
|
| 206 |
+
await ensurePublishedRestored(visitorToken);
|
| 207 |
+
|
| 208 |
+
const publishedPath = getPublishedPath(DEFAULT_DOC_NAME);
|
| 209 |
+
if (existsSync(publishedPath)) {
|
| 210 |
+
res.sendFile(publishedPath);
|
| 211 |
+
return;
|
| 212 |
+
}
|
| 213 |
+
|
| 214 |
+
sendLoginPage(res);
|
| 215 |
+
});
|
| 216 |
+
}
|
| 217 |
+
|
| 218 |
+
return { app, httpServer, hocuspocus, wss };
|
| 219 |
+
}
|
backend/src/persistence.ts
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { writeFileSync, existsSync } from "fs";
|
| 2 |
+
import { join } from "path";
|
| 3 |
+
import * as Y from "yjs";
|
| 4 |
+
import { getDataDir, docPath, sanitizeName } from "./utils.js";
|
| 5 |
+
import {
|
| 6 |
+
isHfStorageEnabled,
|
| 7 |
+
getDatasetId,
|
| 8 |
+
setUserToken,
|
| 9 |
+
pullPublishedAssets,
|
| 10 |
+
schedulePush,
|
| 11 |
+
} from "./hf-storage.js";
|
| 12 |
+
|
| 13 |
+
const DEFAULT_DOC_NAME = "default";
|
| 14 |
+
const SAVE_DEBOUNCE_MS = 2000;
|
| 15 |
+
const saveTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
| 16 |
+
const lastSaveTimestamp = new Map<string, number>();
|
| 17 |
+
|
| 18 |
+
/** @internal - exported for testing */
|
| 19 |
+
export function debouncedSave(documentName: string, ydoc: Y.Doc) {
|
| 20 |
+
const existing = saveTimers.get(documentName);
|
| 21 |
+
if (existing) clearTimeout(existing);
|
| 22 |
+
|
| 23 |
+
saveTimers.set(documentName, setTimeout(() => {
|
| 24 |
+
saveTimers.delete(documentName);
|
| 25 |
+
try {
|
| 26 |
+
const state = Y.encodeStateAsUpdate(ydoc);
|
| 27 |
+
const buf = Buffer.from(state);
|
| 28 |
+
writeFileSync(docPath(documentName), buf);
|
| 29 |
+
lastSaveTimestamp.set(documentName, Date.now());
|
| 30 |
+
console.log(`[persist] saved "${documentName}": ${buf.length} bytes`);
|
| 31 |
+
|
| 32 |
+
if (isHfStorageEnabled()) {
|
| 33 |
+
schedulePush(documentName, buf);
|
| 34 |
+
}
|
| 35 |
+
} catch (err) {
|
| 36 |
+
console.error(`[persist] failed to save "${documentName}":`, (err as Error).message);
|
| 37 |
+
}
|
| 38 |
+
}, SAVE_DEBOUNCE_MS));
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
/** Reset debounce state between tests */
|
| 42 |
+
export function resetSaveTimers() {
|
| 43 |
+
for (const t of saveTimers.values()) clearTimeout(t);
|
| 44 |
+
saveTimers.clear();
|
| 45 |
+
lastSaveTimestamp.clear();
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
let _publishedRestored = false;
|
| 49 |
+
let _restoreInProgress: Promise<void> | null = null;
|
| 50 |
+
|
| 51 |
+
export async function ensurePublishedRestored(token?: string): Promise<void> {
|
| 52 |
+
const DATA_DIR = getDataDir();
|
| 53 |
+
if (_publishedRestored) return;
|
| 54 |
+
if (!getDatasetId()) return;
|
| 55 |
+
|
| 56 |
+
const publishedPath = join(DATA_DIR, "published", sanitizeName(DEFAULT_DOC_NAME), "index.html");
|
| 57 |
+
if (existsSync(publishedPath)) {
|
| 58 |
+
_publishedRestored = true;
|
| 59 |
+
return;
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
if (_restoreInProgress) {
|
| 63 |
+
await _restoreInProgress;
|
| 64 |
+
return;
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
if (token) setUserToken(token);
|
| 68 |
+
|
| 69 |
+
_restoreInProgress = (async () => {
|
| 70 |
+
try {
|
| 71 |
+
const found = await pullPublishedAssets(DEFAULT_DOC_NAME, DATA_DIR);
|
| 72 |
+
if (found) {
|
| 73 |
+
_publishedRestored = true;
|
| 74 |
+
console.log("[server] restored published article from HF dataset");
|
| 75 |
+
}
|
| 76 |
+
} catch (err) {
|
| 77 |
+
console.warn("[server] failed to restore published:", (err as Error).message);
|
| 78 |
+
} finally {
|
| 79 |
+
_restoreInProgress = null;
|
| 80 |
+
}
|
| 81 |
+
})();
|
| 82 |
+
|
| 83 |
+
await _restoreInProgress;
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
/** Reset restore state between tests */
|
| 87 |
+
export function resetPublishedRestored() {
|
| 88 |
+
_publishedRestored = false;
|
| 89 |
+
_restoreInProgress = null;
|
| 90 |
+
}
|
backend/src/routes/auth.ts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Router } from "express";
|
| 2 |
+
import type express from "express";
|
| 3 |
+
import { resolveUser, extractToken, handleOAuthAuthorize, handleOAuthCallback } from "../auth.js";
|
| 4 |
+
import { setUserToken } from "../hf-storage.js";
|
| 5 |
+
import { ensurePublishedRestored } from "../persistence.js";
|
| 6 |
+
|
| 7 |
+
export interface AuthContext {
|
| 8 |
+
oauthEnabled: boolean;
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
/**
|
| 12 |
+
* Middleware that requires editor-level access when OAuth is enabled.
|
| 13 |
+
* Reusable across route modules.
|
| 14 |
+
*/
|
| 15 |
+
export function createRequireEditor(ctx: AuthContext): express.RequestHandler {
|
| 16 |
+
return async (req, res, next) => {
|
| 17 |
+
if (!ctx.oauthEnabled) return next();
|
| 18 |
+
const token = extractToken(req.headers.cookie);
|
| 19 |
+
const user = await resolveUser(token);
|
| 20 |
+
if (!user || !user.canEdit) {
|
| 21 |
+
res.status(403).json({ error: "Unauthorized" });
|
| 22 |
+
return;
|
| 23 |
+
}
|
| 24 |
+
next();
|
| 25 |
+
};
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
export function createAuthRouter(ctx: AuthContext): Router {
|
| 29 |
+
const router = Router();
|
| 30 |
+
|
| 31 |
+
if (ctx.oauthEnabled) {
|
| 32 |
+
router.get("/oauth/authorize", handleOAuthAuthorize);
|
| 33 |
+
router.get("/auth/callback", handleOAuthCallback);
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
router.get("/api/auth/status", async (req, res) => {
|
| 37 |
+
if (!ctx.oauthEnabled) {
|
| 38 |
+
res.json({ authenticated: true, canEdit: true, user: null });
|
| 39 |
+
return;
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
const token = extractToken(req.headers.cookie);
|
| 43 |
+
const user = await resolveUser(token);
|
| 44 |
+
|
| 45 |
+
if (!user) {
|
| 46 |
+
res.json({ authenticated: false, canEdit: false, user: null, loginUrl: "/oauth/authorize" });
|
| 47 |
+
return;
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
if (user.canEdit && token) {
|
| 51 |
+
setUserToken(token);
|
| 52 |
+
ensurePublishedRestored(token).catch(() => {});
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
res.json({
|
| 56 |
+
authenticated: true,
|
| 57 |
+
canEdit: user.canEdit,
|
| 58 |
+
user: {
|
| 59 |
+
name: user.name,
|
| 60 |
+
fullName: user.fullName,
|
| 61 |
+
avatarUrl: user.avatarUrl,
|
| 62 |
+
},
|
| 63 |
+
});
|
| 64 |
+
});
|
| 65 |
+
|
| 66 |
+
return router;
|
| 67 |
+
}
|
backend/src/routes/chat.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Router } from "express";
|
| 2 |
+
import type express from "express";
|
| 3 |
+
import { handleChat, AVAILABLE_MODELS } from "../agent/chat.js";
|
| 4 |
+
import { handleEmbedChat } from "../agent/embed-chat.js";
|
| 5 |
+
|
| 6 |
+
export function createChatRouter(requireEditor: express.RequestHandler): Router {
|
| 7 |
+
const router = Router();
|
| 8 |
+
|
| 9 |
+
router.get("/api/models", (_req, res) => res.json(AVAILABLE_MODELS));
|
| 10 |
+
router.post("/api/chat", requireEditor, handleChat);
|
| 11 |
+
router.post("/api/embed-chat", requireEditor, handleEmbedChat);
|
| 12 |
+
|
| 13 |
+
return router;
|
| 14 |
+
}
|
backend/src/routes/publish.ts
ADDED
|
@@ -0,0 +1,339 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Router } from "express";
|
| 2 |
+
import { writeFileSync, existsSync } from "fs";
|
| 3 |
+
import { randomUUID } from "crypto";
|
| 4 |
+
import { EventEmitter } from "events";
|
| 5 |
+
import * as Y from "yjs";
|
| 6 |
+
import type { Hocuspocus } from "@hocuspocus/server";
|
| 7 |
+
import { resolveUser, extractToken } from "../auth.js";
|
| 8 |
+
import { isHfStorageEnabled, setUserToken } from "../hf-storage.js";
|
| 9 |
+
import {
|
| 10 |
+
publishDocument,
|
| 11 |
+
previewDocument,
|
| 12 |
+
type PublishResult,
|
| 13 |
+
type PublishStage,
|
| 14 |
+
} from "../publisher/index.js";
|
| 15 |
+
import { docPath } from "../utils.js";
|
| 16 |
+
|
| 17 |
+
const DEFAULT_DOC_NAME = "default";
|
| 18 |
+
|
| 19 |
+
export interface PublishContext {
|
| 20 |
+
oauthEnabled: boolean;
|
| 21 |
+
hocuspocus: Hocuspocus;
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
/** Lock descriptor kept while a publish is running for a given docName. */
|
| 25 |
+
interface PublishLock {
|
| 26 |
+
jobId: string;
|
| 27 |
+
startedAt: number;
|
| 28 |
+
userName?: string;
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
interface PublishJob {
|
| 32 |
+
emitter: EventEmitter;
|
| 33 |
+
/** Latest stage so a late SSE subscriber can immediately show the current label. */
|
| 34 |
+
stage?: { stage: string; detail?: Record<string, unknown> };
|
| 35 |
+
finished?: { ok: true; result: PublishResult } | { ok: false; error: string };
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
/** Per-docName mutex: a doc can only be published by one request at a time. */
|
| 39 |
+
const publishLocks = new Map<string, PublishLock>();
|
| 40 |
+
|
| 41 |
+
/** Active/finished jobs indexed by jobId so `/api/publish/stream` can attach. */
|
| 42 |
+
const publishJobs = new Map<string, PublishJob>();
|
| 43 |
+
|
| 44 |
+
/** How long we keep a finished job in memory so a late subscriber can still see the final event. */
|
| 45 |
+
const JOB_RETENTION_MS = 30_000;
|
| 46 |
+
|
| 47 |
+
/**
|
| 48 |
+
* Broadcast publish status into the Y.Doc via a dedicated Y.Map("publish-status").
|
| 49 |
+
* Every collaborative editor observes this map and disables its Publish button
|
| 50 |
+
* while a publish is active. Called at the start and end of a publish.
|
| 51 |
+
*/
|
| 52 |
+
async function broadcastPublishStatus(
|
| 53 |
+
hocuspocus: Hocuspocus,
|
| 54 |
+
docName: string,
|
| 55 |
+
status: { active: boolean; userName?: string; startedAt?: number; jobId?: string }
|
| 56 |
+
): Promise<void> {
|
| 57 |
+
try {
|
| 58 |
+
const conn = await hocuspocus.openDirectConnection(docName);
|
| 59 |
+
if (conn.document) {
|
| 60 |
+
const statusMap = conn.document.getMap("publish-status");
|
| 61 |
+
conn.document.transact(() => {
|
| 62 |
+
statusMap.set("active", status.active);
|
| 63 |
+
statusMap.set("userName", status.userName ?? null);
|
| 64 |
+
statusMap.set("startedAt", status.startedAt ?? null);
|
| 65 |
+
statusMap.set("jobId", status.jobId ?? null);
|
| 66 |
+
});
|
| 67 |
+
}
|
| 68 |
+
await conn.disconnect();
|
| 69 |
+
} catch (err) {
|
| 70 |
+
console.warn("[publish] failed to broadcast publish status:", (err as Error).message);
|
| 71 |
+
}
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
export function createPublishRouter(ctx: PublishContext): Router {
|
| 75 |
+
const router = Router();
|
| 76 |
+
|
| 77 |
+
// ---------- Preview ----------
|
| 78 |
+
|
| 79 |
+
router.get("/api/preview/:docName", async (req, res) => {
|
| 80 |
+
const docName = req.params.docName || DEFAULT_DOC_NAME;
|
| 81 |
+
|
| 82 |
+
if (ctx.oauthEnabled) {
|
| 83 |
+
const token = extractToken(req.headers.cookie);
|
| 84 |
+
const user = await resolveUser(token);
|
| 85 |
+
if (!user || !user.canEdit) {
|
| 86 |
+
res.status(403).json({ error: "Unauthorized" });
|
| 87 |
+
return;
|
| 88 |
+
}
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
try {
|
| 92 |
+
const conn = await ctx.hocuspocus.openDirectConnection(docName);
|
| 93 |
+
if (conn.document) {
|
| 94 |
+
const update = Y.encodeStateAsUpdate(conn.document);
|
| 95 |
+
writeFileSync(docPath(docName), Buffer.from(update));
|
| 96 |
+
}
|
| 97 |
+
await conn.disconnect();
|
| 98 |
+
|
| 99 |
+
const result = await previewDocument(docName);
|
| 100 |
+
if ("error" in result) {
|
| 101 |
+
res.status(404).json({ error: result.error });
|
| 102 |
+
return;
|
| 103 |
+
}
|
| 104 |
+
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
| 105 |
+
res.send(result.html);
|
| 106 |
+
} catch (err: any) {
|
| 107 |
+
console.error("[preview] error:", err);
|
| 108 |
+
res.status(500).json({ error: err.message || "Preview failed" });
|
| 109 |
+
}
|
| 110 |
+
});
|
| 111 |
+
|
| 112 |
+
// ---------- Publish ----------
|
| 113 |
+
|
| 114 |
+
router.post("/api/publish", async (req, res) => {
|
| 115 |
+
let userToken: string | undefined;
|
| 116 |
+
let userName: string | undefined;
|
| 117 |
+
|
| 118 |
+
if (ctx.oauthEnabled) {
|
| 119 |
+
const token = extractToken(req.headers.cookie);
|
| 120 |
+
const user = await resolveUser(token);
|
| 121 |
+
if (!user) {
|
| 122 |
+
console.warn("[publish] no valid user from token, cookie present:", !!token);
|
| 123 |
+
res.status(403).json({ error: "Unauthorized: please log in first" });
|
| 124 |
+
return;
|
| 125 |
+
}
|
| 126 |
+
if (!user.canEdit) {
|
| 127 |
+
console.warn("[publish] user lacks write access:", user.name);
|
| 128 |
+
res.status(403).json({ error: "Unauthorized: write access required" });
|
| 129 |
+
return;
|
| 130 |
+
}
|
| 131 |
+
userToken = token ?? undefined;
|
| 132 |
+
userName = user.name;
|
| 133 |
+
if (userToken) setUserToken(userToken);
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
const docName = DEFAULT_DOC_NAME;
|
| 137 |
+
|
| 138 |
+
// Mutex: reject with 409 if another publish is already in progress.
|
| 139 |
+
const existing = publishLocks.get(docName);
|
| 140 |
+
if (existing) {
|
| 141 |
+
res.status(409).json({
|
| 142 |
+
error: "Publish already in progress",
|
| 143 |
+
jobId: existing.jobId,
|
| 144 |
+
startedAt: existing.startedAt,
|
| 145 |
+
userName: existing.userName ?? null,
|
| 146 |
+
});
|
| 147 |
+
return;
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
const jobId = randomUUID();
|
| 151 |
+
const startedAt = Date.now();
|
| 152 |
+
const emitter = new EventEmitter();
|
| 153 |
+
emitter.setMaxListeners(32);
|
| 154 |
+
const job: PublishJob = { emitter };
|
| 155 |
+
publishJobs.set(jobId, job);
|
| 156 |
+
publishLocks.set(docName, { jobId, startedAt, userName });
|
| 157 |
+
|
| 158 |
+
// Broadcast "publishing" status to all collaborators immediately so their
|
| 159 |
+
// Publish buttons become disabled.
|
| 160 |
+
broadcastPublishStatus(ctx.hocuspocus, docName, {
|
| 161 |
+
active: true,
|
| 162 |
+
userName,
|
| 163 |
+
startedAt,
|
| 164 |
+
jobId,
|
| 165 |
+
}).catch(() => {});
|
| 166 |
+
|
| 167 |
+
// Return the jobId right away so the client can open the SSE stream.
|
| 168 |
+
res.json({ jobId });
|
| 169 |
+
|
| 170 |
+
// Run the publish pipeline asynchronously; SSE subscribers receive events.
|
| 171 |
+
try {
|
| 172 |
+
// Snapshot the Y.Doc state to disk (as before) so publishDocument can
|
| 173 |
+
// reload it from a stable .yjs file.
|
| 174 |
+
try {
|
| 175 |
+
const conn = await ctx.hocuspocus.openDirectConnection(docName);
|
| 176 |
+
if (conn.document) {
|
| 177 |
+
const update = Y.encodeStateAsUpdate(conn.document);
|
| 178 |
+
writeFileSync(docPath(docName), Buffer.from(update));
|
| 179 |
+
}
|
| 180 |
+
await conn.disconnect();
|
| 181 |
+
} catch (err) {
|
| 182 |
+
console.warn("[publish] snapshot failed:", (err as Error).message);
|
| 183 |
+
}
|
| 184 |
+
|
| 185 |
+
const emitStage = (stage: PublishStage | string, detail?: Record<string, unknown>) => {
|
| 186 |
+
const event = { stage, detail };
|
| 187 |
+
job.stage = event;
|
| 188 |
+
emitter.emit("stage", event);
|
| 189 |
+
};
|
| 190 |
+
|
| 191 |
+
const result = await publishDocument(docName, userToken, { onStage: emitStage });
|
| 192 |
+
|
| 193 |
+
if (!result.success) {
|
| 194 |
+
job.finished = { ok: false, error: result.error || "Publish failed" };
|
| 195 |
+
emitter.emit("done", { success: false, error: result.error || "Publish failed" });
|
| 196 |
+
} else {
|
| 197 |
+
job.finished = { ok: true, result };
|
| 198 |
+
emitter.emit("done", { success: true, result });
|
| 199 |
+
}
|
| 200 |
+
} catch (err: any) {
|
| 201 |
+
console.error("[publish] error:", err);
|
| 202 |
+
const message = err?.message || "Publish failed";
|
| 203 |
+
job.finished = { ok: false, error: message };
|
| 204 |
+
emitter.emit("done", { success: false, error: message });
|
| 205 |
+
} finally {
|
| 206 |
+
publishLocks.delete(docName);
|
| 207 |
+
broadcastPublishStatus(ctx.hocuspocus, docName, { active: false }).catch(() => {});
|
| 208 |
+
|
| 209 |
+
// Keep the finished job around briefly so late SSE subscribers still receive the outcome.
|
| 210 |
+
setTimeout(() => {
|
| 211 |
+
publishJobs.delete(jobId);
|
| 212 |
+
}, JOB_RETENTION_MS);
|
| 213 |
+
}
|
| 214 |
+
});
|
| 215 |
+
|
| 216 |
+
// ---------- SSE stream ----------
|
| 217 |
+
|
| 218 |
+
router.get("/api/publish/stream", async (req, res) => {
|
| 219 |
+
const jobId = typeof req.query.jobId === "string" ? req.query.jobId : "";
|
| 220 |
+
const job = publishJobs.get(jobId);
|
| 221 |
+
|
| 222 |
+
if (ctx.oauthEnabled) {
|
| 223 |
+
const token = extractToken(req.headers.cookie);
|
| 224 |
+
const user = await resolveUser(token);
|
| 225 |
+
if (!user || !user.canEdit) {
|
| 226 |
+
res.status(403).end();
|
| 227 |
+
return;
|
| 228 |
+
}
|
| 229 |
+
}
|
| 230 |
+
|
| 231 |
+
if (!job) {
|
| 232 |
+
res.status(404).end();
|
| 233 |
+
return;
|
| 234 |
+
}
|
| 235 |
+
|
| 236 |
+
res.setHeader("Content-Type", "text/event-stream");
|
| 237 |
+
res.setHeader("Cache-Control", "no-cache, no-transform");
|
| 238 |
+
res.setHeader("Connection", "keep-alive");
|
| 239 |
+
res.setHeader("X-Accel-Buffering", "no");
|
| 240 |
+
res.flushHeaders?.();
|
| 241 |
+
|
| 242 |
+
const write = (event: string, payload: unknown) => {
|
| 243 |
+
res.write(`event: ${event}\n`);
|
| 244 |
+
res.write(`data: ${JSON.stringify(payload)}\n\n`);
|
| 245 |
+
};
|
| 246 |
+
|
| 247 |
+
// Immediately replay the current stage (if any) so the subscriber sees the latest label.
|
| 248 |
+
if (job.stage) write("stage", job.stage);
|
| 249 |
+
|
| 250 |
+
const onStage = (payload: { stage: string; detail?: Record<string, unknown> }) => {
|
| 251 |
+
write("stage", payload);
|
| 252 |
+
};
|
| 253 |
+
const onDone = (payload: { success: boolean; error?: string; result?: PublishResult }) => {
|
| 254 |
+
write("done", payload);
|
| 255 |
+
cleanup();
|
| 256 |
+
res.end();
|
| 257 |
+
};
|
| 258 |
+
|
| 259 |
+
// If the job already finished (rare: client subscribed after completion),
|
| 260 |
+
// emit the final event and close.
|
| 261 |
+
if (job.finished) {
|
| 262 |
+
if (job.finished.ok) {
|
| 263 |
+
write("done", { success: true, result: job.finished.result });
|
| 264 |
+
} else {
|
| 265 |
+
write("done", { success: false, error: job.finished.error });
|
| 266 |
+
}
|
| 267 |
+
res.end();
|
| 268 |
+
return;
|
| 269 |
+
}
|
| 270 |
+
|
| 271 |
+
job.emitter.on("stage", onStage);
|
| 272 |
+
job.emitter.on("done", onDone);
|
| 273 |
+
|
| 274 |
+
// Keep-alive comments every 15s to defeat proxy idle timeouts.
|
| 275 |
+
const keepAlive = setInterval(() => {
|
| 276 |
+
res.write(`: keepalive ${Date.now()}\n\n`);
|
| 277 |
+
}, 15_000);
|
| 278 |
+
|
| 279 |
+
const cleanup = () => {
|
| 280 |
+
clearInterval(keepAlive);
|
| 281 |
+
job.emitter.off("stage", onStage);
|
| 282 |
+
job.emitter.off("done", onDone);
|
| 283 |
+
};
|
| 284 |
+
|
| 285 |
+
req.on("close", () => {
|
| 286 |
+
cleanup();
|
| 287 |
+
res.end();
|
| 288 |
+
});
|
| 289 |
+
});
|
| 290 |
+
|
| 291 |
+
// ---------- Status (used by non-collaborative fallback) ----------
|
| 292 |
+
|
| 293 |
+
router.get("/api/publish/status", (_req, res) => {
|
| 294 |
+
const lock = publishLocks.get(DEFAULT_DOC_NAME);
|
| 295 |
+
if (!lock) {
|
| 296 |
+
res.json({ active: false });
|
| 297 |
+
return;
|
| 298 |
+
}
|
| 299 |
+
res.json({
|
| 300 |
+
active: true,
|
| 301 |
+
jobId: lock.jobId,
|
| 302 |
+
startedAt: lock.startedAt,
|
| 303 |
+
userName: lock.userName ?? null,
|
| 304 |
+
});
|
| 305 |
+
});
|
| 306 |
+
|
| 307 |
+
// ---------- Reset Document ----------
|
| 308 |
+
|
| 309 |
+
router.post("/api/admin/reset-document", async (req, res) => {
|
| 310 |
+
if (ctx.oauthEnabled) {
|
| 311 |
+
const token = extractToken(req.headers.cookie);
|
| 312 |
+
const user = await resolveUser(token);
|
| 313 |
+
if (!user || !user.canEdit) {
|
| 314 |
+
res.status(403).json({ error: "Unauthorized" });
|
| 315 |
+
return;
|
| 316 |
+
}
|
| 317 |
+
if (token) setUserToken(token);
|
| 318 |
+
}
|
| 319 |
+
|
| 320 |
+
try {
|
| 321 |
+
const p = docPath(DEFAULT_DOC_NAME);
|
| 322 |
+
if (existsSync(p)) {
|
| 323 |
+
const { unlinkSync } = await import("fs");
|
| 324 |
+
unlinkSync(p);
|
| 325 |
+
console.log("[admin] deleted local Y.Doc file:", p);
|
| 326 |
+
}
|
| 327 |
+
|
| 328 |
+
await ctx.hocuspocus.closeConnections(DEFAULT_DOC_NAME);
|
| 329 |
+
console.log("[admin] closed connections for", DEFAULT_DOC_NAME);
|
| 330 |
+
|
| 331 |
+
res.json({ success: true, message: "Document reset. Refresh the editor to start fresh." });
|
| 332 |
+
} catch (err: any) {
|
| 333 |
+
console.error("[admin] reset error:", err);
|
| 334 |
+
res.status(500).json({ error: err.message || "Reset failed" });
|
| 335 |
+
}
|
| 336 |
+
});
|
| 337 |
+
|
| 338 |
+
return router;
|
| 339 |
+
}
|
backend/src/server.ts
CHANGED
|
@@ -1,81 +1,10 @@
|
|
| 1 |
import "dotenv/config";
|
| 2 |
-
import
|
| 3 |
-
import {
|
| 4 |
-
import {
|
| 5 |
-
import { Hocuspocus } from "@hocuspocus/server";
|
| 6 |
-
import { Database } from "@hocuspocus/extension-database";
|
| 7 |
-
import { readFileSync, writeFileSync, mkdirSync, existsSync } from "fs";
|
| 8 |
-
import { join } from "path";
|
| 9 |
-
import { randomUUID } from "crypto";
|
| 10 |
-
import multer from "multer";
|
| 11 |
-
import * as Y from "yjs";
|
| 12 |
-
import { handleChat } from "./agent/chat.js";
|
| 13 |
-
import { citationsRouter } from "./citations.js";
|
| 14 |
-
import {
|
| 15 |
-
isHfStorageEnabled,
|
| 16 |
-
ensureDatasetExists,
|
| 17 |
-
getDatasetId,
|
| 18 |
-
setUserToken,
|
| 19 |
-
uploadImageToHf,
|
| 20 |
-
pullDocument,
|
| 21 |
-
pullPublishedAssets,
|
| 22 |
-
schedulePush,
|
| 23 |
-
flushAll,
|
| 24 |
-
} from "./hf-storage.js";
|
| 25 |
-
import { resolveUser, extractToken, isOAuthEnabled, handleOAuthAuthorize, handleOAuthCallback } from "./auth.js";
|
| 26 |
-
import { publishDocument, previewDocument } from "./publisher/index.js";
|
| 27 |
-
import { DATA_DIR, docPath, sanitizeName } from "./utils.js";
|
| 28 |
|
| 29 |
const PORT = parseInt(process.env.PORT || "8080", 10);
|
| 30 |
|
| 31 |
-
/** Single collab document; URL `?doc=` is not supported. */
|
| 32 |
-
const DEFAULT_DOC_NAME = "default";
|
| 33 |
-
|
| 34 |
-
/**
|
| 35 |
-
* Lazy restore of published assets from HF dataset.
|
| 36 |
-
* Called at startup and on every request until successful.
|
| 37 |
-
* Dataset is public so no token is required for reads.
|
| 38 |
-
*/
|
| 39 |
-
let _publishedRestored = false;
|
| 40 |
-
let _restoreInProgress: Promise<void> | null = null;
|
| 41 |
-
|
| 42 |
-
async function ensurePublishedRestored(token?: string): Promise<void> {
|
| 43 |
-
if (_publishedRestored) return;
|
| 44 |
-
if (!getDatasetId()) return;
|
| 45 |
-
|
| 46 |
-
const publishedPath = join(DATA_DIR, "published", sanitizeName(DEFAULT_DOC_NAME), "index.html");
|
| 47 |
-
if (existsSync(publishedPath)) {
|
| 48 |
-
_publishedRestored = true;
|
| 49 |
-
return;
|
| 50 |
-
}
|
| 51 |
-
|
| 52 |
-
// Prevent concurrent pulls (multiple requests arriving at the same time)
|
| 53 |
-
if (_restoreInProgress) {
|
| 54 |
-
await _restoreInProgress;
|
| 55 |
-
return;
|
| 56 |
-
}
|
| 57 |
-
|
| 58 |
-
if (token) setUserToken(token);
|
| 59 |
-
|
| 60 |
-
_restoreInProgress = (async () => {
|
| 61 |
-
try {
|
| 62 |
-
const found = await pullPublishedAssets(DEFAULT_DOC_NAME, DATA_DIR);
|
| 63 |
-
if (found) {
|
| 64 |
-
_publishedRestored = true;
|
| 65 |
-
console.log("[server] restored published article from HF dataset");
|
| 66 |
-
}
|
| 67 |
-
} catch (err) {
|
| 68 |
-
console.warn("[server] failed to restore published:", (err as Error).message);
|
| 69 |
-
} finally {
|
| 70 |
-
_restoreInProgress = null;
|
| 71 |
-
}
|
| 72 |
-
})();
|
| 73 |
-
|
| 74 |
-
await _restoreInProgress;
|
| 75 |
-
}
|
| 76 |
-
|
| 77 |
-
mkdirSync(DATA_DIR, { recursive: true });
|
| 78 |
-
|
| 79 |
if (isHfStorageEnabled()) {
|
| 80 |
console.log("[server] HF Dataset persistence enabled");
|
| 81 |
} else if (getDatasetId()) {
|
|
@@ -92,405 +21,12 @@ if (oauthEnabled) {
|
|
| 92 |
console.log("[server] HF OAuth disabled (no SPACE_ID), all users can edit");
|
| 93 |
}
|
| 94 |
|
| 95 |
-
const
|
| 96 |
-
async onAuthenticate({ token, context }: { token: string; context: any }) {
|
| 97 |
-
if (!oauthEnabled) return;
|
| 98 |
-
|
| 99 |
-
// Prefer WebSocket protocol token, fall back to cookie token passed via context
|
| 100 |
-
const authToken = token || context?.token;
|
| 101 |
-
const user = await resolveUser(authToken);
|
| 102 |
-
if (!user || !user.canEdit) {
|
| 103 |
-
throw new Error("Unauthorized: no write access");
|
| 104 |
-
}
|
| 105 |
-
if (authToken) setUserToken(authToken);
|
| 106 |
-
return { user };
|
| 107 |
-
},
|
| 108 |
-
extensions: [
|
| 109 |
-
new Database({
|
| 110 |
-
fetch: async ({ documentName }: { documentName: string }) => {
|
| 111 |
-
const p = docPath(documentName);
|
| 112 |
-
if (existsSync(p)) return readFileSync(p);
|
| 113 |
-
|
| 114 |
-
if (isHfStorageEnabled()) {
|
| 115 |
-
const data = await pullDocument(documentName);
|
| 116 |
-
if (data) {
|
| 117 |
-
writeFileSync(p, data);
|
| 118 |
-
console.log(`[server] pulled ${documentName} from HF`);
|
| 119 |
-
return Buffer.from(data);
|
| 120 |
-
}
|
| 121 |
-
}
|
| 122 |
-
|
| 123 |
-
return null;
|
| 124 |
-
},
|
| 125 |
-
store: async ({
|
| 126 |
-
documentName,
|
| 127 |
-
state,
|
| 128 |
-
}: {
|
| 129 |
-
documentName: string;
|
| 130 |
-
state: Buffer;
|
| 131 |
-
}) => {
|
| 132 |
-
writeFileSync(docPath(documentName), state);
|
| 133 |
-
|
| 134 |
-
if (isHfStorageEnabled()) {
|
| 135 |
-
schedulePush(documentName, state);
|
| 136 |
-
}
|
| 137 |
-
},
|
| 138 |
-
}),
|
| 139 |
-
],
|
| 140 |
-
});
|
| 141 |
-
|
| 142 |
-
const app = express();
|
| 143 |
-
const httpServer = createServer(app);
|
| 144 |
-
|
| 145 |
-
app.use(express.json({ limit: "1mb" }));
|
| 146 |
-
|
| 147 |
-
// ---------- OAuth routes ----------
|
| 148 |
-
|
| 149 |
-
if (oauthEnabled) {
|
| 150 |
-
app.get("/oauth/authorize", handleOAuthAuthorize);
|
| 151 |
-
app.get("/auth/callback", handleOAuthCallback);
|
| 152 |
-
}
|
| 153 |
-
|
| 154 |
-
// ---------- Auth ----------
|
| 155 |
-
|
| 156 |
-
app.get("/api/auth/status", async (req, res) => {
|
| 157 |
-
if (!oauthEnabled) {
|
| 158 |
-
res.json({ authenticated: true, canEdit: true, user: null });
|
| 159 |
-
return;
|
| 160 |
-
}
|
| 161 |
-
|
| 162 |
-
const token = extractToken(req.headers.cookie);
|
| 163 |
-
const user = await resolveUser(token);
|
| 164 |
-
|
| 165 |
-
if (!user) {
|
| 166 |
-
res.json({ authenticated: false, canEdit: false, user: null });
|
| 167 |
-
return;
|
| 168 |
-
}
|
| 169 |
-
|
| 170 |
-
// Cache the user's OAuth token for HF API calls (dataset storage)
|
| 171 |
-
if (user.canEdit && token) {
|
| 172 |
-
setUserToken(token);
|
| 173 |
-
// Lazy-restore published article on first authenticated request
|
| 174 |
-
ensurePublishedRestored(token).catch(() => {});
|
| 175 |
-
}
|
| 176 |
-
|
| 177 |
-
res.json({
|
| 178 |
-
authenticated: true,
|
| 179 |
-
canEdit: user.canEdit,
|
| 180 |
-
user: {
|
| 181 |
-
name: user.name,
|
| 182 |
-
fullName: user.fullName,
|
| 183 |
-
avatarUrl: user.avatarUrl,
|
| 184 |
-
},
|
| 185 |
-
});
|
| 186 |
-
});
|
| 187 |
-
|
| 188 |
-
// ---------- Collab WebSocket (native ws upgrade, no express-ws) ----------
|
| 189 |
-
|
| 190 |
-
const wss = new WebSocketServer({ noServer: true });
|
| 191 |
-
|
| 192 |
-
httpServer.on("upgrade", (req, socket, head) => {
|
| 193 |
-
const url = req.url || "";
|
| 194 |
-
// Hocuspocus v3 provider connects to /collab (no document name in path)
|
| 195 |
-
if (url === "/collab" || url.startsWith("/collab/") || url.startsWith("/collab?")) {
|
| 196 |
-
console.log(`[ws] upgrade request for ${url}`);
|
| 197 |
-
wss.handleUpgrade(req, socket, head, (ws) => {
|
| 198 |
-
ws.setMaxListeners(Infinity);
|
| 199 |
-
ws.on("error", (error) => {
|
| 200 |
-
console.error("[ws] socket error:", error.message);
|
| 201 |
-
});
|
| 202 |
-
if (process.env.NODE_ENV !== "production") {
|
| 203 |
-
ws.on("message", (data: Buffer, isBinary: boolean) => {
|
| 204 |
-
const buf = Buffer.isBuffer(data) ? data : Buffer.from(data as any);
|
| 205 |
-
console.log(`[ws-debug] msg ${buf.length}B binary=${isBinary} first20=${buf.slice(0, 20).toString("hex")}`);
|
| 206 |
-
});
|
| 207 |
-
}
|
| 208 |
-
const token = extractToken(req.headers.cookie);
|
| 209 |
-
hocuspocus.handleConnection(ws, req, { token });
|
| 210 |
-
});
|
| 211 |
-
} else {
|
| 212 |
-
console.log(`[ws] rejected upgrade for ${url}`);
|
| 213 |
-
socket.destroy();
|
| 214 |
-
}
|
| 215 |
-
});
|
| 216 |
-
|
| 217 |
-
// ---------- AI Chat ----------
|
| 218 |
-
|
| 219 |
-
app.post("/api/chat", handleChat);
|
| 220 |
-
|
| 221 |
-
// ---------- Citations ----------
|
| 222 |
-
|
| 223 |
-
app.use("/api/citations", citationsRouter);
|
| 224 |
-
|
| 225 |
-
// ---------- Preview (render without saving) ----------
|
| 226 |
-
|
| 227 |
-
app.get("/api/preview/:docName", async (req, res) => {
|
| 228 |
-
const docName = req.params.docName || DEFAULT_DOC_NAME;
|
| 229 |
-
|
| 230 |
-
if (oauthEnabled) {
|
| 231 |
-
const token = extractToken(req.headers.cookie);
|
| 232 |
-
const user = await resolveUser(token);
|
| 233 |
-
if (!user || !user.canEdit) {
|
| 234 |
-
res.status(403).json({ error: "Unauthorized" });
|
| 235 |
-
return;
|
| 236 |
-
}
|
| 237 |
-
}
|
| 238 |
-
|
| 239 |
-
try {
|
| 240 |
-
// Flush the live Hocuspocus doc to disk first
|
| 241 |
-
const conn = await hocuspocus.openDirectConnection(docName);
|
| 242 |
-
if (conn.document) {
|
| 243 |
-
const update = Y.encodeStateAsUpdate(conn.document);
|
| 244 |
-
writeFileSync(docPath(docName), Buffer.from(update));
|
| 245 |
-
}
|
| 246 |
-
await conn.disconnect();
|
| 247 |
-
|
| 248 |
-
const result = await previewDocument(docName);
|
| 249 |
-
if ("error" in result) {
|
| 250 |
-
res.status(404).json({ error: result.error });
|
| 251 |
-
return;
|
| 252 |
-
}
|
| 253 |
-
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
| 254 |
-
res.send(result.html);
|
| 255 |
-
} catch (err: any) {
|
| 256 |
-
console.error("[preview] error:", err);
|
| 257 |
-
res.status(500).json({ error: err.message || "Preview failed" });
|
| 258 |
-
}
|
| 259 |
-
});
|
| 260 |
-
|
| 261 |
-
// ---------- Publish ----------
|
| 262 |
-
|
| 263 |
-
app.post("/api/publish", async (req, res) => {
|
| 264 |
-
let userToken: string | undefined;
|
| 265 |
-
|
| 266 |
-
if (oauthEnabled) {
|
| 267 |
-
const token = extractToken(req.headers.cookie);
|
| 268 |
-
const user = await resolveUser(token);
|
| 269 |
-
if (!user) {
|
| 270 |
-
console.warn("[publish] no valid user from token, cookie present:", !!token);
|
| 271 |
-
res.status(403).json({ error: "Unauthorized: please log in first" });
|
| 272 |
-
return;
|
| 273 |
-
}
|
| 274 |
-
if (!user.canEdit) {
|
| 275 |
-
console.warn("[publish] user lacks write access:", user.name);
|
| 276 |
-
res.status(403).json({ error: "Unauthorized: write access required" });
|
| 277 |
-
return;
|
| 278 |
-
}
|
| 279 |
-
userToken = token ?? undefined;
|
| 280 |
-
if (userToken) setUserToken(userToken);
|
| 281 |
-
}
|
| 282 |
-
|
| 283 |
-
try {
|
| 284 |
-
const conn = await hocuspocus.openDirectConnection(DEFAULT_DOC_NAME);
|
| 285 |
-
if (conn.document) {
|
| 286 |
-
const liveTypes: string[] = [];
|
| 287 |
-
conn.document.share.forEach((_v, k) => liveTypes.push(k));
|
| 288 |
-
console.log("[publish] Live Hocuspocus doc shared types:", liveTypes);
|
| 289 |
-
const liveFragment = conn.document.getXmlFragment("default");
|
| 290 |
-
console.log("[publish] Live XmlFragment length:", liveFragment.length);
|
| 291 |
-
|
| 292 |
-
const update = Y.encodeStateAsUpdate(conn.document);
|
| 293 |
-
console.log("[publish] Encoded state size:", update.byteLength, "bytes");
|
| 294 |
-
writeFileSync(docPath(DEFAULT_DOC_NAME), Buffer.from(update));
|
| 295 |
-
} else {
|
| 296 |
-
console.warn("[publish] openDirectConnection returned no document");
|
| 297 |
-
}
|
| 298 |
-
await conn.disconnect();
|
| 299 |
-
|
| 300 |
-
const result = await publishDocument(DEFAULT_DOC_NAME, userToken);
|
| 301 |
-
if (!result.success) {
|
| 302 |
-
res.status(500).json({ error: result.error || "Publish failed" });
|
| 303 |
-
return;
|
| 304 |
-
}
|
| 305 |
-
res.json(result);
|
| 306 |
-
} catch (err: any) {
|
| 307 |
-
console.error("[publish] error:", err);
|
| 308 |
-
res.status(500).json({ error: err.message || "Publish failed" });
|
| 309 |
-
}
|
| 310 |
-
});
|
| 311 |
-
|
| 312 |
-
// ---------- Reset Document ----------
|
| 313 |
-
|
| 314 |
-
app.post("/api/admin/reset-document", async (req, res) => {
|
| 315 |
-
if (oauthEnabled) {
|
| 316 |
-
const token = extractToken(req.headers.cookie);
|
| 317 |
-
const user = await resolveUser(token);
|
| 318 |
-
if (!user || !user.canEdit) {
|
| 319 |
-
res.status(403).json({ error: "Unauthorized" });
|
| 320 |
-
return;
|
| 321 |
-
}
|
| 322 |
-
if (token) setUserToken(token);
|
| 323 |
-
}
|
| 324 |
-
|
| 325 |
-
try {
|
| 326 |
-
// Delete the local .yjs file so the next connection starts fresh
|
| 327 |
-
const p = docPath(DEFAULT_DOC_NAME);
|
| 328 |
-
if (existsSync(p)) {
|
| 329 |
-
const { unlinkSync } = await import("fs");
|
| 330 |
-
unlinkSync(p);
|
| 331 |
-
console.log("[admin] deleted local Y.Doc file:", p);
|
| 332 |
-
}
|
| 333 |
-
|
| 334 |
-
// Close existing connections to force reload
|
| 335 |
-
await hocuspocus.closeConnections(DEFAULT_DOC_NAME);
|
| 336 |
-
console.log("[admin] closed connections for", DEFAULT_DOC_NAME);
|
| 337 |
-
|
| 338 |
-
res.json({ success: true, message: "Document reset. Refresh the editor to start fresh." });
|
| 339 |
-
} catch (err: any) {
|
| 340 |
-
console.error("[admin] reset error:", err);
|
| 341 |
-
res.status(500).json({ error: err.message || "Reset failed" });
|
| 342 |
-
}
|
| 343 |
-
});
|
| 344 |
-
|
| 345 |
-
// ---------- Image Upload ----------
|
| 346 |
-
|
| 347 |
-
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 10 * 1024 * 1024 } });
|
| 348 |
-
|
| 349 |
-
app.post("/api/upload", upload.single("file"), async (req, res) => {
|
| 350 |
-
try {
|
| 351 |
-
const file = (req as any).file as Express.Multer.File | undefined;
|
| 352 |
-
if (!file) {
|
| 353 |
-
res.status(400).json({ error: "No file provided" });
|
| 354 |
-
return;
|
| 355 |
-
}
|
| 356 |
-
|
| 357 |
-
const ext = file.originalname.split(".").pop() || "png";
|
| 358 |
-
const filename = `${randomUUID()}.${ext}`;
|
| 359 |
-
|
| 360 |
-
const userToken = extractToken(req.headers.cookie) ?? undefined;
|
| 361 |
-
if (userToken) setUserToken(userToken);
|
| 362 |
-
|
| 363 |
-
if (isHfStorageEnabled()) {
|
| 364 |
-
const url = await uploadImageToHf(file.buffer, filename, userToken);
|
| 365 |
-
res.json({ url });
|
| 366 |
-
return;
|
| 367 |
-
}
|
| 368 |
-
|
| 369 |
-
// Fallback: save locally and serve via /uploads/
|
| 370 |
-
const uploadsDir = join(DATA_DIR, "uploads");
|
| 371 |
-
mkdirSync(uploadsDir, { recursive: true });
|
| 372 |
-
writeFileSync(join(uploadsDir, filename), file.buffer);
|
| 373 |
-
res.json({ url: `/uploads/${filename}` });
|
| 374 |
-
} catch (err: any) {
|
| 375 |
-
console.error("[upload] error:", err);
|
| 376 |
-
res.status(500).json({ error: err.message || "Upload failed" });
|
| 377 |
-
}
|
| 378 |
-
});
|
| 379 |
-
|
| 380 |
-
// Serve locally uploaded images
|
| 381 |
-
app.use("/uploads", express.static(join(DATA_DIR, "uploads")));
|
| 382 |
-
|
| 383 |
-
// Serve locally published articles
|
| 384 |
-
app.use("/published", express.static(join(DATA_DIR, "published")));
|
| 385 |
-
|
| 386 |
-
// ---------- Static frontend ----------
|
| 387 |
-
|
| 388 |
-
const staticDir =
|
| 389 |
-
process.env.NODE_ENV === "production"
|
| 390 |
-
? join(DATA_DIR, "..", "frontend-dist")
|
| 391 |
-
: join(DATA_DIR, "..", "..", "frontend", "dist");
|
| 392 |
-
|
| 393 |
-
function getPublishedPath(docName: string): string {
|
| 394 |
-
return join(DATA_DIR, "published", docName, "index.html");
|
| 395 |
-
}
|
| 396 |
-
|
| 397 |
-
function sendLoginPage(res: express.Response) {
|
| 398 |
-
res.status(200).send(`<!DOCTYPE html>
|
| 399 |
-
<html lang="en">
|
| 400 |
-
<head>
|
| 401 |
-
<meta charset="utf-8" />
|
| 402 |
-
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
| 403 |
-
<title>Research Article Template Editor</title>
|
| 404 |
-
<style>
|
| 405 |
-
* { margin: 0; padding: 0; box-sizing: border-box; }
|
| 406 |
-
body { display: flex; align-items: center; justify-content: center; height: 100vh;
|
| 407 |
-
font-family: system-ui, -apple-system, sans-serif; background: #0a0a0a; color: #aaa; }
|
| 408 |
-
.card { text-align: center; max-width: 420px; padding: 2rem; }
|
| 409 |
-
h1 { color: #fff; font-size: 1.5rem; margin-bottom: 0.5rem; }
|
| 410 |
-
p { margin-bottom: 1.5rem; line-height: 1.5; }
|
| 411 |
-
a.btn { display: inline-block; padding: 0.6rem 1.5rem; border-radius: 8px;
|
| 412 |
-
background: #958DF1; color: #fff; text-decoration: none; font-weight: 500;
|
| 413 |
-
transition: opacity 0.15s; }
|
| 414 |
-
a.btn:hover { opacity: 0.85; }
|
| 415 |
-
</style>
|
| 416 |
-
</head>
|
| 417 |
-
<body>
|
| 418 |
-
<div class="card">
|
| 419 |
-
<h1>This article is not yet published</h1>
|
| 420 |
-
<p>Log in with your Hugging Face account to access the editor.</p>
|
| 421 |
-
<a class="btn" href="/oauth/authorize" target="_blank">Sign in with Hugging Face</a>
|
| 422 |
-
</div>
|
| 423 |
-
</body>
|
| 424 |
-
</html>`);
|
| 425 |
-
}
|
| 426 |
-
|
| 427 |
-
if (existsSync(staticDir)) {
|
| 428 |
-
// Serve JS/CSS/assets but NOT index.html (routing handles that)
|
| 429 |
-
app.use(express.static(staticDir, { index: false }));
|
| 430 |
-
|
| 431 |
-
// GET /editor → requires auth, serves the SPA
|
| 432 |
-
app.get("/editor", async (req, res) => {
|
| 433 |
-
if (oauthEnabled) {
|
| 434 |
-
const token = extractToken(req.headers.cookie);
|
| 435 |
-
const user = await resolveUser(token);
|
| 436 |
-
|
| 437 |
-
if (!user || !user.canEdit) {
|
| 438 |
-
// Not logged in or no write access → show login prompt
|
| 439 |
-
// Can't navigate top frame from sandboxed iframe, so show a button
|
| 440 |
-
res.status(200).send(`<!DOCTYPE html>
|
| 441 |
-
<html lang="en">
|
| 442 |
-
<head><meta charset="utf-8"/><meta name="viewport" content="width=device-width,initial-scale=1"/>
|
| 443 |
-
<title>Sign in required</title>
|
| 444 |
-
<style>*{margin:0;padding:0;box-sizing:border-box}body{display:flex;align-items:center;justify-content:center;height:100vh;font-family:system-ui,-apple-system,sans-serif;background:#0a0a0a;color:#aaa}.card{text-align:center;max-width:420px;padding:2rem}h1{color:#fff;font-size:1.5rem;margin-bottom:.5rem}p{margin-bottom:1.5rem;line-height:1.5}a.btn{display:inline-block;padding:.6rem 1.5rem;border-radius:8px;background:#958DF1;color:#fff;text-decoration:none;font-weight:500;transition:opacity .15s}a.btn:hover{opacity:.85}</style></head>
|
| 445 |
-
<body><div class="card">
|
| 446 |
-
<h1>Editor access</h1>
|
| 447 |
-
<p>Sign in with your Hugging Face account to start editing.</p>
|
| 448 |
-
<a class="btn" href="/oauth/authorize" target="_blank">Sign in with Hugging Face</a>
|
| 449 |
-
<p style="margin-top:1rem;font-size:.85rem;color:#666">After signing in, come back and <a href="/editor" style="color:#958DF1">refresh this page</a>.</p>
|
| 450 |
-
</div></body></html>`);
|
| 451 |
-
return;
|
| 452 |
-
}
|
| 453 |
-
}
|
| 454 |
-
|
| 455 |
-
res.sendFile(join(staticDir, "index.html"));
|
| 456 |
-
});
|
| 457 |
-
|
| 458 |
-
// GET / (and all other paths) → public article or login prompt
|
| 459 |
-
app.get("*", async (req, res) => {
|
| 460 |
-
if (!oauthEnabled) {
|
| 461 |
-
// Dev mode: serve editor directly
|
| 462 |
-
res.sendFile(join(staticDir, "index.html"));
|
| 463 |
-
return;
|
| 464 |
-
}
|
| 465 |
-
|
| 466 |
-
// Try lazy-restore on every request until successful.
|
| 467 |
-
// Dataset is public, so no token is needed for reads.
|
| 468 |
-
// An OAuth token is passed when available for extra reliability.
|
| 469 |
-
const visitorToken = extractToken(req.headers.cookie) ?? undefined;
|
| 470 |
-
await ensurePublishedRestored(visitorToken);
|
| 471 |
-
|
| 472 |
-
// In production with OAuth: serve the published article if it exists
|
| 473 |
-
const publishedPath = getPublishedPath(DEFAULT_DOC_NAME);
|
| 474 |
-
|
| 475 |
-
if (existsSync(publishedPath)) {
|
| 476 |
-
res.sendFile(publishedPath);
|
| 477 |
-
return;
|
| 478 |
-
}
|
| 479 |
-
|
| 480 |
-
// No published version → login page
|
| 481 |
-
sendLoginPage(res);
|
| 482 |
-
});
|
| 483 |
-
}
|
| 484 |
-
|
| 485 |
-
// ---------- Start ----------
|
| 486 |
|
| 487 |
const server = httpServer.listen(PORT, async () => {
|
| 488 |
console.log(`[server] running on http://localhost:${PORT}`);
|
| 489 |
console.log(`[server] collab websocket at ws://localhost:${PORT}/collab`);
|
| 490 |
console.log(`[server] SPACE_ID=${process.env.SPACE_ID || "(not set)"} HF_DATASET_ID=${getDatasetId() || "(empty)"} HF_TOKEN=${process.env.HF_TOKEN ? "set" : "not set"}`);
|
| 491 |
-
|
| 492 |
-
// Eager restore: dataset is public, works without token
|
| 493 |
-
await ensurePublishedRestored();
|
| 494 |
});
|
| 495 |
|
| 496 |
async function gracefulShutdown(signal: string) {
|
|
|
|
| 1 |
import "dotenv/config";
|
| 2 |
+
import { isHfStorageEnabled, getDatasetId, flushAll } from "./hf-storage.js";
|
| 3 |
+
import { isOAuthEnabled } from "./auth.js";
|
| 4 |
+
import { createApp } from "./create-app.js";
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
|
| 6 |
const PORT = parseInt(process.env.PORT || "8080", 10);
|
| 7 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
if (isHfStorageEnabled()) {
|
| 9 |
console.log("[server] HF Dataset persistence enabled");
|
| 10 |
} else if (getDatasetId()) {
|
|
|
|
| 21 |
console.log("[server] HF OAuth disabled (no SPACE_ID), all users can edit");
|
| 22 |
}
|
| 23 |
|
| 24 |
+
const { httpServer } = createApp();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
|
| 26 |
const server = httpServer.listen(PORT, async () => {
|
| 27 |
console.log(`[server] running on http://localhost:${PORT}`);
|
| 28 |
console.log(`[server] collab websocket at ws://localhost:${PORT}/collab`);
|
| 29 |
console.log(`[server] SPACE_ID=${process.env.SPACE_ID || "(not set)"} HF_DATASET_ID=${getDatasetId() || "(empty)"} HF_TOKEN=${process.env.HF_TOKEN ? "set" : "not set"}`);
|
|
|
|
|
|
|
|
|
|
| 30 |
});
|
| 31 |
|
| 32 |
async function gracefulShutdown(signal: string) {
|
backend/src/utils.ts
CHANGED
|
@@ -3,12 +3,26 @@ import { fileURLToPath } from "url";
|
|
| 3 |
|
| 4 |
const __dirname = dirname(fileURLToPath(import.meta.url));
|
| 5 |
|
| 6 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
|
| 8 |
export function sanitizeName(name: string): string {
|
| 9 |
return name.replace(/[^a-zA-Z0-9_-]/g, "_");
|
| 10 |
}
|
| 11 |
|
| 12 |
export function docPath(name: string): string {
|
| 13 |
-
return join(
|
| 14 |
}
|
|
|
|
| 3 |
|
| 4 |
const __dirname = dirname(fileURLToPath(import.meta.url));
|
| 5 |
|
| 6 |
+
const DEFAULT_DATA_DIR = join(__dirname, "..", "data");
|
| 7 |
+
|
| 8 |
+
let _dataDir: string | undefined;
|
| 9 |
+
|
| 10 |
+
/** Override DATA_DIR for testing. Pass undefined to reset to default. */
|
| 11 |
+
export function setDataDir(dir: string | undefined) {
|
| 12 |
+
_dataDir = dir;
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
export function getDataDir(): string {
|
| 16 |
+
return _dataDir ?? process.env.DATA_DIR ?? DEFAULT_DATA_DIR;
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
/** @deprecated Use getDataDir() instead */
|
| 20 |
+
export const DATA_DIR = DEFAULT_DATA_DIR;
|
| 21 |
|
| 22 |
export function sanitizeName(name: string): string {
|
| 23 |
return name.replace(/[^a-zA-Z0-9_-]/g, "_");
|
| 24 |
}
|
| 25 |
|
| 26 |
export function docPath(name: string): string {
|
| 27 |
+
return join(getDataDir(), `${sanitizeName(name)}.yjs`);
|
| 28 |
}
|
backend/tests/agent-chat.test.ts
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Agent Chat Tests
|
| 3 |
+
*
|
| 4 |
+
* Tests for /api/chat and /api/embed-chat routes:
|
| 5 |
+
* - Input validation (missing messages)
|
| 6 |
+
* - Missing API key handling
|
| 7 |
+
* - Model list endpoint
|
| 8 |
+
*/
|
| 9 |
+
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
| 10 |
+
import request from "supertest";
|
| 11 |
+
import { mkdtempSync, rmSync } from "fs";
|
| 12 |
+
import { mkdirSync } from "fs";
|
| 13 |
+
import { tmpdir } from "os";
|
| 14 |
+
import { join } from "path";
|
| 15 |
+
import { setDataDir } from "../src/utils.js";
|
| 16 |
+
import { createApp, resetSaveTimers } from "../src/create-app.js";
|
| 17 |
+
|
| 18 |
+
let tmpDir: string;
|
| 19 |
+
let app: ReturnType<typeof createApp>["app"];
|
| 20 |
+
let httpServer: ReturnType<typeof createApp>["httpServer"];
|
| 21 |
+
let hocuspocus: ReturnType<typeof createApp>["hocuspocus"];
|
| 22 |
+
|
| 23 |
+
beforeEach(() => {
|
| 24 |
+
tmpDir = mkdtempSync(join(tmpdir(), "collab-agent-test-"));
|
| 25 |
+
mkdirSync(tmpDir, { recursive: true });
|
| 26 |
+
setDataDir(tmpDir);
|
| 27 |
+
const result = createApp();
|
| 28 |
+
app = result.app;
|
| 29 |
+
httpServer = result.httpServer;
|
| 30 |
+
hocuspocus = result.hocuspocus;
|
| 31 |
+
});
|
| 32 |
+
|
| 33 |
+
afterEach(async () => {
|
| 34 |
+
resetSaveTimers();
|
| 35 |
+
try { await hocuspocus.destroy(); } catch {}
|
| 36 |
+
try { httpServer.close(); } catch {}
|
| 37 |
+
setDataDir(undefined);
|
| 38 |
+
try { rmSync(tmpDir, { recursive: true, force: true }); } catch {}
|
| 39 |
+
vi.restoreAllMocks();
|
| 40 |
+
});
|
| 41 |
+
|
| 42 |
+
describe("/api/models", () => {
|
| 43 |
+
it("returns a non-empty array of models", async () => {
|
| 44 |
+
const res = await request(app).get("/api/models").expect(200);
|
| 45 |
+
expect(Array.isArray(res.body)).toBe(true);
|
| 46 |
+
expect(res.body.length).toBeGreaterThan(0);
|
| 47 |
+
expect(res.body[0]).toHaveProperty("id");
|
| 48 |
+
expect(res.body[0]).toHaveProperty("label");
|
| 49 |
+
expect(res.body[0]).toHaveProperty("context");
|
| 50 |
+
expect(res.body[0]).toHaveProperty("cost");
|
| 51 |
+
});
|
| 52 |
+
});
|
| 53 |
+
|
| 54 |
+
describe("/api/chat - validation", () => {
|
| 55 |
+
it("rejects request without messages", async () => {
|
| 56 |
+
const res = await request(app)
|
| 57 |
+
.post("/api/chat")
|
| 58 |
+
.send({ context: {} })
|
| 59 |
+
.expect(400);
|
| 60 |
+
|
| 61 |
+
expect(res.body).toHaveProperty("error");
|
| 62 |
+
expect(res.body.error).toContain("messages");
|
| 63 |
+
});
|
| 64 |
+
|
| 65 |
+
it("rejects request with non-array messages", async () => {
|
| 66 |
+
const res = await request(app)
|
| 67 |
+
.post("/api/chat")
|
| 68 |
+
.send({ messages: "not-an-array" })
|
| 69 |
+
.expect(400);
|
| 70 |
+
|
| 71 |
+
expect(res.body).toHaveProperty("error");
|
| 72 |
+
});
|
| 73 |
+
|
| 74 |
+
it("returns 500 when OPENROUTER_API_KEY is missing", async () => {
|
| 75 |
+
const original = process.env.OPENROUTER_API_KEY;
|
| 76 |
+
delete process.env.OPENROUTER_API_KEY;
|
| 77 |
+
|
| 78 |
+
const res = await request(app)
|
| 79 |
+
.post("/api/chat")
|
| 80 |
+
.send({
|
| 81 |
+
messages: [{ id: "1", role: "user", parts: [{ type: "text", text: "hello" }] }],
|
| 82 |
+
})
|
| 83 |
+
.expect(500);
|
| 84 |
+
|
| 85 |
+
expect(res.body).toHaveProperty("error");
|
| 86 |
+
expect(res.body.error).toContain("OPENROUTER_API_KEY");
|
| 87 |
+
|
| 88 |
+
if (original) process.env.OPENROUTER_API_KEY = original;
|
| 89 |
+
});
|
| 90 |
+
});
|
| 91 |
+
|
| 92 |
+
describe("/api/embed-chat - validation", () => {
|
| 93 |
+
it("rejects request without messages", async () => {
|
| 94 |
+
const res = await request(app)
|
| 95 |
+
.post("/api/embed-chat")
|
| 96 |
+
.send({ context: {} })
|
| 97 |
+
.expect(400);
|
| 98 |
+
|
| 99 |
+
expect(res.body).toHaveProperty("error");
|
| 100 |
+
expect(res.body.error).toContain("messages");
|
| 101 |
+
});
|
| 102 |
+
|
| 103 |
+
it("rejects request with non-array messages", async () => {
|
| 104 |
+
const res = await request(app)
|
| 105 |
+
.post("/api/embed-chat")
|
| 106 |
+
.send({ messages: 42 })
|
| 107 |
+
.expect(400);
|
| 108 |
+
|
| 109 |
+
expect(res.body).toHaveProperty("error");
|
| 110 |
+
});
|
| 111 |
+
|
| 112 |
+
it("returns 500 when OPENROUTER_API_KEY is missing", async () => {
|
| 113 |
+
const original = process.env.OPENROUTER_API_KEY;
|
| 114 |
+
delete process.env.OPENROUTER_API_KEY;
|
| 115 |
+
|
| 116 |
+
const res = await request(app)
|
| 117 |
+
.post("/api/embed-chat")
|
| 118 |
+
.send({
|
| 119 |
+
messages: [{ id: "1", role: "user", parts: [{ type: "text", text: "make a chart" }] }],
|
| 120 |
+
})
|
| 121 |
+
.expect(500);
|
| 122 |
+
|
| 123 |
+
expect(res.body).toHaveProperty("error");
|
| 124 |
+
expect(res.body.error).toContain("OPENROUTER_API_KEY");
|
| 125 |
+
|
| 126 |
+
if (original) process.env.OPENROUTER_API_KEY = original;
|
| 127 |
+
});
|
| 128 |
+
});
|
backend/tests/api-routes.test.ts
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* API Routes Tests (Section 3 of TESTS.md)
|
| 3 |
+
*
|
| 4 |
+
* Integration tests using Supertest against createApp().
|
| 5 |
+
* Runs with OAuth disabled (no SPACE_ID) so all routes are accessible.
|
| 6 |
+
*/
|
| 7 |
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
| 8 |
+
import request from "supertest";
|
| 9 |
+
import { mkdtempSync, rmSync, writeFileSync, existsSync } from "fs";
|
| 10 |
+
import { mkdirSync } from "fs";
|
| 11 |
+
import { tmpdir } from "os";
|
| 12 |
+
import { join } from "path";
|
| 13 |
+
import * as Y from "yjs";
|
| 14 |
+
import { setDataDir, docPath } from "../src/utils.js";
|
| 15 |
+
import { createApp } from "../src/create-app.js";
|
| 16 |
+
import { resetSaveTimers } from "../src/create-app.js";
|
| 17 |
+
|
| 18 |
+
let tmpDir: string;
|
| 19 |
+
let app: ReturnType<typeof createApp>["app"];
|
| 20 |
+
let httpServer: ReturnType<typeof createApp>["httpServer"];
|
| 21 |
+
let hocuspocus: ReturnType<typeof createApp>["hocuspocus"];
|
| 22 |
+
|
| 23 |
+
function seedDoc(name: string, text: string) {
|
| 24 |
+
const ydoc = new Y.Doc();
|
| 25 |
+
const fragment = ydoc.getXmlFragment("default");
|
| 26 |
+
const el = new Y.XmlElement("paragraph");
|
| 27 |
+
el.insert(0, [new Y.XmlText(text)]);
|
| 28 |
+
fragment.insert(0, [el]);
|
| 29 |
+
|
| 30 |
+
const frontmatter = ydoc.getMap("frontmatter");
|
| 31 |
+
frontmatter.set("title", "Test Article");
|
| 32 |
+
frontmatter.set("description", "A test article");
|
| 33 |
+
|
| 34 |
+
const state = Y.encodeStateAsUpdate(ydoc);
|
| 35 |
+
writeFileSync(docPath(name), Buffer.from(state));
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
beforeEach(() => {
|
| 39 |
+
tmpDir = mkdtempSync(join(tmpdir(), "collab-api-test-"));
|
| 40 |
+
mkdirSync(tmpDir, { recursive: true });
|
| 41 |
+
setDataDir(tmpDir);
|
| 42 |
+
|
| 43 |
+
const result = createApp();
|
| 44 |
+
app = result.app;
|
| 45 |
+
httpServer = result.httpServer;
|
| 46 |
+
hocuspocus = result.hocuspocus;
|
| 47 |
+
});
|
| 48 |
+
|
| 49 |
+
afterEach(async () => {
|
| 50 |
+
resetSaveTimers();
|
| 51 |
+
try {
|
| 52 |
+
await hocuspocus.destroy();
|
| 53 |
+
} catch {}
|
| 54 |
+
try {
|
| 55 |
+
httpServer.close();
|
| 56 |
+
} catch {}
|
| 57 |
+
setDataDir(undefined);
|
| 58 |
+
try {
|
| 59 |
+
rmSync(tmpDir, { recursive: true, force: true });
|
| 60 |
+
} catch {}
|
| 61 |
+
});
|
| 62 |
+
|
| 63 |
+
/**
|
| 64 |
+
* Drive the async publish pipeline end-to-end: POST returns a jobId, then we
|
| 65 |
+
* consume /api/publish/stream until the "done" SSE event fires. Supertest
|
| 66 |
+
* buffers the full response body once the server closes the stream, which
|
| 67 |
+
* happens right after `done`.
|
| 68 |
+
*/
|
| 69 |
+
async function runPublishAndWait(): Promise<{
|
| 70 |
+
success: boolean;
|
| 71 |
+
result?: { htmlUrl: string | null; pdfUrl: string | null; thumbUrl: string | null; success: boolean };
|
| 72 |
+
error?: string;
|
| 73 |
+
}> {
|
| 74 |
+
const post = await request(app).post("/api/publish").expect(200);
|
| 75 |
+
expect(post.body).toHaveProperty("jobId");
|
| 76 |
+
const jobId: string = post.body.jobId;
|
| 77 |
+
|
| 78 |
+
const stream = await request(app)
|
| 79 |
+
.get(`/api/publish/stream?jobId=${encodeURIComponent(jobId)}`)
|
| 80 |
+
.buffer(true)
|
| 81 |
+
.parse((res, cb) => {
|
| 82 |
+
let data = "";
|
| 83 |
+
res.setEncoding("utf8");
|
| 84 |
+
res.on("data", (chunk: string) => { data += chunk; });
|
| 85 |
+
res.on("end", () => cb(null, data));
|
| 86 |
+
});
|
| 87 |
+
|
| 88 |
+
const body = stream.text ?? (stream.body as unknown as string);
|
| 89 |
+
const match = /event: done\ndata: (.+)/.exec(body);
|
| 90 |
+
if (!match) throw new Error(`No done event in SSE body: ${body.slice(0, 500)}`);
|
| 91 |
+
return JSON.parse(match[1]);
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
describe("3.1 Publish routes", () => {
|
| 95 |
+
it("3.1.1 POST /api/publish returns a jobId and the SSE stream reports success", async () => {
|
| 96 |
+
seedDoc("default", "Hello from the test article.");
|
| 97 |
+
|
| 98 |
+
const done = await runPublishAndWait();
|
| 99 |
+
|
| 100 |
+
expect(done.success).toBe(true);
|
| 101 |
+
expect(done.result?.success).toBe(true);
|
| 102 |
+
expect(typeof done.result?.htmlUrl).toBe("string");
|
| 103 |
+
}, 60_000);
|
| 104 |
+
|
| 105 |
+
it("3.1.2 Publish writes local HTML file", async () => {
|
| 106 |
+
seedDoc("default", "Published content goes here.");
|
| 107 |
+
|
| 108 |
+
await runPublishAndWait();
|
| 109 |
+
|
| 110 |
+
const publishedPath = join(tmpDir, "published", "default", "index.html");
|
| 111 |
+
expect(existsSync(publishedPath)).toBe(true);
|
| 112 |
+
}, 60_000);
|
| 113 |
+
|
| 114 |
+
it("3.1.3 Concurrent POST /api/publish returns 409 for the second request", async () => {
|
| 115 |
+
seedDoc("default", "Concurrency check.");
|
| 116 |
+
|
| 117 |
+
// Fire both in the same tick. The mutex is set synchronously inside the
|
| 118 |
+
// first handler before `res.json({ jobId })` is returned, so the second
|
| 119 |
+
// handler (scheduled right after) must see it and respond with 409.
|
| 120 |
+
const [a, b] = await Promise.all([
|
| 121 |
+
request(app).post("/api/publish"),
|
| 122 |
+
request(app).post("/api/publish"),
|
| 123 |
+
]);
|
| 124 |
+
|
| 125 |
+
const statuses = [a.status, b.status].sort((x, y) => x - y);
|
| 126 |
+
expect(statuses).toEqual([200, 409]);
|
| 127 |
+
|
| 128 |
+
const winner = a.status === 200 ? a : b;
|
| 129 |
+
expect(winner.body).toHaveProperty("jobId");
|
| 130 |
+
const loser = a.status === 409 ? a : b;
|
| 131 |
+
expect(loser.body).toHaveProperty("error");
|
| 132 |
+
|
| 133 |
+
// Drain the SSE stream so the job releases cleanly and the test harness
|
| 134 |
+
// doesn't leak open sockets.
|
| 135 |
+
await request(app)
|
| 136 |
+
.get(`/api/publish/stream?jobId=${encodeURIComponent(winner.body.jobId)}`)
|
| 137 |
+
.buffer(true)
|
| 138 |
+
.parse((res, cb) => {
|
| 139 |
+
let data = "";
|
| 140 |
+
res.setEncoding("utf8");
|
| 141 |
+
res.on("data", (chunk: string) => { data += chunk; });
|
| 142 |
+
res.on("end", () => cb(null, data));
|
| 143 |
+
});
|
| 144 |
+
}, 60_000);
|
| 145 |
+
});
|
| 146 |
+
|
| 147 |
+
describe("3.2 Auth status", () => {
|
| 148 |
+
it("returns authenticated=true when OAuth is disabled", async () => {
|
| 149 |
+
const res = await request(app)
|
| 150 |
+
.get("/api/auth/status")
|
| 151 |
+
.expect(200);
|
| 152 |
+
|
| 153 |
+
expect(res.body).toHaveProperty("authenticated", true);
|
| 154 |
+
expect(res.body).toHaveProperty("canEdit", true);
|
| 155 |
+
});
|
| 156 |
+
});
|
backend/tests/persistence.test.ts
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Persistence Tests (Section 2 of TESTS.md)
|
| 3 |
+
*
|
| 4 |
+
* Tests debouncedSave, local file read/write, and flushAll.
|
| 5 |
+
* Uses a temporary directory to avoid polluting real data.
|
| 6 |
+
*/
|
| 7 |
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
| 8 |
+
import { mkdirSync, existsSync, readFileSync, writeFileSync } from "fs";
|
| 9 |
+
import { mkdtempSync, rmSync } from "fs";
|
| 10 |
+
import { tmpdir } from "os";
|
| 11 |
+
import { join } from "path";
|
| 12 |
+
import * as Y from "yjs";
|
| 13 |
+
import { setDataDir, getDataDir, docPath } from "../src/utils.js";
|
| 14 |
+
import { debouncedSave, resetSaveTimers } from "../src/create-app.js";
|
| 15 |
+
|
| 16 |
+
let tmpDir: string;
|
| 17 |
+
|
| 18 |
+
beforeEach(() => {
|
| 19 |
+
tmpDir = mkdtempSync(join(tmpdir(), "collab-test-"));
|
| 20 |
+
mkdirSync(tmpDir, { recursive: true });
|
| 21 |
+
setDataDir(tmpDir);
|
| 22 |
+
});
|
| 23 |
+
|
| 24 |
+
afterEach(() => {
|
| 25 |
+
resetSaveTimers();
|
| 26 |
+
setDataDir(undefined);
|
| 27 |
+
try {
|
| 28 |
+
rmSync(tmpDir, { recursive: true, force: true });
|
| 29 |
+
} catch {}
|
| 30 |
+
});
|
| 31 |
+
|
| 32 |
+
function makeDoc(text: string): Y.Doc {
|
| 33 |
+
const ydoc = new Y.Doc();
|
| 34 |
+
const fragment = ydoc.getXmlFragment("default");
|
| 35 |
+
const el = new Y.XmlElement("paragraph");
|
| 36 |
+
el.insert(0, [new Y.XmlText(text)]);
|
| 37 |
+
fragment.insert(0, [el]);
|
| 38 |
+
return ydoc;
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
describe("2.1 Local persistence", () => {
|
| 42 |
+
it("2.1.1 debouncedSave writes .yjs file after debounce", async () => {
|
| 43 |
+
const ydoc = makeDoc("Hello world");
|
| 44 |
+
|
| 45 |
+
debouncedSave("test-doc", ydoc);
|
| 46 |
+
|
| 47 |
+
// File should NOT exist immediately (debounce is 2s)
|
| 48 |
+
const p = docPath("test-doc");
|
| 49 |
+
expect(existsSync(p)).toBe(false);
|
| 50 |
+
|
| 51 |
+
// Wait for debounce to fire
|
| 52 |
+
await new Promise((r) => setTimeout(r, 2500));
|
| 53 |
+
|
| 54 |
+
expect(existsSync(p)).toBe(true);
|
| 55 |
+
const buf = readFileSync(p);
|
| 56 |
+
expect(buf.length).toBeGreaterThan(0);
|
| 57 |
+
|
| 58 |
+
// Verify it's valid Yjs binary by applying it to a new doc
|
| 59 |
+
const restored = new Y.Doc();
|
| 60 |
+
Y.applyUpdate(restored, new Uint8Array(buf));
|
| 61 |
+
const fragment = restored.getXmlFragment("default");
|
| 62 |
+
expect(fragment.length).toBeGreaterThan(0);
|
| 63 |
+
});
|
| 64 |
+
|
| 65 |
+
it("2.1.2 docPath reads from configured DATA_DIR", () => {
|
| 66 |
+
// Write a .yjs file manually, then verify docPath points to it
|
| 67 |
+
const ydoc = makeDoc("Existing content");
|
| 68 |
+
const state = Y.encodeStateAsUpdate(ydoc);
|
| 69 |
+
const p = docPath("existing");
|
| 70 |
+
writeFileSync(p, Buffer.from(state));
|
| 71 |
+
|
| 72 |
+
// Read it back and verify content
|
| 73 |
+
expect(existsSync(p)).toBe(true);
|
| 74 |
+
const buf = readFileSync(p);
|
| 75 |
+
const restored = new Y.Doc();
|
| 76 |
+
Y.applyUpdate(restored, new Uint8Array(buf));
|
| 77 |
+
const fragment = restored.getXmlFragment("default");
|
| 78 |
+
expect(fragment.length).toBeGreaterThan(0);
|
| 79 |
+
});
|
| 80 |
+
|
| 81 |
+
it("2.1.3 debounce collapses rapid saves into one write", async () => {
|
| 82 |
+
const ydoc = makeDoc("Version 1");
|
| 83 |
+
|
| 84 |
+
// Trigger 3 rapid saves
|
| 85 |
+
debouncedSave("rapid", ydoc);
|
| 86 |
+
|
| 87 |
+
const ydoc2 = makeDoc("Version 2");
|
| 88 |
+
debouncedSave("rapid", ydoc2);
|
| 89 |
+
|
| 90 |
+
const ydoc3 = makeDoc("Version 3");
|
| 91 |
+
debouncedSave("rapid", ydoc3);
|
| 92 |
+
|
| 93 |
+
const p = docPath("rapid");
|
| 94 |
+
|
| 95 |
+
// Nothing written yet
|
| 96 |
+
expect(existsSync(p)).toBe(false);
|
| 97 |
+
|
| 98 |
+
// Wait for the single debounced write
|
| 99 |
+
await new Promise((r) => setTimeout(r, 2500));
|
| 100 |
+
|
| 101 |
+
expect(existsSync(p)).toBe(true);
|
| 102 |
+
|
| 103 |
+
// The file should contain the last version (ydoc3)
|
| 104 |
+
const buf = readFileSync(p);
|
| 105 |
+
const restored = new Y.Doc();
|
| 106 |
+
Y.applyUpdate(restored, new Uint8Array(buf));
|
| 107 |
+
const fragment = restored.getXmlFragment("default");
|
| 108 |
+
expect(fragment.length).toBeGreaterThan(0);
|
| 109 |
+
});
|
| 110 |
+
});
|