tfrere HF Staff commited on
Commit
f6678ab
·
1 Parent(s): d15d7f7

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 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 DEFAULT_MODEL = "anthropic/claude-sonnet-4";
8
-
9
- function getProvider() {
10
- const apiKey = process.env.OPENROUTER_API_KEY;
11
- if (!apiKey) {
12
- throw new Error("OPENROUTER_API_KEY environment variable is required");
13
- }
14
- return createOpenRouter({ apiKey });
15
- }
16
 
17
  export async function handleChat(req: Request, res: Response) {
18
- try {
19
- const { messages, context, model } = req.body;
20
-
21
- if (!messages || !Array.isArray(messages)) {
22
- res.status(400).json({ error: "messages array is required" });
23
- return;
24
- }
25
-
26
- const provider = getProvider();
27
- const modelId = model || process.env.OPENROUTER_MODEL || DEFAULT_MODEL;
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(SPACE_ID && OAUTH_CLIENT_ID);
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: true,
99
- sameSite: "none",
100
  maxAge,
101
  path: "/",
102
  });
103
 
104
- res.redirect("/editor");
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 express from "express";
3
- import { createServer } from "http";
4
- import { WebSocketServer } from "ws";
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 hocuspocus = new Hocuspocus({
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
- export const DATA_DIR = join(__dirname, "..", "data");
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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(DATA_DIR, `${sanitizeName(name)}.yjs`);
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
+ });