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,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Native messaging stdio framing for stikfix.
|
|
3
|
+
* Implements the Chrome native messaging wire protocol:
|
|
4
|
+
* 4-byte UInt32LE JSON byte length + UTF-8 JSON body.
|
|
5
|
+
*
|
|
6
|
+
* ONB-02: framing must be lossless and chunk-safe.
|
|
7
|
+
* Pitfall 2: ALL stdout writes MUST use Buffer (never string) to avoid
|
|
8
|
+
* Windows text-mode \n→\r\n corruption of the binary length field.
|
|
9
|
+
*
|
|
10
|
+
* Node builtins only — no WXT, no Chrome imports.
|
|
11
|
+
*/
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
// encodeNativeMessage
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
/**
|
|
16
|
+
* Encode a message object into the Chrome native messaging wire format:
|
|
17
|
+
* [4 bytes: UInt32LE JSON byte length][N bytes: UTF-8 JSON]
|
|
18
|
+
*
|
|
19
|
+
* Returns a single Buffer containing both header and body (one alloc).
|
|
20
|
+
*/
|
|
21
|
+
export function encodeNativeMessage(msg) {
|
|
22
|
+
const json = JSON.stringify(msg);
|
|
23
|
+
const body = Buffer.from(json, 'utf8');
|
|
24
|
+
const header = Buffer.alloc(4);
|
|
25
|
+
header.writeUInt32LE(body.length, 0);
|
|
26
|
+
return Buffer.concat([header, body]);
|
|
27
|
+
}
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
// decodeNativeMessages
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
/**
|
|
32
|
+
* Decode zero or more complete native-messaging frames from `buf`.
|
|
33
|
+
*
|
|
34
|
+
* Returns:
|
|
35
|
+
* - `messages`: array of decoded objects for all complete frames found
|
|
36
|
+
* - `rest`: the unconsumed tail of `buf` (may be empty or a partial frame)
|
|
37
|
+
*
|
|
38
|
+
* Malformed JSON frames are swallowed (the buffer is advanced past them
|
|
39
|
+
* so a bad frame does not stall the reader — RESEARCH Pattern 2).
|
|
40
|
+
*/
|
|
41
|
+
export function decodeNativeMessages(buf) {
|
|
42
|
+
const messages = [];
|
|
43
|
+
let pos = 0;
|
|
44
|
+
while (buf.length - pos >= 4) {
|
|
45
|
+
const msgLen = buf.readUInt32LE(pos);
|
|
46
|
+
if (buf.length - pos < 4 + msgLen) {
|
|
47
|
+
// Frame is incomplete — stop here, return remainder
|
|
48
|
+
break;
|
|
49
|
+
}
|
|
50
|
+
const jsonSlice = buf.slice(pos + 4, pos + 4 + msgLen).toString('utf8');
|
|
51
|
+
pos += 4 + msgLen;
|
|
52
|
+
try {
|
|
53
|
+
messages.push(JSON.parse(jsonSlice));
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
// Malformed JSON — swallow and continue (do not stall reader)
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return { messages, rest: pos > 0 ? buf.slice(pos) : buf };
|
|
60
|
+
}
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
// sendNativeMessage
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
/**
|
|
65
|
+
* Write one native message to `out` (defaults to process.stdout).
|
|
66
|
+
*
|
|
67
|
+
* CRITICAL (Pitfall 2): writes the combined header+body as a single Buffer
|
|
68
|
+
* via Buffer.concat — NEVER passes a string to out.write(). On Windows,
|
|
69
|
+
* string writes go through text-mode I/O which translates \n → \r\n and
|
|
70
|
+
* corrupts the binary length prefix.
|
|
71
|
+
*/
|
|
72
|
+
export function sendNativeMessage(msg, out = process.stdout) {
|
|
73
|
+
out.write(encodeNativeMessage(msg));
|
|
74
|
+
}
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
// readNativeMessages
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
/**
|
|
79
|
+
* Attach to `inp` (defaults to process.stdin) and call `onMessage` for each
|
|
80
|
+
* fully decoded native-messaging frame received.
|
|
81
|
+
*
|
|
82
|
+
* Accumulates chunks into a module-local Buffer, draining complete frames via
|
|
83
|
+
* decodeNativeMessages after every data event. Calls process.exit(0) when
|
|
84
|
+
* stdin closes (per Chrome native messaging lifecycle — 'end' = Chrome closed
|
|
85
|
+
* the pipe, native host should terminate).
|
|
86
|
+
*/
|
|
87
|
+
export function readNativeMessages(onMessage, inp = process.stdin) {
|
|
88
|
+
let buf = Buffer.alloc(0);
|
|
89
|
+
inp.on('data', (chunk) => {
|
|
90
|
+
buf = Buffer.concat([buf, chunk]);
|
|
91
|
+
const { messages, rest } = decodeNativeMessages(buf);
|
|
92
|
+
buf = Buffer.from(rest);
|
|
93
|
+
for (const msg of messages) {
|
|
94
|
+
onMessage(msg);
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
inp.on('end', () => {
|
|
98
|
+
process.exit(0);
|
|
99
|
+
});
|
|
100
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Single-instance guard probe helper (FIX-SI).
|
|
3
|
+
*
|
|
4
|
+
* Extracted into its own module so tests can import probeExistingHost
|
|
5
|
+
* without triggering the top-level CLI code in index.ts.
|
|
6
|
+
*
|
|
7
|
+
* Builtins only: node:http, node:path. No new dependencies.
|
|
8
|
+
*/
|
|
9
|
+
import * as http from 'node:http';
|
|
10
|
+
import { resolve as resolvePath } from 'node:path';
|
|
11
|
+
import { BIND_HOST } from './bind.js';
|
|
12
|
+
/**
|
|
13
|
+
* Probe an existing host instance on the given port.
|
|
14
|
+
*
|
|
15
|
+
* Returns { port } if a live stikfix-host is running for the same root,
|
|
16
|
+
* or null if the port is stale / belongs to something else / unreachable.
|
|
17
|
+
*
|
|
18
|
+
* Bounded by a 700 ms timeout. Uses node:http only (builtins, no new deps).
|
|
19
|
+
*/
|
|
20
|
+
export function probeExistingHost(root, port) {
|
|
21
|
+
return new Promise((resolve) => {
|
|
22
|
+
const options = {
|
|
23
|
+
hostname: BIND_HOST,
|
|
24
|
+
port,
|
|
25
|
+
path: '/status',
|
|
26
|
+
method: 'GET',
|
|
27
|
+
};
|
|
28
|
+
const req = http.request(options, (res) => {
|
|
29
|
+
const chunks = [];
|
|
30
|
+
res.on('data', (chunk) => chunks.push(chunk));
|
|
31
|
+
res.on('end', () => {
|
|
32
|
+
if (res.statusCode !== 200) {
|
|
33
|
+
resolve(null);
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
try {
|
|
37
|
+
const body = JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
38
|
+
if (body['app'] === 'stikfix' &&
|
|
39
|
+
typeof body['root'] === 'string' &&
|
|
40
|
+
resolvePath(body['root']) === resolvePath(root)) {
|
|
41
|
+
resolve({ port });
|
|
42
|
+
}
|
|
43
|
+
else {
|
|
44
|
+
resolve(null);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
resolve(null);
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
// Bounded timeout: destroy socket and resolve null
|
|
53
|
+
req.setTimeout(700, () => {
|
|
54
|
+
req.destroy();
|
|
55
|
+
resolve(null);
|
|
56
|
+
});
|
|
57
|
+
req.on('error', () => {
|
|
58
|
+
resolve(null);
|
|
59
|
+
});
|
|
60
|
+
req.end();
|
|
61
|
+
});
|
|
62
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Note read/list/edit/delete service for stikfix-host.
|
|
3
|
+
* HOST-14/15/16: resolveSerialFile, listAnnotations, editNote, deleteNote
|
|
4
|
+
*
|
|
5
|
+
* matchesUrlPath is IMPORTED from lib/pin-position.ts (single source of truth —
|
|
6
|
+
* do NOT redefine inline; lib/pin-position.ts is the authoritative implementation).
|
|
7
|
+
*
|
|
8
|
+
* All file operations are path-confined via isInsideDir from security.ts (T-06-02).
|
|
9
|
+
* Throws {statusCode:404} when serial doesn't resolve (D-06).
|
|
10
|
+
* Throws {statusCode:403} when resolved path fails isInsideDir guard.
|
|
11
|
+
*/
|
|
12
|
+
import { readFileSync, readdirSync } from 'node:fs';
|
|
13
|
+
import { readFile, writeFile, rm, readdir } from 'node:fs/promises';
|
|
14
|
+
import { join, basename } from 'node:path';
|
|
15
|
+
import { parse as yamlParse, stringify as yamlStringify } from 'yaml';
|
|
16
|
+
import { isInsideDir } from './security.js';
|
|
17
|
+
import { matchesUrlPath } from '../../lib/pin-position.js';
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
// resolveSerialFile — find a note file by its leading 4-digit serial
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
/**
|
|
22
|
+
* Find the note file whose name starts with `<serial>-` in `notesDir`.
|
|
23
|
+
* Matches both *.md and *.read.md (the serial prefix predicate handles both).
|
|
24
|
+
* Returns the absolute path to the file, or null when no match.
|
|
25
|
+
*
|
|
26
|
+
* Pattern: readdirSync + startsWith filter (serial.ts:27-36 analog).
|
|
27
|
+
* Does NOT call getNextSerial / withSerialLock — read-only resolution (Pitfall 7).
|
|
28
|
+
*/
|
|
29
|
+
export function resolveSerialFile(notesDir, serial) {
|
|
30
|
+
const files = readdirSync(notesDir);
|
|
31
|
+
// Must end in .md or .read.md — avoids matching sibling +N.png files (RESEARCH.md Pitfall 5)
|
|
32
|
+
const match = files.find(f => f.startsWith(serial + '-') && (f.endsWith('.md') || f.endsWith('.read.md')));
|
|
33
|
+
return match ? join(notesDir, match) : null;
|
|
34
|
+
}
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
// listAnnotations — list all notes whose URL path matches the given page URL
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
/**
|
|
39
|
+
* Read all *.md files in notesDir, parse their YAML frontmatter, and return a
|
|
40
|
+
* PinDescriptor for each note whose `url` pathname matches `pageUrl` (D-02).
|
|
41
|
+
*
|
|
42
|
+
* When `opts.allUrls` is true the per-note URL-path match is skipped, so notes
|
|
43
|
+
* from ALL pages are returned (project-wide listing). A note still needs a
|
|
44
|
+
* string `url` field to be included. The read/.read.md exclusion and the
|
|
45
|
+
* `status === 'read'` skip apply identically in both modes — read notes are
|
|
46
|
+
* never returned regardless of `allUrls`.
|
|
47
|
+
*
|
|
48
|
+
* Serial is extracted from filename.slice(0,4) — NEVER from fm['id'] which loses
|
|
49
|
+
* zero-padding (RESEARCH.md Pitfall 7).
|
|
50
|
+
*
|
|
51
|
+
* CANONICAL: reads fm['note_position'] into the note_position descriptor field —
|
|
52
|
+
* the SAME key that buildFrontmatter writes AND the extension consumer reads off
|
|
53
|
+
* the wire. Do NOT use alternative key names (a mismatch breaks free-pin placement).
|
|
54
|
+
* Also maps fm['reply'] → reply and fm['fixed_in'] → fixedIn (snake_case → camelCase).
|
|
55
|
+
*/
|
|
56
|
+
export function listAnnotations(notesDir, pageUrl, opts) {
|
|
57
|
+
// Read notes must not produce pins. The review-notes skill marks a note done
|
|
58
|
+
// two ways (belt-and-suspenders): it renames the file to `*.read.md` AND sets
|
|
59
|
+
// `status: read` in the frontmatter. Exclude both here so a marked-read note's
|
|
60
|
+
// pin disappears on the next refresh. The filename check is the robust primary
|
|
61
|
+
// signal (works even if the frontmatter status is stale or missing).
|
|
62
|
+
const files = readdirSync(notesDir).filter(f => f.endsWith('.md') && (opts?.includeDone || !f.endsWith('.read.md')));
|
|
63
|
+
const pins = [];
|
|
64
|
+
for (const file of files) {
|
|
65
|
+
const serial = file.slice(0, 4); // leading 4-digit serial from filename
|
|
66
|
+
const content = readFileSync(join(notesDir, file), 'utf8');
|
|
67
|
+
// Extract YAML frontmatter block
|
|
68
|
+
const fmMatch = content.match(/^---\n([\s\S]*?)\n---/);
|
|
69
|
+
if (!fmMatch)
|
|
70
|
+
continue;
|
|
71
|
+
const fm = yamlParse(fmMatch[1]);
|
|
72
|
+
// Skip notes explicitly marked read in frontmatter (secondary signal —
|
|
73
|
+
// catches an in-place status flip that did not rename the file).
|
|
74
|
+
if (!opts?.includeDone && fm['status'] === 'read')
|
|
75
|
+
continue;
|
|
76
|
+
// Skip notes without a string url field
|
|
77
|
+
if (typeof fm['url'] !== 'string')
|
|
78
|
+
continue;
|
|
79
|
+
// D-02: path-only URL match (query string ignored). Skipped for project-wide
|
|
80
|
+
// listing (opts.allUrls) so notes from every page are returned.
|
|
81
|
+
if (!opts?.allUrls && !matchesUrlPath(fm['url'], pageUrl))
|
|
82
|
+
continue;
|
|
83
|
+
// Extract first line of body as text
|
|
84
|
+
const bodyStart = content.indexOf('\n---', 3) + 4; // skip closing ---\n
|
|
85
|
+
const text = content.slice(bodyStart).trim().split('\n')[0] ?? '';
|
|
86
|
+
pins.push({
|
|
87
|
+
serial,
|
|
88
|
+
mode: fm['mode'] ?? 'free',
|
|
89
|
+
status: fm['status'] ?? 'unread',
|
|
90
|
+
url: fm['url'],
|
|
91
|
+
text,
|
|
92
|
+
selector: typeof fm['selector'] === 'string' ? fm['selector'] : undefined,
|
|
93
|
+
rect: fm['rect'],
|
|
94
|
+
// CANONICAL KEY: note_position — D-03 (same name on disk, descriptor, and wire)
|
|
95
|
+
note_position: fm['note_position'],
|
|
96
|
+
screenshots: Array.isArray(fm['screenshots']) ? fm['screenshots'] : [],
|
|
97
|
+
reply: typeof fm['reply'] === 'string' ? fm['reply'] : undefined,
|
|
98
|
+
fixedIn: typeof fm['fixed_in'] === 'string' ? fm['fixed_in'] : undefined,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
return pins;
|
|
102
|
+
}
|
|
103
|
+
// ---------------------------------------------------------------------------
|
|
104
|
+
// editNote — overwrite note body in place; set status:unread; preserve frontmatter+screenshots
|
|
105
|
+
// ---------------------------------------------------------------------------
|
|
106
|
+
/**
|
|
107
|
+
* Rewrite the note body for the given serial. Preserves the YAML frontmatter block
|
|
108
|
+
* (with status updated to 'unread') and any existing ### Screenshots section.
|
|
109
|
+
*
|
|
110
|
+
* Throws {statusCode:404} when serial doesn't resolve.
|
|
111
|
+
* Throws {statusCode:403} when resolved path fails isInsideDir guard (T-06-02).
|
|
112
|
+
*
|
|
113
|
+
* D-04: editing an already-read note re-marks it unread (you changed it).
|
|
114
|
+
*/
|
|
115
|
+
export async function editNote(notesDir, serial, newComment) {
|
|
116
|
+
const mdPath = resolveSerialFile(notesDir, serial);
|
|
117
|
+
if (!mdPath) {
|
|
118
|
+
throw Object.assign(new Error('not found'), { statusCode: 404 });
|
|
119
|
+
}
|
|
120
|
+
if (!isInsideDir(notesDir, mdPath)) {
|
|
121
|
+
throw Object.assign(new Error('forbidden'), { statusCode: 403 });
|
|
122
|
+
}
|
|
123
|
+
const content = await readFile(mdPath, 'utf8');
|
|
124
|
+
// Extract the complete frontmatter block (including --- delimiters)
|
|
125
|
+
const fmMatch = content.match(/^(---\n[\s\S]*?\n---)\n/);
|
|
126
|
+
if (!fmMatch) {
|
|
127
|
+
throw Object.assign(new Error('malformed note: no frontmatter'), { statusCode: 400 });
|
|
128
|
+
}
|
|
129
|
+
// Re-parse frontmatter to update status
|
|
130
|
+
const rawFm = fmMatch[1].replace(/^---\n/, '').replace(/\n---$/, '');
|
|
131
|
+
const fm = yamlParse(rawFm);
|
|
132
|
+
fm['status'] = 'unread';
|
|
133
|
+
const newFmBlock = '---\n' + yamlStringify(fm) + '---\n';
|
|
134
|
+
// Extract existing body after frontmatter
|
|
135
|
+
const bodyAfterFm = content.slice(fmMatch[0].length);
|
|
136
|
+
// Preserve the ### Screenshots section if present (D-04 — preserve screenshots)
|
|
137
|
+
const screenshotSection = bodyAfterFm.match(/(### Screenshots[\s\S]*)$/)?.[1] ?? '';
|
|
138
|
+
const newBody = newComment + '\n' + (screenshotSection ? '\n' + screenshotSection : '');
|
|
139
|
+
await writeFile(mdPath, newFmBlock + newBody, 'utf8');
|
|
140
|
+
}
|
|
141
|
+
// ---------------------------------------------------------------------------
|
|
142
|
+
// deleteNote — remove the .md file and any +N.png screenshots
|
|
143
|
+
// ---------------------------------------------------------------------------
|
|
144
|
+
/**
|
|
145
|
+
* Hard-delete the note file and its associated screenshot PNGs.
|
|
146
|
+
* Base name stripping handles both *.md and *.read.md (Pitfall 5 / RESEARCH.md).
|
|
147
|
+
*
|
|
148
|
+
* Throws {statusCode:404} when serial doesn't resolve.
|
|
149
|
+
* Throws {statusCode:403} when resolved path fails isInsideDir guard (T-06-02).
|
|
150
|
+
*
|
|
151
|
+
* Every rm is guarded by isInsideDir — no path traversal (T-06-01/02).
|
|
152
|
+
*/
|
|
153
|
+
export async function deleteNote(notesDir, serial) {
|
|
154
|
+
const mdPath = resolveSerialFile(notesDir, serial);
|
|
155
|
+
if (!mdPath) {
|
|
156
|
+
throw Object.assign(new Error('not found'), { statusCode: 404 });
|
|
157
|
+
}
|
|
158
|
+
if (!isInsideDir(notesDir, mdPath)) {
|
|
159
|
+
throw Object.assign(new Error('forbidden'), { statusCode: 403 });
|
|
160
|
+
}
|
|
161
|
+
// Strip both .md and .read.md extensions to get the base name for PNG glob (Pitfall 5)
|
|
162
|
+
const base = basename(mdPath).replace(/\.read\.md$|\.md$/, '');
|
|
163
|
+
// Remove the .md file
|
|
164
|
+
await rm(mdPath);
|
|
165
|
+
// Find and remove associated +N.png screenshots
|
|
166
|
+
const dirEntries = await readdir(notesDir);
|
|
167
|
+
const pngs = dirEntries.filter(f => f.startsWith(base + '+') && f.endsWith('.png'));
|
|
168
|
+
for (const png of pngs) {
|
|
169
|
+
const pngPath = join(notesDir, png);
|
|
170
|
+
// isInsideDir guard on every rm — non-negotiable (T-06-02)
|
|
171
|
+
if (isInsideDir(notesDir, pngPath)) {
|
|
172
|
+
await rm(pngPath);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Security helpers for stikfix-host.
|
|
3
|
+
* D-04: readBody with 12 MB hard cap
|
|
4
|
+
* D-06/HOST-05: checkToken with crypto.timingSafeEqual
|
|
5
|
+
* D-10/HOST-09: isInsideDir path-traversal guard (Windows-correct with path.sep)
|
|
6
|
+
*/
|
|
7
|
+
import { timingSafeEqual } from 'node:crypto';
|
|
8
|
+
import { resolve, sep } from 'node:path';
|
|
9
|
+
const MAX_BODY = 12 * 1024 * 1024; // 12 MB hard cap (D-04)
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
// Token auth (HOST-05, T-02-auth, T-02-timing)
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
/**
|
|
14
|
+
* Validate X-Stikfix-Token header using constant-time comparison.
|
|
15
|
+
* Returns false if header is missing, not a string, or wrong length/value.
|
|
16
|
+
*/
|
|
17
|
+
export function checkToken(req, expectedToken) {
|
|
18
|
+
const provided = req.headers['x-stikfix-token'];
|
|
19
|
+
if (typeof provided !== 'string')
|
|
20
|
+
return false;
|
|
21
|
+
// Compare UTF-8 byte lengths — timingSafeEqual requires equal-length buffers (Pattern 5)
|
|
22
|
+
// Using Buffer.from(...,'utf8') avoids a RangeError when multibyte chars make
|
|
23
|
+
// UTF-16 .length equal but UTF-8 byte length differ (CR-01).
|
|
24
|
+
const a = Buffer.from(provided, 'utf8');
|
|
25
|
+
const b = Buffer.from(expectedToken, 'utf8');
|
|
26
|
+
if (a.length !== b.length)
|
|
27
|
+
return false; // byte-length guard
|
|
28
|
+
return timingSafeEqual(a, b);
|
|
29
|
+
}
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
// Body size cap (HOST-11, T-02-dos)
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
/**
|
|
34
|
+
* Accumulate request body as a string with a hard 12 MB cap.
|
|
35
|
+
* Calls req.destroy() + rejects with statusCode 413 if cap is exceeded (Pattern 3).
|
|
36
|
+
*/
|
|
37
|
+
export function readBody(req) {
|
|
38
|
+
return new Promise((resolve2, reject) => {
|
|
39
|
+
const chunks = [];
|
|
40
|
+
let total = 0;
|
|
41
|
+
let rejected = false;
|
|
42
|
+
req.on('data', (chunk) => {
|
|
43
|
+
if (rejected)
|
|
44
|
+
return;
|
|
45
|
+
total += chunk.length;
|
|
46
|
+
if (total > MAX_BODY) {
|
|
47
|
+
rejected = true;
|
|
48
|
+
req.destroy();
|
|
49
|
+
reject(Object.assign(new Error('Payload Too Large'), { statusCode: 413 }));
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
chunks.push(chunk);
|
|
53
|
+
});
|
|
54
|
+
req.on('end', () => {
|
|
55
|
+
if (!rejected)
|
|
56
|
+
resolve2(Buffer.concat(chunks).toString('utf8'));
|
|
57
|
+
});
|
|
58
|
+
req.on('error', (err) => {
|
|
59
|
+
if (!rejected)
|
|
60
|
+
reject(err);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
// Path-traversal guard (HOST-09, T-02-traversal)
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
/**
|
|
68
|
+
* Return true if `target` resolves to `root` itself or a path strictly inside it.
|
|
69
|
+
* Uses path.sep to prevent the /rootfoo-prefix bypass (Pitfall 4, Pattern 6).
|
|
70
|
+
*/
|
|
71
|
+
export function isInsideDir(root, target) {
|
|
72
|
+
const resolvedRoot = resolve(root);
|
|
73
|
+
const resolvedTarget = resolve(target);
|
|
74
|
+
return resolvedTarget === resolvedRoot ||
|
|
75
|
+
resolvedTarget.startsWith(resolvedRoot + sep);
|
|
76
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Serial assignment + in-process promise-queue mutex.
|
|
3
|
+
* D-03: All writes serialize through withSerialLock; no file-system locking needed.
|
|
4
|
+
*/
|
|
5
|
+
import { readdirSync } from 'node:fs';
|
|
6
|
+
// Module-level queue — forces all locked operations to execute sequentially.
|
|
7
|
+
let queue = Promise.resolve();
|
|
8
|
+
/**
|
|
9
|
+
* Execute `fn` exclusively — all concurrent callers queue behind the previous call.
|
|
10
|
+
* A throwing `fn` does not poison the queue (errors are swallowed on the tail).
|
|
11
|
+
*/
|
|
12
|
+
export function withSerialLock(fn) {
|
|
13
|
+
const result = queue.then(fn);
|
|
14
|
+
// Swallow errors on the queue tail so a failed write does not block future writes.
|
|
15
|
+
queue = result.then(() => undefined, () => undefined);
|
|
16
|
+
return result;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Scan `notesDir` for files matching /^\d{4}-/ (both *.md and *.read.md),
|
|
20
|
+
* return max(serial) + 1. Returns 1 for an empty directory.
|
|
21
|
+
* Must be called inside withSerialLock to avoid concurrent scan race (Pitfall 3).
|
|
22
|
+
*/
|
|
23
|
+
export function getNextSerial(notesDir) {
|
|
24
|
+
const files = readdirSync(notesDir);
|
|
25
|
+
const serials = files
|
|
26
|
+
.map(f => f.match(/^(\d{4})-/))
|
|
27
|
+
.filter((m) => m !== null)
|
|
28
|
+
.map(m => parseInt(m[1], 10));
|
|
29
|
+
// IN-02: use reduce instead of spread to avoid call-stack limit on large dirs
|
|
30
|
+
const max = serials.length > 0 ? serials.reduce((a, b) => Math.max(a, b), 0) : 0;
|
|
31
|
+
return max + 1;
|
|
32
|
+
}
|