stikfix 1.2.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/LICENSE +21 -0
- package/README.md +187 -0
- package/dist/host/src/bind.js +69 -0
- package/dist/host/src/bootstrap/register.js +556 -0
- package/dist/host/src/config.js +147 -0
- package/dist/host/src/extension-id.js +49 -0
- package/dist/host/src/folder-picker.js +142 -0
- package/dist/host/src/index.js +110 -0
- package/dist/host/src/native-host.js +143 -0
- package/dist/host/src/native-msg.js +100 -0
- package/dist/host/src/probe.js +62 -0
- package/dist/host/src/read-note.js +175 -0
- package/dist/host/src/security.js +76 -0
- package/dist/host/src/serial.js +32 -0
- package/dist/host/src/server.js +451 -0
- package/dist/host/src/types.js +5 -0
- package/dist/host/src/validate-folder.js +68 -0
- package/dist/host/src/write-note.js +158 -0
- package/dist/host/stikfix-init.cjs +500 -0
- package/dist/host/stikfix-native.cjs +257 -0
- package/package.json +66 -0
- package/public/icon/128.png +0 -0
- package/public/icon/16.png +0 -0
- package/public/icon/256.png +0 -0
- package/public/icon/32.png +0 -0
- package/public/icon/48.png +0 -0
- package/public/icon/stikfix.ico +0 -0
- package/public/icon/stikfix.svg +47 -0
|
@@ -0,0 +1,451 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP server factory for stikfix-host.
|
|
3
|
+
* D-05/HOST-10: CORS echo-Origin + Access-Control-Allow-Private-Network on every response
|
|
4
|
+
* D-06/HOST-04: GET /status no-token, no secrets
|
|
5
|
+
* D-05/HOST-05: POST /annotation token-gated via checkToken
|
|
6
|
+
* D-04/HOST-11: readBody 12 MB cap -> 413; JSON.parse -> 400
|
|
7
|
+
* D-03/HOST-06: withSerialLock(getNextSerial + writeNote) for atomic serial assignment
|
|
8
|
+
* Pitfall 6: setCorsHeaders called at top of handler — before any response write
|
|
9
|
+
*/
|
|
10
|
+
import * as http from 'node:http';
|
|
11
|
+
import { readFile } from 'node:fs/promises';
|
|
12
|
+
import { join, basename } from 'node:path';
|
|
13
|
+
import { VERSION, ensureNotesDir } from './config.js';
|
|
14
|
+
import { checkToken, readBody, isInsideDir } from './security.js';
|
|
15
|
+
import { withSerialLock, getNextSerial } from './serial.js';
|
|
16
|
+
import { writeNote } from './write-note.js';
|
|
17
|
+
import { listAnnotations, editNote, deleteNote } from './read-note.js';
|
|
18
|
+
import { validateChosenFolder } from './validate-folder.js';
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
// Per-request notes-dir resolution (D-04 — targetDir support)
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
/**
|
|
23
|
+
* Marker error mapped to HTTP 400 by every handler. Thrown by resolveNotesDir
|
|
24
|
+
* when a supplied targetDir fails validation — so an invalid/system targetDir
|
|
25
|
+
* NEVER results in a write outside the validated folder (T-09-14b).
|
|
26
|
+
*/
|
|
27
|
+
class InvalidTargetDirError extends Error {
|
|
28
|
+
statusCode = 400;
|
|
29
|
+
constructor() {
|
|
30
|
+
super('Invalid target folder');
|
|
31
|
+
this.name = 'InvalidTargetDirError';
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Resolve the notes directory for a single request (D-04).
|
|
36
|
+
*
|
|
37
|
+
* Security (T-09-14b): every targetDir is RE-VALIDATED here, server-side, on
|
|
38
|
+
* EVERY request via the shared validateChosenFolder (absolute + exists +
|
|
39
|
+
* isDirectory + system-dir deny-list). A valid targetDir confines the write to
|
|
40
|
+
* <validated>/notes (created on demand). This extends the Phase 8 "confined
|
|
41
|
+
* writes" invariant from a single --root to any user-chosen, validated folder.
|
|
42
|
+
*
|
|
43
|
+
* Back-compat: when targetDir is absent/empty, returns cfg.notesDir UNCHANGED —
|
|
44
|
+
* the existing --root flow is byte-for-byte identical to today (no regression).
|
|
45
|
+
*
|
|
46
|
+
* @throws InvalidTargetDirError (→ HTTP 400) when a non-empty targetDir is invalid.
|
|
47
|
+
*/
|
|
48
|
+
function resolveNotesDir(cfg, targetDir) {
|
|
49
|
+
if (typeof targetDir === 'string' && targetDir.length > 0) {
|
|
50
|
+
const valid = validateChosenFolder(targetDir);
|
|
51
|
+
if (!valid)
|
|
52
|
+
throw new InvalidTargetDirError();
|
|
53
|
+
const dir = join(valid, 'notes');
|
|
54
|
+
ensureNotesDir(dir);
|
|
55
|
+
return dir;
|
|
56
|
+
}
|
|
57
|
+
// No targetDir → identical behavior to today (back-compat / --root default).
|
|
58
|
+
return cfg.notesDir;
|
|
59
|
+
}
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
// CORS helpers (Pattern 4)
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
/**
|
|
64
|
+
* Set base CORS headers on every response — echoes the request Origin.
|
|
65
|
+
* Must be called FIRST in every route handler, before any writeHead/end (Pitfall 6).
|
|
66
|
+
*/
|
|
67
|
+
function setCorsHeaders(req, res) {
|
|
68
|
+
const origin = req.headers.origin ?? '*';
|
|
69
|
+
res.setHeader('Access-Control-Allow-Origin', origin);
|
|
70
|
+
res.setHeader('Access-Control-Allow-Private-Network', 'true');
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Add OPTIONS preflight-specific headers (called after setCorsHeaders for OPTIONS requests).
|
|
74
|
+
*/
|
|
75
|
+
function setPreflightHeaders(req, res) {
|
|
76
|
+
setCorsHeaders(req, res);
|
|
77
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
|
|
78
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, X-Stikfix-Token');
|
|
79
|
+
res.setHeader('Access-Control-Max-Age', '86400');
|
|
80
|
+
}
|
|
81
|
+
// ---------------------------------------------------------------------------
|
|
82
|
+
// Route handlers
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
function handleStatus(req, res, cfg) {
|
|
85
|
+
setCorsHeaders(req, res);
|
|
86
|
+
const body = JSON.stringify({
|
|
87
|
+
app: 'stikfix',
|
|
88
|
+
version: VERSION,
|
|
89
|
+
name: cfg.name,
|
|
90
|
+
root: cfg.root,
|
|
91
|
+
notesDir: cfg.notesDir,
|
|
92
|
+
origins: cfg.origins,
|
|
93
|
+
});
|
|
94
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
95
|
+
res.end(body);
|
|
96
|
+
}
|
|
97
|
+
function handleOptions(req, res) {
|
|
98
|
+
setPreflightHeaders(req, res);
|
|
99
|
+
res.writeHead(204);
|
|
100
|
+
res.end();
|
|
101
|
+
}
|
|
102
|
+
async function handleAnnotation(req, res, cfg) {
|
|
103
|
+
// 1. CORS on every response path (Pitfall 6)
|
|
104
|
+
setCorsHeaders(req, res);
|
|
105
|
+
// 2. Token auth (HOST-05)
|
|
106
|
+
if (!checkToken(req, cfg.token)) {
|
|
107
|
+
res.writeHead(401, { 'Content-Type': 'application/json' });
|
|
108
|
+
res.end(JSON.stringify({ ok: false, error: 'unauthorized' }));
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
// 3. Read body with size cap (HOST-11)
|
|
112
|
+
let raw;
|
|
113
|
+
try {
|
|
114
|
+
raw = await readBody(req);
|
|
115
|
+
}
|
|
116
|
+
catch (e) {
|
|
117
|
+
const err = e;
|
|
118
|
+
const status = err.statusCode === 413 ? 413 : 400;
|
|
119
|
+
res.writeHead(status, { 'Content-Type': 'application/json' });
|
|
120
|
+
res.end(JSON.stringify({ ok: false, error: err.message ?? 'read error' }));
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
// 4. Parse JSON (D-04)
|
|
124
|
+
let payload;
|
|
125
|
+
try {
|
|
126
|
+
payload = JSON.parse(raw);
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
130
|
+
res.end(JSON.stringify({ ok: false, error: 'invalid JSON' }));
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
// 4b. WR-02: Runtime payload shape guard before reaching the disk-write path.
|
|
134
|
+
// buildFrontmatter/buildNoteBody dereference page.url, viewport.width, etc.
|
|
135
|
+
// unconditionally — a missing field causes a TypeError (500). Validate first.
|
|
136
|
+
if ((payload.mode !== 'free' && payload.mode !== 'element') ||
|
|
137
|
+
typeof payload.comment !== 'string' ||
|
|
138
|
+
!payload.page || typeof payload.page.url !== 'string' || typeof payload.page.title !== 'string' ||
|
|
139
|
+
!payload.viewport ||
|
|
140
|
+
typeof payload.viewport.width !== 'number' ||
|
|
141
|
+
typeof payload.viewport.height !== 'number' ||
|
|
142
|
+
typeof payload.viewport.devicePixelRatio !== 'number') {
|
|
143
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
144
|
+
res.end(JSON.stringify({ ok: false, error: 'invalid payload' }));
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
// 5. Resolve the per-request notes dir (D-04). An invalid/system targetDir
|
|
148
|
+
// throws InvalidTargetDirError → 400 BEFORE any serial is burned or file
|
|
149
|
+
// is written. Absent targetDir → cfg.notesDir (back-compat, no regression).
|
|
150
|
+
let notesDir;
|
|
151
|
+
try {
|
|
152
|
+
notesDir = resolveNotesDir(cfg, payload.targetDir);
|
|
153
|
+
}
|
|
154
|
+
catch (e) {
|
|
155
|
+
const err = e;
|
|
156
|
+
const status = err.statusCode === 400 ? 400 : 500;
|
|
157
|
+
res.writeHead(status, { 'Content-Type': 'application/json' });
|
|
158
|
+
res.end(JSON.stringify({ ok: false, error: err.message ?? 'invalid target folder' }));
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
// 6. Write under serial lock (D-03 / Pitfall 3).
|
|
162
|
+
// Writes are confined to <notesDir> (= cfg.notesDir OR <validated targetDir>/notes).
|
|
163
|
+
// targetDir is NOT persisted into the note frontmatter (routing-only field).
|
|
164
|
+
try {
|
|
165
|
+
const { file, serial } = await withSerialLock(async () => {
|
|
166
|
+
const serialNum = getNextSerial(notesDir);
|
|
167
|
+
return writeNote(notesDir, payload, serialNum);
|
|
168
|
+
});
|
|
169
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
170
|
+
res.end(JSON.stringify({ ok: true, file, serial }));
|
|
171
|
+
}
|
|
172
|
+
catch (e) {
|
|
173
|
+
// CR-02: propagate statusCode from write-phase errors (e.g. bad screenshot → 400)
|
|
174
|
+
const err = e;
|
|
175
|
+
const status = (typeof err.statusCode === 'number' && err.statusCode >= 400 && err.statusCode < 600)
|
|
176
|
+
? err.statusCode
|
|
177
|
+
: 500;
|
|
178
|
+
res.writeHead(status, { 'Content-Type': 'application/json' });
|
|
179
|
+
res.end(JSON.stringify({ ok: false, error: err.message ?? 'internal error' }));
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
// ---------------------------------------------------------------------------
|
|
183
|
+
// Phase 6 handlers: GET /annotations, PUT /annotation/<serial>, DELETE /annotation/<serial>
|
|
184
|
+
// HOST-14/15/16
|
|
185
|
+
// ---------------------------------------------------------------------------
|
|
186
|
+
/**
|
|
187
|
+
* GET /annotations?url=<page-url>
|
|
188
|
+
* Returns all notes whose URL path matches the given page URL (D-02).
|
|
189
|
+
* Token-gated (T-06-04). CORS headers first (Pitfall 6).
|
|
190
|
+
*/
|
|
191
|
+
async function handleListAnnotations(req, res, cfg) {
|
|
192
|
+
setCorsHeaders(req, res);
|
|
193
|
+
if (!checkToken(req, cfg.token)) {
|
|
194
|
+
res.writeHead(401, { 'Content-Type': 'application/json' });
|
|
195
|
+
res.end(JSON.stringify({ ok: false, error: 'unauthorized' }));
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
try {
|
|
199
|
+
// Parse ?url= and ?targetDir= query parameters (path already stripped of query)
|
|
200
|
+
const params = new URL(req.url ?? '/', 'http://x').searchParams;
|
|
201
|
+
const pageUrl = params.get('url') ?? '';
|
|
202
|
+
// D-04: re-validate targetDir per request; absent → cfg.notesDir (back-compat)
|
|
203
|
+
const notesDir = resolveNotesDir(cfg, params.get('targetDir') ?? undefined);
|
|
204
|
+
// ?done=1 → include archived (*.read.md / status:read) notes (panel-only opt-in)
|
|
205
|
+
const includeDone = params.get('done') === '1';
|
|
206
|
+
// ?scope=all → project-wide listing (notes from every page, not just pageUrl)
|
|
207
|
+
const pins = params.get('scope') === 'all'
|
|
208
|
+
? listAnnotations(notesDir, pageUrl, { allUrls: true, includeDone })
|
|
209
|
+
: listAnnotations(notesDir, pageUrl, { includeDone });
|
|
210
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
211
|
+
res.end(JSON.stringify({ ok: true, pins }));
|
|
212
|
+
}
|
|
213
|
+
catch (e) {
|
|
214
|
+
const err = e;
|
|
215
|
+
const status = (typeof err.statusCode === 'number' && err.statusCode >= 400 && err.statusCode < 600)
|
|
216
|
+
? err.statusCode : 500;
|
|
217
|
+
res.writeHead(status, { 'Content-Type': 'application/json' });
|
|
218
|
+
res.end(JSON.stringify({ ok: false, error: err.message ?? 'internal error' }));
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* PUT /annotation/<serial>
|
|
223
|
+
* Overwrites the note body in place; preserves frontmatter + screenshots; re-marks status:unread.
|
|
224
|
+
* Reads body via existing readBody (12 MB cap → 413, HOST-11 reuse).
|
|
225
|
+
* Token-gated (T-06-01). path-confined via editNote (T-06-02).
|
|
226
|
+
*/
|
|
227
|
+
async function handleEditAnnotation(req, res, cfg, serial) {
|
|
228
|
+
setCorsHeaders(req, res);
|
|
229
|
+
if (!checkToken(req, cfg.token)) {
|
|
230
|
+
res.writeHead(401, { 'Content-Type': 'application/json' });
|
|
231
|
+
res.end(JSON.stringify({ ok: false, error: 'unauthorized' }));
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
// Read body with 12 MB cap (HOST-11 invariant reuse, T-06-03)
|
|
235
|
+
let raw;
|
|
236
|
+
try {
|
|
237
|
+
raw = await readBody(req);
|
|
238
|
+
}
|
|
239
|
+
catch (e) {
|
|
240
|
+
const err = e;
|
|
241
|
+
const status = err.statusCode === 413 ? 413 : 400;
|
|
242
|
+
res.writeHead(status, { 'Content-Type': 'application/json' });
|
|
243
|
+
res.end(JSON.stringify({ ok: false, error: err.message ?? 'read error' }));
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
try {
|
|
247
|
+
let parsed;
|
|
248
|
+
try {
|
|
249
|
+
parsed = JSON.parse(raw);
|
|
250
|
+
}
|
|
251
|
+
catch {
|
|
252
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
253
|
+
res.end(JSON.stringify({ ok: false, error: 'invalid JSON' }));
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
const comment = parsed.comment ?? '';
|
|
257
|
+
// D-04: re-validate targetDir per request; absent → cfg.notesDir (back-compat)
|
|
258
|
+
const targetDir = new URL(req.url ?? '/', 'http://x').searchParams.get('targetDir') ?? undefined;
|
|
259
|
+
const notesDir = resolveNotesDir(cfg, targetDir);
|
|
260
|
+
await editNote(notesDir, serial, comment);
|
|
261
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
262
|
+
res.end(JSON.stringify({ ok: true }));
|
|
263
|
+
}
|
|
264
|
+
catch (e) {
|
|
265
|
+
const err = e;
|
|
266
|
+
const status = (typeof err.statusCode === 'number' && err.statusCode >= 400 && err.statusCode < 600)
|
|
267
|
+
? err.statusCode : 500;
|
|
268
|
+
res.writeHead(status, { 'Content-Type': 'application/json' });
|
|
269
|
+
res.end(JSON.stringify({ ok: false, error: err.message ?? 'internal error' }));
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
/**
|
|
273
|
+
* DELETE /annotation/<serial>
|
|
274
|
+
* Removes the .md file and its +N.png screenshots (D-05/D-06).
|
|
275
|
+
* Token-gated (T-06-01). path-confined via deleteNote (T-06-02).
|
|
276
|
+
*/
|
|
277
|
+
async function handleDeleteAnnotation(req, res, cfg, serial) {
|
|
278
|
+
setCorsHeaders(req, res);
|
|
279
|
+
if (!checkToken(req, cfg.token)) {
|
|
280
|
+
res.writeHead(401, { 'Content-Type': 'application/json' });
|
|
281
|
+
res.end(JSON.stringify({ ok: false, error: 'unauthorized' }));
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
try {
|
|
285
|
+
// D-04: re-validate targetDir per request; absent → cfg.notesDir (back-compat)
|
|
286
|
+
const targetDir = new URL(req.url ?? '/', 'http://x').searchParams.get('targetDir') ?? undefined;
|
|
287
|
+
const notesDir = resolveNotesDir(cfg, targetDir);
|
|
288
|
+
await deleteNote(notesDir, serial);
|
|
289
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
290
|
+
res.end(JSON.stringify({ ok: true }));
|
|
291
|
+
}
|
|
292
|
+
catch (e) {
|
|
293
|
+
const err = e;
|
|
294
|
+
const status = (typeof err.statusCode === 'number' && err.statusCode >= 400 && err.statusCode < 600)
|
|
295
|
+
? err.statusCode : 500;
|
|
296
|
+
res.writeHead(status, { 'Content-Type': 'application/json' });
|
|
297
|
+
res.end(JSON.stringify({ ok: false, error: err.message ?? 'internal error' }));
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
/**
|
|
301
|
+
* GET /screenshot?serial=<serial>&file=<basename.png>
|
|
302
|
+
* Serves a screenshot PNG file from notesDir as image/png bytes.
|
|
303
|
+
*
|
|
304
|
+
* Security model:
|
|
305
|
+
* - Token-gated via checkToken (same as all other read verbs) — prevents
|
|
306
|
+
* info-disclosure from unauthenticated callers (T-06-04 class).
|
|
307
|
+
* - Path-traversal guard (T-06-02 class): `file` param must be a plain
|
|
308
|
+
* basename (no `/`, `\`, or `..`), MUST start with `<serial>` (ties the
|
|
309
|
+
* file to the note being requested), MUST end with `.png`.
|
|
310
|
+
* The resolved absolute path is then verified via isInsideDir before read.
|
|
311
|
+
* - No directory listing: only a single named file is ever returned.
|
|
312
|
+
* - SW is the sole HTTP client — this endpoint is not called from page context.
|
|
313
|
+
*/
|
|
314
|
+
async function handleGetScreenshot(req, res, cfg) {
|
|
315
|
+
// CORS first (Pitfall 6)
|
|
316
|
+
setCorsHeaders(req, res);
|
|
317
|
+
// Token gate (T-06-04)
|
|
318
|
+
if (!checkToken(req, cfg.token)) {
|
|
319
|
+
res.writeHead(401, { 'Content-Type': 'application/json' });
|
|
320
|
+
res.end(JSON.stringify({ ok: false, error: 'unauthorized' }));
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
const params = new URL(req.url ?? '/', 'http://x').searchParams;
|
|
324
|
+
const serial = params.get('serial') ?? '';
|
|
325
|
+
const file = params.get('file') ?? '';
|
|
326
|
+
// Basename guard: reject any path separator or traversal component (T-06-02)
|
|
327
|
+
if (!serial ||
|
|
328
|
+
!file ||
|
|
329
|
+
file.includes('/') ||
|
|
330
|
+
file.includes('\\') ||
|
|
331
|
+
file.includes('..') ||
|
|
332
|
+
!file.startsWith(serial) || // ties file to the specific note serial
|
|
333
|
+
!file.endsWith('.png')) {
|
|
334
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
335
|
+
res.end(JSON.stringify({ ok: false, error: 'invalid file parameter' }));
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
// D-04: re-validate targetDir per request; absent → cfg.notesDir (back-compat).
|
|
339
|
+
// An invalid/system targetDir → 400 (no file served outside the validated dir).
|
|
340
|
+
let notesDir;
|
|
341
|
+
try {
|
|
342
|
+
notesDir = resolveNotesDir(cfg, params.get('targetDir') ?? undefined);
|
|
343
|
+
}
|
|
344
|
+
catch (e) {
|
|
345
|
+
const err = e;
|
|
346
|
+
const status = err.statusCode === 400 ? 400 : 500;
|
|
347
|
+
res.writeHead(status, { 'Content-Type': 'application/json' });
|
|
348
|
+
res.end(JSON.stringify({ ok: false, error: err.message ?? 'invalid target folder' }));
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
// Reconstruct absolute path and verify confinement against the RESOLVED dir (T-06-02)
|
|
352
|
+
const resolved = join(notesDir, basename(file));
|
|
353
|
+
if (!isInsideDir(notesDir, resolved)) {
|
|
354
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
355
|
+
res.end(JSON.stringify({ ok: false, error: 'forbidden' }));
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
let bytes;
|
|
359
|
+
try {
|
|
360
|
+
bytes = await readFile(resolved);
|
|
361
|
+
}
|
|
362
|
+
catch {
|
|
363
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
364
|
+
res.end(JSON.stringify({ ok: false, error: 'not found' }));
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
res.writeHead(200, { 'Content-Type': 'image/png', 'Content-Length': String(bytes.length) });
|
|
368
|
+
res.end(bytes);
|
|
369
|
+
}
|
|
370
|
+
// ---------------------------------------------------------------------------
|
|
371
|
+
// Server factory (D-01: createHostServer — does NOT call listen)
|
|
372
|
+
// ---------------------------------------------------------------------------
|
|
373
|
+
/**
|
|
374
|
+
* Create a configured http.Server with /status, OPTIONS, POST /annotation routing.
|
|
375
|
+
* Does NOT call server.listen — index.ts owns port binding (Pattern 1).
|
|
376
|
+
*/
|
|
377
|
+
export function createHostServer(cfg) {
|
|
378
|
+
const server = http.createServer((req, res) => {
|
|
379
|
+
const method = req.method ?? 'GET';
|
|
380
|
+
// WR-01: match on the path only — strip query string so /status?x=1 still routes correctly
|
|
381
|
+
const path = (req.url ?? '/').split('?', 1)[0];
|
|
382
|
+
if (method === 'OPTIONS') {
|
|
383
|
+
handleOptions(req, res);
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
if (method === 'GET' && path === '/status') {
|
|
387
|
+
handleStatus(req, res, cfg);
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
if (method === 'POST' && path === '/annotation') {
|
|
391
|
+
handleAnnotation(req, res, cfg).catch((e) => {
|
|
392
|
+
// Last-resort: if handler itself threw unexpectedly and response not started
|
|
393
|
+
if (!res.headersSent) {
|
|
394
|
+
setCorsHeaders(req, res);
|
|
395
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
396
|
+
res.end(JSON.stringify({ ok: false, error: String(e) }));
|
|
397
|
+
}
|
|
398
|
+
});
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
// Phase 6 routes: GET /annotations, PUT/DELETE /annotation/<serial>
|
|
402
|
+
if (method === 'GET' && path === '/annotations') {
|
|
403
|
+
handleListAnnotations(req, res, cfg).catch((e) => {
|
|
404
|
+
if (!res.headersSent) {
|
|
405
|
+
setCorsHeaders(req, res);
|
|
406
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
407
|
+
res.end(JSON.stringify({ ok: false, error: String(e) }));
|
|
408
|
+
}
|
|
409
|
+
});
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
if (method === 'PUT' && path.startsWith('/annotation/')) {
|
|
413
|
+
const serial = path.slice('/annotation/'.length);
|
|
414
|
+
handleEditAnnotation(req, res, cfg, serial).catch((e) => {
|
|
415
|
+
if (!res.headersSent) {
|
|
416
|
+
setCorsHeaders(req, res);
|
|
417
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
418
|
+
res.end(JSON.stringify({ ok: false, error: String(e) }));
|
|
419
|
+
}
|
|
420
|
+
});
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
if (method === 'DELETE' && path.startsWith('/annotation/')) {
|
|
424
|
+
const serial = path.slice('/annotation/'.length);
|
|
425
|
+
handleDeleteAnnotation(req, res, cfg, serial).catch((e) => {
|
|
426
|
+
if (!res.headersSent) {
|
|
427
|
+
setCorsHeaders(req, res);
|
|
428
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
429
|
+
res.end(JSON.stringify({ ok: false, error: String(e) }));
|
|
430
|
+
}
|
|
431
|
+
});
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
// GET /screenshot?serial=&file= — token-gated PNG file serve (FIX-1 UAT)
|
|
435
|
+
if (method === 'GET' && path === '/screenshot') {
|
|
436
|
+
handleGetScreenshot(req, res, cfg).catch((e) => {
|
|
437
|
+
if (!res.headersSent) {
|
|
438
|
+
setCorsHeaders(req, res);
|
|
439
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
440
|
+
res.end(JSON.stringify({ ok: false, error: String(e) }));
|
|
441
|
+
}
|
|
442
|
+
});
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
// 404 for all other routes (CORS headers still required — Pitfall 6)
|
|
446
|
+
setCorsHeaders(req, res);
|
|
447
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
448
|
+
res.end(JSON.stringify({ ok: false, error: 'not found' }));
|
|
449
|
+
});
|
|
450
|
+
return server;
|
|
451
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared folder-picker / target-folder validation (T-09-14 / T-09-14b).
|
|
3
|
+
*
|
|
4
|
+
* Single source of truth used by BOTH:
|
|
5
|
+
* - the native host (native-host.ts) when validating a freshly-picked folder, and
|
|
6
|
+
* - the HTTP server (server.ts) when re-validating an optional per-request
|
|
7
|
+
* targetDir before confining a note write to <targetDir>/notes.
|
|
8
|
+
*
|
|
9
|
+
* Node builtins only — no Chrome/WXT imports.
|
|
10
|
+
*/
|
|
11
|
+
import { existsSync, statSync } from 'node:fs';
|
|
12
|
+
import { isAbsolute, resolve } from 'node:path';
|
|
13
|
+
/**
|
|
14
|
+
* Sensitive system directories that must NEVER become a note-write root.
|
|
15
|
+
* Compared against a resolved, normalized chosen path (case-insensitive on
|
|
16
|
+
* win32). If the chosen folder equals one of these roots, the pick is rejected.
|
|
17
|
+
*/
|
|
18
|
+
const SYSTEM_DIRS_NIX = ['/', '/System', '/usr', '/etc'];
|
|
19
|
+
const SYSTEM_DIRS_WIN = ['C:\\Windows', 'C:\\Program Files'];
|
|
20
|
+
/**
|
|
21
|
+
* Validate a folder before it becomes a note root (T-09-14 / T-09-14b).
|
|
22
|
+
*
|
|
23
|
+
* IMPORTANT — why isInsideDir does NOT apply here:
|
|
24
|
+
* The chosen folder is a *brand-new* note root selected by the user, NOT a
|
|
25
|
+
* child of any pre-existing root. The `isInsideDir(root, target)` confinement
|
|
26
|
+
* guard asserts that `target` lives inside an already-established `root`; there
|
|
27
|
+
* is no such pre-existing root at folder-pick time. Do not "fix" this by adding
|
|
28
|
+
* an isInsideDir call against the wrong base — that would reject every valid
|
|
29
|
+
* pick. Instead we validate the path defensively on its own terms:
|
|
30
|
+
* (1) absolute, (2) exists, (3) is a directory, (4) not a sensitive system dir.
|
|
31
|
+
*
|
|
32
|
+
* Server-side reuse (D-04): the server re-runs this on EVERY request that
|
|
33
|
+
* carries a targetDir, then confines the write to <returned>/notes. An invalid
|
|
34
|
+
* or system targetDir returns null → the server maps that to HTTP 400 and
|
|
35
|
+
* writes nothing.
|
|
36
|
+
*
|
|
37
|
+
* @returns the validated absolute directory, or null if any check fails.
|
|
38
|
+
*/
|
|
39
|
+
export function validateChosenFolder(folder, plat = process.platform) {
|
|
40
|
+
// User cancelled / dialog unavailable / empty target — no folder chosen.
|
|
41
|
+
if (folder === null || typeof folder !== 'string' || folder.length === 0) {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
// (1) Must be absolute.
|
|
45
|
+
if (!isAbsolute(folder))
|
|
46
|
+
return null;
|
|
47
|
+
// (2) Must exist + (3) must be a directory.
|
|
48
|
+
try {
|
|
49
|
+
if (!existsSync(folder))
|
|
50
|
+
return null;
|
|
51
|
+
if (!statSync(folder).isDirectory())
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
// (4) Reject sensitive system directories. Normalize via resolve() and, on
|
|
58
|
+
// win32, compare case-insensitively (the filesystem is case-insensitive).
|
|
59
|
+
const normalized = resolve(folder);
|
|
60
|
+
const denyList = plat === 'win32' ? SYSTEM_DIRS_WIN : SYSTEM_DIRS_NIX;
|
|
61
|
+
const cmp = plat === 'win32' ? normalized.toLowerCase() : normalized;
|
|
62
|
+
for (const sysDir of denyList) {
|
|
63
|
+
const sysCmp = plat === 'win32' ? resolve(sysDir).toLowerCase() : resolve(sysDir);
|
|
64
|
+
if (cmp === sysCmp)
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
return normalized;
|
|
68
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Note file writer for stikfix-host.
|
|
3
|
+
* D-09/HOST-07: writes <serial>-<YYYYMMDD-HHmmss>.md with YAML frontmatter
|
|
4
|
+
* D-09/HOST-08: decodes PNG data-URLs to <base>+<N>.png next to the .md
|
|
5
|
+
* D-11: PRD §9.2 note format — frontmatter + comment + element context + screenshots
|
|
6
|
+
*/
|
|
7
|
+
import { writeFile } from 'node:fs/promises';
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
import { stringify as yamlStringify } from 'yaml';
|
|
10
|
+
const PNG_PREFIX = 'data:image/png;base64,';
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
// Public helpers (exported for unit tests)
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
/**
|
|
15
|
+
* Format local time as YYYYMMDD-HHmmss (Pattern 10).
|
|
16
|
+
* 15 characters including the dash.
|
|
17
|
+
*/
|
|
18
|
+
export function localTimestamp() {
|
|
19
|
+
const d = new Date();
|
|
20
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
21
|
+
return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}-` +
|
|
22
|
+
`${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`;
|
|
23
|
+
}
|
|
24
|
+
// 8-byte PNG file signature (ISO 15948 §12.1)
|
|
25
|
+
const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
26
|
+
/**
|
|
27
|
+
* Validate the data:image/png;base64, prefix, decode to a Buffer, and assert
|
|
28
|
+
* the 8-byte PNG magic signature (WR-04). Throws {statusCode:400} on any failure
|
|
29
|
+
* (wrong prefix, zero-length buffer, bad magic bytes).
|
|
30
|
+
*/
|
|
31
|
+
export function decodePngDataUrl(dataUrl) {
|
|
32
|
+
if (!dataUrl.startsWith(PNG_PREFIX)) {
|
|
33
|
+
throw Object.assign(new Error('Invalid screenshot mime: expected data:image/png;base64,'), { statusCode: 400 });
|
|
34
|
+
}
|
|
35
|
+
const buf = Buffer.from(dataUrl.slice(PNG_PREFIX.length), 'base64');
|
|
36
|
+
// WR-04: reject zero-length buffers and non-PNG magic bytes
|
|
37
|
+
if (buf.length < PNG_SIGNATURE.length) {
|
|
38
|
+
throw Object.assign(new Error('Invalid screenshot: decoded buffer is too small to be a PNG'), { statusCode: 400 });
|
|
39
|
+
}
|
|
40
|
+
if (!buf.subarray(0, PNG_SIGNATURE.length).equals(PNG_SIGNATURE)) {
|
|
41
|
+
throw Object.assign(new Error('Invalid screenshot: PNG magic bytes not found'), { statusCode: 400 });
|
|
42
|
+
}
|
|
43
|
+
return buf;
|
|
44
|
+
}
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
// Internal helpers
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
/**
|
|
49
|
+
* Build the YAML frontmatter block per PRD §9.2 (Pattern 8).
|
|
50
|
+
* selector + react_component ONLY for element mode (D-11).
|
|
51
|
+
* IN-01: `base` parameter removed (was unused).
|
|
52
|
+
*/
|
|
53
|
+
export function buildFrontmatter(payload, serial, screenshotRelPaths) {
|
|
54
|
+
const { mode, page, viewport, element } = payload;
|
|
55
|
+
const fm = {
|
|
56
|
+
id: serial,
|
|
57
|
+
created: new Date().toISOString(),
|
|
58
|
+
mode,
|
|
59
|
+
url: page.url,
|
|
60
|
+
title: page.title,
|
|
61
|
+
viewport: {
|
|
62
|
+
width: viewport.width,
|
|
63
|
+
height: viewport.height,
|
|
64
|
+
dpr: viewport.devicePixelRatio,
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
if (mode === 'element' && element) {
|
|
68
|
+
if (element.selector)
|
|
69
|
+
fm['selector'] = element.selector;
|
|
70
|
+
if (element.reactComponent)
|
|
71
|
+
fm['react_component'] = element.reactComponent;
|
|
72
|
+
// D-03: persist page-absolute rect for orphaned pin fallback (PIN-03)
|
|
73
|
+
if (element.rect)
|
|
74
|
+
fm['rect'] = element.rect;
|
|
75
|
+
}
|
|
76
|
+
// D-03: persist free-note viewport coords for pin floating position (PIN-02)
|
|
77
|
+
// CANONICAL KEY: note_position — matches listAnnotations read key (D-03)
|
|
78
|
+
if (mode === 'free' && payload.notePosition) {
|
|
79
|
+
fm['note_position'] = payload.notePosition;
|
|
80
|
+
}
|
|
81
|
+
fm['screenshots'] = screenshotRelPaths;
|
|
82
|
+
fm['status'] = 'unread';
|
|
83
|
+
return '---\n' + yamlStringify(fm) + '---\n';
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Build the Markdown body per PRD §9.2 RESEARCH "Note Body Building" example.
|
|
87
|
+
* Free notes: comment only + optional Screenshots section.
|
|
88
|
+
* Element notes: comment + ## Element context + computed styles table + outerHTML + Screenshots.
|
|
89
|
+
*/
|
|
90
|
+
export function buildNoteBody(base, payload) {
|
|
91
|
+
const { comment, element, screenshots } = payload;
|
|
92
|
+
const screenshotBasenames = (screenshots ?? []).map((_, i) => `${base}+${i + 1}.png`);
|
|
93
|
+
let body = `${comment ?? ''}\n`;
|
|
94
|
+
if (element) {
|
|
95
|
+
body += `\n## Element context\n\n`;
|
|
96
|
+
body += `- **Selector:** \`${element.selector}\`\n`;
|
|
97
|
+
if (element.reactComponent)
|
|
98
|
+
body += `- **React component:** \`${element.reactComponent}\`\n`;
|
|
99
|
+
body += `- **Tag / role:** \`${element.tag}\` / \`${element.role ?? element.tag}\``;
|
|
100
|
+
if (element.ariaLabel)
|
|
101
|
+
body += ` · **aria-label:** ${element.ariaLabel}`;
|
|
102
|
+
body += '\n';
|
|
103
|
+
if (element.text)
|
|
104
|
+
body += `- **Text:** ${element.text}\n`;
|
|
105
|
+
const r = element.rect;
|
|
106
|
+
if (r)
|
|
107
|
+
body += `- **Rect:** x=${r.x} y=${r.y} w=${r.width} h=${r.height}\n`;
|
|
108
|
+
if (element.computedStyles && Object.keys(element.computedStyles).length > 0) {
|
|
109
|
+
body += `\n### Computed styles (curated)\n| prop | value |\n|------|-------|\n`;
|
|
110
|
+
for (const [k, v] of Object.entries(element.computedStyles)) {
|
|
111
|
+
body += `| ${k} | ${v} |\n`;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
if (element.outerHTML) {
|
|
115
|
+
body += `\n### outerHTML (truncated)\n\`\`\`html\n${element.outerHTML}\n\`\`\`\n`;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
if (screenshotBasenames.length > 0) {
|
|
119
|
+
body += `\n### Screenshots\n`;
|
|
120
|
+
screenshotBasenames.forEach((p, i) => {
|
|
121
|
+
const kind = payload.screenshots?.[i]?.kind ?? `+${i + 1}`;
|
|
122
|
+
body += `\n`;
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
return body;
|
|
126
|
+
}
|
|
127
|
+
// ---------------------------------------------------------------------------
|
|
128
|
+
// Main export
|
|
129
|
+
// ---------------------------------------------------------------------------
|
|
130
|
+
/**
|
|
131
|
+
* Write a note to `notesDir` using the provided serial (caller holds the lock).
|
|
132
|
+
* Returns { file, serial } where file is the absolute .md path.
|
|
133
|
+
*
|
|
134
|
+
* The caller (server.ts) must call this inside withSerialLock so that
|
|
135
|
+
* getNextSerial → writeNote is atomic (Pitfall 3 / D-03).
|
|
136
|
+
*/
|
|
137
|
+
export async function writeNote(notesDir, payload, serial) {
|
|
138
|
+
const ts = localTimestamp();
|
|
139
|
+
const padded = String(serial).padStart(4, '0');
|
|
140
|
+
const base = `${padded}-${ts}`;
|
|
141
|
+
const mdPath = join(notesDir, `${base}.md`);
|
|
142
|
+
// CR-02: Decode/validate ALL screenshots into buffers BEFORE touching disk.
|
|
143
|
+
// A bad dataUrl throws {statusCode:400} here — before any file is written —
|
|
144
|
+
// so there is no partial on-disk state (no orphaned .md, no burned serial).
|
|
145
|
+
const pngBuffers = (payload.screenshots ?? []).map(s => decodePngDataUrl(s.dataUrl));
|
|
146
|
+
// Collect relative screenshot filenames for frontmatter
|
|
147
|
+
const screenshotRelPaths = pngBuffers.map((_, i) => `${base}+${i + 1}.png`);
|
|
148
|
+
// Build and write the .md file
|
|
149
|
+
const frontmatter = buildFrontmatter(payload, serial, screenshotRelPaths);
|
|
150
|
+
const body = buildNoteBody(base, payload);
|
|
151
|
+
await writeFile(mdPath, frontmatter + body, 'utf8');
|
|
152
|
+
// Write each decoded PNG buffer next to the .md
|
|
153
|
+
for (let i = 0; i < pngBuffers.length; i++) {
|
|
154
|
+
const pngPath = join(notesDir, `${base}+${i + 1}.png`);
|
|
155
|
+
await writeFile(pngPath, pngBuffers[i]);
|
|
156
|
+
}
|
|
157
|
+
return { file: mdPath, serial: padded };
|
|
158
|
+
}
|