sitedrift 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/mcp.mjs ADDED
@@ -0,0 +1,324 @@
1
+ import { readVersion } from './cli.mjs';
2
+ import { requestSession } from './agent.mjs';
3
+ import { readSession } from './session.mjs';
4
+
5
+ const PROTOCOL_VERSIONS = new Set([
6
+ '2024-11-05',
7
+ '2025-03-26',
8
+ '2025-06-18',
9
+ '2025-11-25',
10
+ ]);
11
+ const LATEST_PROTOCOL_VERSION = '2025-11-25';
12
+
13
+ const TOOLS = [
14
+ {
15
+ name: 'sitedrift_context',
16
+ title: 'Get sitedrift session context',
17
+ description: 'Get the active DEV/LIVE targets, viewer URL, capabilities, and session metadata. Call this first.',
18
+ inputSchema: {
19
+ type: 'object',
20
+ properties: {
21
+ port: { type: 'integer', minimum: 1, maximum: 65533, default: 4178 },
22
+ },
23
+ additionalProperties: false,
24
+ },
25
+ annotations: { readOnlyHint: true, openWorldHint: false },
26
+ },
27
+ {
28
+ name: 'sitedrift_notes_list',
29
+ title: 'List review notes',
30
+ description: 'List the current shared visual-review notes.',
31
+ inputSchema: {
32
+ type: 'object',
33
+ properties: {
34
+ port: { type: 'integer', minimum: 1, maximum: 65533, default: 4178 },
35
+ },
36
+ additionalProperties: false,
37
+ },
38
+ annotations: { readOnlyHint: true, openWorldHint: false },
39
+ },
40
+ {
41
+ name: 'sitedrift_note_add',
42
+ title: 'Add a review note',
43
+ description: 'Add one concrete visual finding for the user or another agent.',
44
+ inputSchema: {
45
+ type: 'object',
46
+ required: ['text'],
47
+ properties: {
48
+ text: { type: 'string', minLength: 1, maxLength: 2000 },
49
+ route: { type: 'string', default: '/' },
50
+ side: { type: ['string', 'null'], enum: ['dev', 'live', null] },
51
+ author: { type: 'string', default: 'agent' },
52
+ port: { type: 'integer', minimum: 1, maximum: 65533, default: 4178 },
53
+ },
54
+ additionalProperties: false,
55
+ },
56
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
57
+ },
58
+ ...['resolve', 'reopen', 'remove'].map((action) => ({
59
+ name: `sitedrift_note_${action}`,
60
+ title: `${action[0].toUpperCase()}${action.slice(1)} a review note`,
61
+ description: `${action[0].toUpperCase()}${action.slice(1)} one shared review note by ID.`,
62
+ inputSchema: {
63
+ type: 'object',
64
+ required: ['id'],
65
+ properties: {
66
+ id: { type: 'string', minLength: 1 },
67
+ port: { type: 'integer', minimum: 1, maximum: 65533, default: 4178 },
68
+ },
69
+ additionalProperties: false,
70
+ },
71
+ annotations: {
72
+ readOnlyHint: false,
73
+ destructiveHint: action === 'remove',
74
+ idempotentHint: action !== 'remove',
75
+ openWorldHint: false,
76
+ },
77
+ })),
78
+ {
79
+ name: 'sitedrift_notes_clear',
80
+ title: 'Clear all review notes',
81
+ description: 'Delete every note in the active review session. Use only when the user explicitly requests it.',
82
+ inputSchema: {
83
+ type: 'object',
84
+ properties: {
85
+ port: { type: 'integer', minimum: 1, maximum: 65533, default: 4178 },
86
+ },
87
+ additionalProperties: false,
88
+ },
89
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
90
+ },
91
+ {
92
+ name: 'sitedrift_setup',
93
+ title: 'Get setup instructions',
94
+ description: 'Return the shortest install, project configuration, launch, HTTPS, and MCP-client setup instructions.',
95
+ inputSchema: {
96
+ type: 'object',
97
+ properties: {
98
+ dev: { type: 'string', description: 'Local development origin.' },
99
+ live: { type: 'string', description: 'Production origin.' },
100
+ },
101
+ additionalProperties: false,
102
+ },
103
+ annotations: { readOnlyHint: true, openWorldHint: false },
104
+ },
105
+ ];
106
+
107
+ function jsonResult(value) {
108
+ return {
109
+ content: [{ type: 'text', text: JSON.stringify(value, null, 2) }],
110
+ structuredContent: value,
111
+ };
112
+ }
113
+
114
+ function setupInstructions(args = {}) {
115
+ const dev = args.dev || 'http://localhost:4321';
116
+ const live = args.live || 'https://example.com';
117
+ return {
118
+ install: 'npm install --global sitedrift',
119
+ configFile: 'sitedrift.config.json',
120
+ config: { dev, live, open: true },
121
+ launch: 'sitedrift',
122
+ https: ['sitedrift --setup-https', 'sitedrift --https'],
123
+ mcp: {
124
+ command: 'sitedrift-mcp',
125
+ alternative: 'npx -y sitedrift mcp',
126
+ config: { command: 'sitedrift-mcp', args: [] },
127
+ },
128
+ firstTool: 'sitedrift_context',
129
+ guide: 'Read the packaged AGENTS.md or the sitedrift://guide MCP resource.',
130
+ };
131
+ }
132
+
133
+ function noteOperation(name, args) {
134
+ if (name === 'sitedrift_note_add') {
135
+ return {
136
+ op: 'add',
137
+ text: args.text,
138
+ route: args.route || '/',
139
+ side: args.side || null,
140
+ author: args.author || 'agent',
141
+ };
142
+ }
143
+ if (name === 'sitedrift_notes_clear') return { op: 'clear' };
144
+ return { op: name.replace('sitedrift_note_', ''), id: args.id };
145
+ }
146
+
147
+ async function callTool(name, args = {}) {
148
+ if (name === 'sitedrift_setup') return setupInstructions(args);
149
+ const session = readSession(args.port || 4178);
150
+ if (name === 'sitedrift_context') return requestSession(session, '/api/v1/session');
151
+ if (name === 'sitedrift_notes_list') return requestSession(session, '/api/v1/notes');
152
+ if (!TOOLS.some((tool) => tool.name === name)) throw new Error(`Unknown tool: ${name}`);
153
+ return requestSession(session, '/api/v1/notes', {
154
+ method: 'POST',
155
+ body: JSON.stringify(noteOperation(name, args)),
156
+ });
157
+ }
158
+
159
+ function guideText() {
160
+ return `# sitedrift agent workflow
161
+
162
+ 1. Call sitedrift_context before doing review work.
163
+ 2. Use the returned viewer URL and DEV/LIVE targets as the source of truth.
164
+ 3. Record one concrete issue per sitedrift_note_add call. Include the route and side.
165
+ 4. Re-list notes before changing code and after verification.
166
+ 5. Resolve a note only after verifying the fix; remove notes only when explicitly asked.
167
+ 6. If no session is running, call sitedrift_setup and help the user create sitedrift.config.json, then launch sitedrift.
168
+
169
+ The MCP server never receives browser credentials and only talks to a loopback sitedrift session using its private mode-0600 descriptor.`;
170
+ }
171
+
172
+ function promptResult(args = {}) {
173
+ const route = args.route || '/';
174
+ return {
175
+ description: 'Review one route with sitedrift and leave actionable shared notes.',
176
+ messages: [{
177
+ role: 'user',
178
+ content: {
179
+ type: 'text',
180
+ text: `Review ${route} with sitedrift. Call sitedrift_context first, inspect DEV and LIVE, add one specific note per discrepancy, avoid duplicates, and resolve notes only after verification.`,
181
+ },
182
+ }],
183
+ };
184
+ }
185
+
186
+ export async function handleMcpRequest(message) {
187
+ const { id, method, params = {} } = message;
188
+ if (method === 'notifications/initialized' || method === 'notifications/cancelled') return null;
189
+ if (method === 'ping') return { jsonrpc: '2.0', id, result: {} };
190
+ if (method === 'initialize') {
191
+ const requested = params.protocolVersion;
192
+ if (requested && !PROTOCOL_VERSIONS.has(requested)) {
193
+ return {
194
+ jsonrpc: '2.0',
195
+ id,
196
+ error: {
197
+ code: -32602,
198
+ message: 'Unsupported protocol version',
199
+ data: { supported: [...PROTOCOL_VERSIONS], requested },
200
+ },
201
+ };
202
+ }
203
+ return {
204
+ jsonrpc: '2.0',
205
+ id,
206
+ result: {
207
+ protocolVersion: requested || LATEST_PROTOCOL_VERSION,
208
+ capabilities: { tools: {}, resources: {}, prompts: {} },
209
+ serverInfo: { name: 'sitedrift', version: readVersion() },
210
+ instructions: 'Call sitedrift_context first. If no session is running, call sitedrift_setup.',
211
+ },
212
+ };
213
+ }
214
+ if (method === 'tools/list') return { jsonrpc: '2.0', id, result: { tools: TOOLS } };
215
+ if (method === 'tools/call') {
216
+ try {
217
+ return { jsonrpc: '2.0', id, result: jsonResult(await callTool(params.name, params.arguments)) };
218
+ } catch (error) {
219
+ return {
220
+ jsonrpc: '2.0',
221
+ id,
222
+ result: {
223
+ content: [{ type: 'text', text: error.message }],
224
+ isError: true,
225
+ },
226
+ };
227
+ }
228
+ }
229
+ if (method === 'resources/list') {
230
+ return {
231
+ jsonrpc: '2.0',
232
+ id,
233
+ result: {
234
+ resources: [
235
+ { uri: 'sitedrift://guide', name: 'Agent guide', mimeType: 'text/markdown' },
236
+ { uri: 'sitedrift://session', name: 'Active session', mimeType: 'application/json' },
237
+ { uri: 'sitedrift://notes', name: 'Review notes', mimeType: 'application/json' },
238
+ ],
239
+ },
240
+ };
241
+ }
242
+ if (method === 'resources/read') {
243
+ try {
244
+ let text;
245
+ let mimeType = 'application/json';
246
+ if (params.uri === 'sitedrift://guide') {
247
+ text = guideText();
248
+ mimeType = 'text/markdown';
249
+ } else {
250
+ const session = readSession(4178);
251
+ const pathname = params.uri === 'sitedrift://session'
252
+ ? '/api/v1/session'
253
+ : params.uri === 'sitedrift://notes'
254
+ ? '/api/v1/notes'
255
+ : null;
256
+ if (!pathname) throw new Error(`Unknown resource: ${params.uri}`);
257
+ text = JSON.stringify(await requestSession(session, pathname), null, 2);
258
+ }
259
+ return {
260
+ jsonrpc: '2.0',
261
+ id,
262
+ result: { contents: [{ uri: params.uri, mimeType, text }] },
263
+ };
264
+ } catch (error) {
265
+ return { jsonrpc: '2.0', id, error: { code: -32002, message: error.message } };
266
+ }
267
+ }
268
+ if (method === 'prompts/list') {
269
+ return {
270
+ jsonrpc: '2.0',
271
+ id,
272
+ result: {
273
+ prompts: [{
274
+ name: 'review_route',
275
+ title: 'Review a route',
276
+ description: 'Compare one route and record actionable findings.',
277
+ arguments: [{ name: 'route', description: 'Route to review, such as /pricing.', required: false }],
278
+ }],
279
+ },
280
+ };
281
+ }
282
+ if (method === 'prompts/get') {
283
+ if (params.name !== 'review_route') {
284
+ return { jsonrpc: '2.0', id, error: { code: -32602, message: `Unknown prompt: ${params.name}` } };
285
+ }
286
+ return { jsonrpc: '2.0', id, result: promptResult(params.arguments) };
287
+ }
288
+ return { jsonrpc: '2.0', id, error: { code: -32601, message: `Method not found: ${method}` } };
289
+ }
290
+
291
+ async function processMessage(message) {
292
+ if (Array.isArray(message)) {
293
+ const responses = (await Promise.all(message.map(handleMcpRequest))).filter(Boolean);
294
+ return responses.length ? responses : null;
295
+ }
296
+ return handleMcpRequest(message);
297
+ }
298
+
299
+ export function runMcpServer(input = process.stdin, output = process.stdout) {
300
+ let buffer = '';
301
+ input.setEncoding('utf8');
302
+ input.on('data', (chunk) => {
303
+ buffer += chunk;
304
+ let newline;
305
+ while ((newline = buffer.indexOf('\n')) !== -1) {
306
+ const line = buffer.slice(0, newline).trim();
307
+ buffer = buffer.slice(newline + 1);
308
+ if (!line) continue;
309
+ Promise.resolve()
310
+ .then(() => JSON.parse(line))
311
+ .then(processMessage)
312
+ .then((response) => {
313
+ if (response) output.write(`${JSON.stringify(response)}\n`);
314
+ })
315
+ .catch((error) => {
316
+ output.write(`${JSON.stringify({
317
+ jsonrpc: '2.0',
318
+ id: null,
319
+ error: { code: -32700, message: error.message },
320
+ })}\n`);
321
+ });
322
+ }
323
+ });
324
+ }
package/src/notes.mjs ADDED
@@ -0,0 +1,78 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ // Review notes are a JSON file the server reads/mutates and the viewer polls,
5
+ // making it a shared channel between humans and AI sessions.
6
+ export function createNotes({ notesFile, author }) {
7
+ function load() {
8
+ try {
9
+ const data = JSON.parse(fs.readFileSync(notesFile, 'utf8'));
10
+ if (Array.isArray(data)) return data;
11
+ return Array.isArray(data.notes) ? data.notes : [];
12
+ } catch {
13
+ return [];
14
+ }
15
+ }
16
+
17
+ function save(notes) {
18
+ fs.mkdirSync(path.dirname(notesFile), { recursive: true });
19
+ const tmp = `${notesFile}.${process.pid}.${Date.now()}.tmp`;
20
+ fs.writeFileSync(tmp, JSON.stringify(notes, null, 2), { mode: 0o600 });
21
+ fs.renameSync(tmp, notesFile);
22
+ }
23
+
24
+ function id() {
25
+ return Date.now().toString(36) + Math.random().toString(36).slice(2, 7);
26
+ }
27
+
28
+ function markdown(notes) {
29
+ if (!notes.length) return '# sitedrift review notes\n\n_No notes yet._\n';
30
+ const lines = ['# sitedrift review notes', ''];
31
+ for (const note of notes) {
32
+ const box = note.done ? '[x]' : '[ ]';
33
+ const where = [note.route && note.route !== '/' ? note.route : '', note.side ? note.side.toUpperCase() : '']
34
+ .filter(Boolean).join(' ');
35
+ const tag = where ? ` _(${where})_` : '';
36
+ lines.push(`- ${box} **${note.author || 'note'}:** ${note.text}${tag}`);
37
+ }
38
+ lines.push('');
39
+ return lines.join('\n');
40
+ }
41
+
42
+ function applyOp(op) {
43
+ let notes = load();
44
+ if (op.op === 'add' && op.text) {
45
+ const text = String(op.text).slice(0, 2000);
46
+ const rawRoute = String(op.route || '/').slice(0, 2048);
47
+ const route = rawRoute.startsWith('/') ? rawRoute : `/${rawRoute}`;
48
+ const who = String(op.author || author || 'note').slice(0, 24);
49
+ const side = op.side === 'dev' || op.side === 'live' ? op.side : null;
50
+ // Skip an identical open note so repeated `--note` seeding doesn't pile up.
51
+ const duplicate = notes.some((note) => !note.done
52
+ && note.text === text && note.route === route && note.author === who && note.side === side);
53
+ if (!duplicate) {
54
+ notes.push({ id: id(), text, author: who, route, side, done: false, ts: Date.now() });
55
+ if (notes.length > 1000) notes = notes.slice(-1000);
56
+ }
57
+ } else if (op.op === 'remove') {
58
+ if (!notes.some((note) => note.id === op.id)) throw new Error(`Unknown note id: ${op.id}`);
59
+ notes = notes.filter((note) => note.id !== op.id);
60
+ } else if (op.op === 'toggle' || op.op === 'resolve' || op.op === 'reopen') {
61
+ const found = notes.some((note) => note.id === op.id);
62
+ if (!found) throw new Error(`Unknown note id: ${op.id}`);
63
+ notes = notes.map((note) => {
64
+ if (note.id !== op.id) return note;
65
+ const done = op.op === 'toggle' ? !note.done : op.op === 'resolve';
66
+ return { ...note, done };
67
+ });
68
+ } else if (op.op === 'clear') {
69
+ notes = [];
70
+ } else {
71
+ throw new Error(`Unknown notes operation: ${op.op || '(missing)'}`);
72
+ }
73
+ save(notes);
74
+ return notes;
75
+ }
76
+
77
+ return { load, save, markdown, applyOp };
78
+ }
package/src/proxy.mjs ADDED
@@ -0,0 +1,80 @@
1
+ import { send } from './http.mjs';
2
+ import { frameBridge, rewriteRootPaths } from './frame-content.mjs';
3
+
4
+ // Reverse-proxies the two origins under /__dev/* and /__live/*, rewriting
5
+ // root-relative URLs so both sites render framed side-by-side. Deliberately
6
+ // strips framing/isolation headers — safe for loopback development only.
7
+ export function createProxy({ devBase, liveBase }) {
8
+ function targetFor(side, pathname, search) {
9
+ const base = side === 'dev' ? devBase : liveBase;
10
+ const relative = pathname.replace(new RegExp(`^/__${side}`), '') || '/';
11
+ return new URL(`${relative}${search}`, `${base.href}/`);
12
+ }
13
+
14
+ async function proxy(req, res, side, requestUrl) {
15
+ const target = targetFor(side, requestUrl.pathname, requestUrl.search);
16
+ const headers = { ...req.headers, host: target.host };
17
+ delete headers['accept-encoding'];
18
+ delete headers.connection;
19
+
20
+ try {
21
+ const upstream = await fetch(target, {
22
+ method: req.method,
23
+ headers,
24
+ redirect: 'manual',
25
+ });
26
+ const responseHeaders = {};
27
+ upstream.headers.forEach((value, key) => {
28
+ if (![
29
+ 'content-encoding',
30
+ 'content-length',
31
+ 'content-security-policy',
32
+ 'content-security-policy-report-only',
33
+ 'cross-origin-embedder-policy',
34
+ 'cross-origin-opener-policy',
35
+ 'cross-origin-resource-policy',
36
+ 'transfer-encoding',
37
+ 'x-frame-options',
38
+ ].includes(key)) {
39
+ responseHeaders[key] = value;
40
+ }
41
+ });
42
+ responseHeaders['cache-control'] = 'no-store';
43
+
44
+ const location = upstream.headers.get('location');
45
+ if (location) {
46
+ const redirected = new URL(location, target);
47
+ if (redirected.origin === target.origin) {
48
+ responseHeaders.location = `/__${side}${redirected.pathname}${redirected.search}${redirected.hash}`;
49
+ }
50
+ }
51
+
52
+ const type = upstream.headers.get('content-type') || '';
53
+ // Rewrite markup/CSS/JS always; rewrite JSON only on the dev side (Vite
54
+ // manifests) so live API payloads with path-like strings aren't corrupted.
55
+ const rewritable = /text\/html|text\/css|javascript/.test(type)
56
+ || (side === 'dev' && /application\/json/.test(type));
57
+ if (rewritable) {
58
+ let body = rewriteRootPaths(await upstream.text(), `/__${side}`);
59
+ if (/text\/html/.test(type)) {
60
+ const injected = frameBridge(side);
61
+ body = body.includes('</head>') ? body.replace('</head>', `${injected}</head>`) : `${injected}${body}`;
62
+ }
63
+ res.writeHead(upstream.status, responseHeaders);
64
+ res.end(body);
65
+ return;
66
+ }
67
+
68
+ res.writeHead(upstream.status, responseHeaders);
69
+ res.end(Buffer.from(await upstream.arrayBuffer()));
70
+ } catch (error) {
71
+ send(
72
+ res,
73
+ 502,
74
+ `Could not load ${target.href}\n\n${error.message}\n\nStart the dev server with: site dev`,
75
+ );
76
+ }
77
+ }
78
+
79
+ return { proxy };
80
+ }
package/src/server.mjs ADDED
@@ -0,0 +1,171 @@
1
+ import http from 'node:http';
2
+ import https from 'node:https';
3
+ import fs from 'node:fs';
4
+
5
+ import { send, readBody } from './http.mjs';
6
+ import { createNotes } from './notes.mjs';
7
+ import { createProxy } from './proxy.mjs';
8
+ import { assets, renderViewer, VIEWER_VERSION } from './viewer.mjs';
9
+
10
+ function sendAsset(res, body, type) {
11
+ if (!body) return send(res, 404, 'not found');
12
+ res.writeHead(200, { 'Content-Type': type, 'Cache-Control': 'max-age=86400' });
13
+ res.end(body);
14
+ }
15
+
16
+ function json(res, status, body) {
17
+ send(res, status, JSON.stringify(body), 'application/json; charset=utf-8');
18
+ }
19
+
20
+ function authorized(req, session) {
21
+ const auth = req.headers.authorization || '';
22
+ if (auth !== `Bearer ${session.token}`) return false;
23
+ const referer = req.headers.referer || '';
24
+ if (!referer) return true;
25
+ try {
26
+ const pathname = new URL(referer).pathname;
27
+ return !pathname.startsWith('/__dev') && !pathname.startsWith('/__live');
28
+ } catch {
29
+ return false;
30
+ }
31
+ }
32
+
33
+ export function createServer(config, tls, session, { control = true, side: frameSide } = {}) {
34
+ const { devBase, liveBase, vaultDir } = config;
35
+ const notes = createNotes(config);
36
+ const { proxy } = createProxy(config);
37
+
38
+ const handler = async (req, res) => {
39
+ try {
40
+ const hostname = new URL(`http://${req.headers.host}`).hostname.replace(/^\[|\]$/g, '');
41
+ if (hostname !== config.host) {
42
+ send(res, 421, 'misdirected request');
43
+ return;
44
+ }
45
+ } catch {
46
+ send(res, 400, 'invalid host');
47
+ return;
48
+ }
49
+ const requestUrl = new URL(req.url || '/', `http://${config.host}:${config.port}`);
50
+ const { pathname } = requestUrl;
51
+
52
+ const isNotes = pathname === '/notes' || pathname === '/api/v1/notes';
53
+ const isSave = pathname === '/notes/save' || pathname === '/api/v1/notes/save';
54
+
55
+ if (!control && frameSide && !pathname.startsWith(`/__${frameSide}`)) {
56
+ const referer = req.headers.referer || '';
57
+ if (referer.includes(`/__${frameSide}/`)) {
58
+ requestUrl.pathname = `/__${frameSide}${pathname}`;
59
+ await proxy(req, res, frameSide, requestUrl);
60
+ } else {
61
+ send(res, 404, 'not found');
62
+ }
63
+ return;
64
+ }
65
+
66
+ if (!control && !pathname.startsWith('/__dev') && !pathname.startsWith('/__live')) {
67
+ const referer = req.headers.referer || '';
68
+ if (referer.includes('/__dev/')) {
69
+ requestUrl.pathname = `/__dev${pathname}`;
70
+ await proxy(req, res, 'dev', requestUrl);
71
+ } else if (referer.includes('/__live/')) {
72
+ requestUrl.pathname = `/__live${pathname}`;
73
+ await proxy(req, res, 'live', requestUrl);
74
+ } else {
75
+ send(res, 404, 'not found');
76
+ }
77
+ } else if (pathname === '/health') {
78
+ send(res, 200, JSON.stringify({
79
+ dev: devBase.href.replace(/\/$/, ''),
80
+ live: liveBase.href.replace(/\/$/, ''),
81
+ version: VIEWER_VERSION,
82
+ }), 'application/json; charset=utf-8');
83
+ } else if (pathname === '/api/v1/session') {
84
+ if (!authorized(req, session)) {
85
+ json(res, 401, { error: 'unauthorized' });
86
+ } else {
87
+ json(res, 200, {
88
+ session: {
89
+ version: session.version,
90
+ url: session.url,
91
+ dev: session.dev,
92
+ live: session.live,
93
+ notesFile: session.notesFile,
94
+ startedAt: session.startedAt,
95
+ },
96
+ capabilities: ['notes:list', 'notes:add', 'notes:resolve', 'notes:reopen', 'notes:remove', 'notes:clear'],
97
+ notes: notes.load(),
98
+ });
99
+ }
100
+ } else if (isNotes) {
101
+ if (!authorized(req, session)) {
102
+ json(res, 401, { error: 'unauthorized' });
103
+ return;
104
+ }
105
+ if (req.method === 'GET') {
106
+ json(res, 200, { notes: notes.load() });
107
+ } else if (req.method === 'POST') {
108
+ // Require a JSON content-type so cross-origin writes need a preflight the
109
+ // server (no CORS headers) will fail — closes the text/plain CSRF path.
110
+ if (!(req.headers['content-type'] || '').includes('application/json')) {
111
+ json(res, 415, { error: 'notes require Content-Type: application/json' });
112
+ } else {
113
+ try {
114
+ const op = JSON.parse((await readBody(req)) || '{}');
115
+ json(res, 200, { notes: notes.applyOp(op) });
116
+ } catch (error) {
117
+ json(res, 400, { error: error.message });
118
+ }
119
+ }
120
+ } else {
121
+ send(res, 405, 'method not allowed');
122
+ }
123
+ } else if (pathname === '/notes.md') {
124
+ send(res, 200, notes.markdown(notes.load()), 'text/markdown; charset=utf-8');
125
+ } else if (isSave) {
126
+ if (!authorized(req, session)) {
127
+ json(res, 401, { error: 'unauthorized' });
128
+ return;
129
+ }
130
+ if (req.method !== 'POST') {
131
+ send(res, 405, 'method not allowed');
132
+ } else if (!vaultDir) {
133
+ json(res, 400, { ok: false, error: 'no vault configured' });
134
+ } else {
135
+ try {
136
+ const stamp = new Date().toISOString().slice(0, 16).replace(/[:T]/g, '-');
137
+ const file = `${vaultDir}/sitedrift-review-${stamp}.md`;
138
+ fs.writeFileSync(file, notes.markdown(notes.load()));
139
+ json(res, 200, { ok: true, path: file });
140
+ } catch (error) {
141
+ json(res, 500, { ok: false, error: error.message });
142
+ }
143
+ }
144
+ } else if (pathname === '/icon.svg') {
145
+ sendAsset(res, assets.icon, 'image/svg+xml; charset=utf-8');
146
+ } else if (pathname === '/viewer.css') {
147
+ sendAsset(res, assets.css, 'text/css; charset=utf-8');
148
+ } else if (pathname === '/viewer.js') {
149
+ sendAsset(res, assets.js, 'text/javascript; charset=utf-8');
150
+ } else if (pathname.startsWith('/__dev')) {
151
+ await proxy(req, res, 'dev', requestUrl);
152
+ } else if (pathname.startsWith('/__live')) {
153
+ await proxy(req, res, 'live', requestUrl);
154
+ } else {
155
+ // A resource requested by a proxied page (no /__side prefix) is routed by
156
+ // its referer; everything else is the viewer shell.
157
+ const referer = req.headers.referer || '';
158
+ if (referer.includes('/__dev/')) {
159
+ requestUrl.pathname = `/__dev${pathname}`;
160
+ await proxy(req, res, 'dev', requestUrl);
161
+ } else if (referer.includes('/__live/')) {
162
+ requestUrl.pathname = `/__live${pathname}`;
163
+ await proxy(req, res, 'live', requestUrl);
164
+ } else {
165
+ send(res, 200, renderViewer(config, session), 'text/html; charset=utf-8');
166
+ }
167
+ }
168
+ };
169
+
170
+ return tls ? https.createServer(tls, handler) : http.createServer(handler);
171
+ }