obsidian-mcp-brain 0.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/lib/access.mjs ADDED
@@ -0,0 +1,108 @@
1
+ // Pure path-normalisation and access-control functions.
2
+ // denyPaths is passed explicitly so these functions are side-effect-free and testable.
3
+
4
+ export function normPath(...parts) {
5
+ const joined = parts.filter(Boolean).join('/').replace(/\/+/g, '/').replace(/^\/+|\/+$/g, '');
6
+ const segments = [];
7
+ for (const seg of joined.split('/')) {
8
+ if (seg === '..') segments.pop();
9
+ else if (seg && seg !== '.') segments.push(seg);
10
+ }
11
+ return segments.join('/');
12
+ }
13
+
14
+ export function isDenied(denyPaths, path) {
15
+ if (!denyPaths.length || !path) return false;
16
+ const p = normPath(path);
17
+ return denyPaths.some(denied => p === denied || p.startsWith(denied + '/'));
18
+ }
19
+
20
+ // Returns an error message string if the tool call should be blocked, else null.
21
+ export function checkAccess(denyPaths, toolName, args) {
22
+ if (!denyPaths.length) return null;
23
+
24
+ switch (toolName) {
25
+ case 'read-note':
26
+ case 'create-note':
27
+ case 'edit-note': {
28
+ const p = normPath(args.folder, args.filename);
29
+ if (isDenied(denyPaths, p)) return `Access denied: '${p}' is restricted`;
30
+ break;
31
+ }
32
+
33
+ case 'delete-note': {
34
+ const p = normPath(args.folder, args.filename);
35
+ if (isDenied(denyPaths, p)) return `Access denied: '${p}' is restricted`;
36
+ break;
37
+ }
38
+
39
+ case 'move-note': {
40
+ const src = normPath(args.folder, args.filename);
41
+ const dst = normPath(args.newFolder, args.newFilename);
42
+ if (isDenied(denyPaths, src)) return `Access denied: source '${src}' is restricted`;
43
+ if (isDenied(denyPaths, dst)) return `Access denied: destination '${dst}' is restricted`;
44
+ break;
45
+ }
46
+
47
+ case 'create-binary-file':
48
+ case 'fetch-binary-file': {
49
+ const p = normPath(args.folder, args.filename);
50
+ if (isDenied(denyPaths, p)) return `Access denied: '${p}' is restricted`;
51
+ break;
52
+ }
53
+
54
+ case 'delete-binary-file': {
55
+ const p = normPath(args.folder, args.filename);
56
+ if (isDenied(denyPaths, p)) return `Access denied: '${p}' is restricted`;
57
+ break;
58
+ }
59
+
60
+ case 'move-binary-file': {
61
+ const src = normPath(args.folder, args.filename);
62
+ const dst = normPath(args.newFolder, args.newFilename);
63
+ if (isDenied(denyPaths, src)) return `Access denied: source '${src}' is restricted`;
64
+ if (isDenied(denyPaths, dst)) return `Access denied: destination '${dst}' is restricted`;
65
+ break;
66
+ }
67
+
68
+ case 'find-backlinks': {
69
+ const p = normPath(args.folder, args.filename);
70
+ if (isDenied(denyPaths, p)) return `Access denied: '${p}' is restricted`;
71
+ break;
72
+ }
73
+
74
+ case 'resolve-wikilink': {
75
+ // The target is a free-text string, not a known path — this is a cheap upfront
76
+ // check on the literal string; walkAllFiles's own per-candidate filtering is what
77
+ // actually keeps denied files out of the result regardless.
78
+ if (args.target) {
79
+ const p = normPath(args.target);
80
+ if (isDenied(denyPaths, p)) return `Access denied: '${p}' is restricted`;
81
+ }
82
+ break;
83
+ }
84
+
85
+ case 'add-tags':
86
+ case 'remove-tags': {
87
+ const files = Array.isArray(args.files) ? args.files : [];
88
+ const blocked = files.map(f => normPath(f)).find(f => isDenied(denyPaths, f));
89
+ if (blocked) return `Access denied: '${blocked}' is restricted`;
90
+ break;
91
+ }
92
+
93
+ case 'create-folder': {
94
+ const p = normPath(args.folder);
95
+ if (isDenied(denyPaths, p)) return `Access denied: '${p}' is restricted`;
96
+ break;
97
+ }
98
+
99
+ case 'search-vault': {
100
+ if (args.path) {
101
+ const p = normPath(args.path);
102
+ if (isDenied(denyPaths, p)) return `Access denied: '${p}' is restricted`;
103
+ }
104
+ break;
105
+ }
106
+ }
107
+ return null;
108
+ }
package/lib/fetch.mjs ADDED
@@ -0,0 +1,184 @@
1
+ // SSRF-safe URL fetching for fetch-binary-file. A caller-supplied URL is effectively
2
+ // a request to make this host issue an arbitrary outbound call — on any deployment,
3
+ // that host may have private network services reachable that shouldn't be — so every
4
+ // function here exists to keep that call scoped to the public internet.
5
+
6
+ import dns from 'node:dns/promises';
7
+ import http from 'node:http';
8
+ import https from 'node:https';
9
+
10
+ const MAX_REDIRECTS = 5;
11
+ const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
12
+
13
+ function ipv4ToInt(ip) {
14
+ const parts = ip.split('.').map(Number);
15
+ return ((parts[0] << 24) | (parts[1] << 16) | (parts[2] << 8) | parts[3]) >>> 0;
16
+ }
17
+
18
+ function ipv4InRange(ip, base, bits) {
19
+ const mask = bits === 0 ? 0 : (0xffffffff << (32 - bits)) >>> 0;
20
+ return (ipv4ToInt(ip) & mask) === (ipv4ToInt(base) & mask);
21
+ }
22
+
23
+ // Loopback, link-local, and the three RFC1918 private ranges.
24
+ const PRIVATE_V4_RANGES = [
25
+ ['0.0.0.0', 8],
26
+ ['127.0.0.0', 8],
27
+ ['169.254.0.0', 16],
28
+ ['10.0.0.0', 8],
29
+ ['172.16.0.0', 12],
30
+ ['192.168.0.0', 16],
31
+ ];
32
+
33
+ export function isPrivateIPv4(ip) {
34
+ return PRIVATE_V4_RANGES.some(([base, bits]) => ipv4InRange(ip, base, bits));
35
+ }
36
+
37
+ export function isPrivateIPv6(ip) {
38
+ const lower = ip.toLowerCase();
39
+ if (lower === '::1' || lower === '::') return true; // loopback / unspecified
40
+ if (/^fe[89ab][0-9a-f]:/.test(lower)) return true; // fe80::/10 link-local
41
+ if (/^f[cd][0-9a-f]{2}:/.test(lower)) return true; // fc00::/7 unique-local
42
+ const mapped = lower.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/); // IPv4-mapped IPv6
43
+ if (mapped) return isPrivateIPv4(mapped[1]);
44
+ return false;
45
+ }
46
+
47
+ /**
48
+ * Validates that url is an http(s) URL whose hostname resolves only to public
49
+ * addresses. Throws with a clear message otherwise.
50
+ * @returns {Promise<{parsed: URL, addresses: {address: string, family: number}[]}>}
51
+ * The resolved addresses are returned so the caller can pin its connection to them
52
+ * (see fetchToBuffer) — re-resolving the hostname later, e.g. inside a plain
53
+ * fetch()/http.request() call, would reopen a DNS-rebinding gap between this check
54
+ * and the actual connection.
55
+ */
56
+ export async function validateUrl(url) {
57
+ let parsed;
58
+ try {
59
+ parsed = new URL(url);
60
+ } catch {
61
+ throw new Error(`Invalid URL: ${url}`);
62
+ }
63
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
64
+ throw new Error(`URL scheme must be http or https: ${url}`);
65
+ }
66
+
67
+ // URL.hostname serializes an IPv6 literal with brackets (e.g. "[::1]"), but
68
+ // dns.lookup expects the bare address — passing the bracketed form fails to
69
+ // resolve it at all, which would make an IPv6-literal URL error out instead of
70
+ // actually being checked (as happened here: a CI environment without IPv6
71
+ // correctly failed to resolve "[::1]", exposing that a same-host environment
72
+ // resolving it by coincidence was never validating the address, just erroring).
73
+ const lookupHost = parsed.hostname.startsWith('[') && parsed.hostname.endsWith(']')
74
+ ? parsed.hostname.slice(1, -1)
75
+ : parsed.hostname;
76
+
77
+ let addresses;
78
+ try {
79
+ addresses = await dns.lookup(lookupHost, { all: true, verbatim: true });
80
+ } catch {
81
+ throw new Error(`Could not resolve hostname: ${parsed.hostname}`);
82
+ }
83
+ if (!addresses.length) {
84
+ throw new Error(`Could not resolve hostname: ${parsed.hostname}`);
85
+ }
86
+ for (const { address, family } of addresses) {
87
+ const isPrivate = family === 4 ? isPrivateIPv4(address) : isPrivateIPv6(address);
88
+ if (isPrivate) {
89
+ throw new Error(`URL resolves to a disallowed address (${address}): ${url}`);
90
+ }
91
+ }
92
+ return { parsed, addresses };
93
+ }
94
+
95
+ // A dns.lookup-compatible function that ignores whatever hostname it's asked to
96
+ // resolve and always returns the address(es) validateUrl already vetted — this is
97
+ // what pins the actual socket to the validated IP, closing the DNS-rebinding gap
98
+ // between validation and connection (the hostname could otherwise resolve to a
99
+ // different, private address on a second, independent lookup).
100
+ function pinnedLookup(addresses) {
101
+ const first = addresses[0];
102
+ return (_hostname, _options, callback) => callback(null, first.address, first.family);
103
+ }
104
+
105
+ /**
106
+ * Downloads url and returns its body as a Buffer, enforcing maxBytes while streaming
107
+ * (aborting mid-transfer rather than after the fact) and a hard per-hop timeoutMs
108
+ * (covering both DNS resolution via validateUrl and the HTTP request itself).
109
+ * Redirects are followed manually, up to MAX_REDIRECTS hops, with validateUrl re-run
110
+ * on every hop's target — never just the first. Uses node:http/https directly (not
111
+ * the global fetch) so the connection can be pinned to the exact address validateUrl
112
+ * already checked, rather than letting the request re-resolve the hostname itself.
113
+ */
114
+ export async function fetchToBuffer(url, { maxBytes, timeoutMs }) {
115
+ let currentUrl = url;
116
+
117
+ for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
118
+ const controller = new AbortController();
119
+ let timedOut = false;
120
+ const timer = setTimeout(() => { timedOut = true; controller.abort(); }, timeoutMs);
121
+
122
+ try {
123
+ // validateUrl's dns.lookup has no cancellation of its own, so a hostname
124
+ // pointed at a slow or unresponsive resolver (fully attacker-controlled,
125
+ // since the URL is caller-supplied) could otherwise stall past timeoutMs
126
+ // despite the "hard per-hop timeout" this function promises. Racing it
127
+ // against the same abort signal bounds the wait even though the underlying
128
+ // lookup can't itself be cancelled; the losing promise is given a no-op
129
+ // catch so its eventual settlement doesn't surface as an unhandled rejection.
130
+ const validatePromise = validateUrl(currentUrl);
131
+ validatePromise.catch(() => {});
132
+ const { parsed, addresses } = await Promise.race([
133
+ validatePromise,
134
+ new Promise((_resolve, reject) => {
135
+ controller.signal.addEventListener('abort', () => reject(new Error('timed out')));
136
+ }),
137
+ ]);
138
+ if (timedOut) throw new Error('timed out');
139
+
140
+ const client = parsed.protocol === 'https:' ? https : http;
141
+ const response = await new Promise((resolve, reject) => {
142
+ const req = client.request(parsed, {
143
+ lookup: pinnedLookup(addresses),
144
+ servername: parsed.hostname, // keep TLS SNI/cert hostname checks against the real hostname
145
+ signal: controller.signal,
146
+ }, resolve);
147
+ req.on('error', reject);
148
+ req.end();
149
+ });
150
+
151
+ if (REDIRECT_STATUSES.has(response.statusCode)) {
152
+ response.resume(); // discard the redirect body
153
+ const location = response.headers.location;
154
+ if (!location) throw new Error(`Redirect response (${response.statusCode}) had no Location header`);
155
+ currentUrl = new URL(location, parsed).toString();
156
+ continue;
157
+ }
158
+
159
+ if (response.statusCode < 200 || response.statusCode >= 300) {
160
+ response.resume();
161
+ throw new Error(`Request failed with status ${response.statusCode}`);
162
+ }
163
+
164
+ const chunks = [];
165
+ let total = 0;
166
+ for await (const chunk of response) {
167
+ total += chunk.length;
168
+ if (total > maxBytes) {
169
+ response.destroy();
170
+ throw new Error(`Response exceeded maxBytes (${maxBytes} bytes)`);
171
+ }
172
+ chunks.push(chunk);
173
+ }
174
+ return Buffer.concat(chunks);
175
+ } catch (err) {
176
+ if (timedOut) throw new Error(`Request timed out after ${timeoutMs}ms`);
177
+ throw err;
178
+ } finally {
179
+ clearTimeout(timer);
180
+ }
181
+ }
182
+
183
+ throw new Error(`Too many redirects (limit ${MAX_REDIRECTS})`);
184
+ }
@@ -0,0 +1,212 @@
1
+ // Pure functions for Obsidian YAML frontmatter parsing and tag manipulation.
2
+ // All functions take and return content strings; no filesystem I/O.
3
+
4
+ import { escRe } from './utils.mjs';
5
+
6
+ const FM_RE = /^---\r?\n([\s\S]*?)\r?\n---(\r?\n|$)/;
7
+ const TAGS_INLINE_RE = /^tags:\s*\[([^\]]*)\]/m;
8
+ const TAGS_BLOCK_RE = /^tags:\s*\r?\n((?:[ \t]+-[^\r\n]*\r?\n?)*)/m;
9
+
10
+ /** Matches `field: [...]` inline-array style, for any field name. */
11
+ function fieldInlineRe(field) {
12
+ return new RegExp(`^${escRe(field)}:[ \\t]*\\[[^\\]]*\\][ \\t]*$`, 'm');
13
+ }
14
+
15
+ /** Matches `field:\n - ...` block-sequence style, for any field name. */
16
+ function fieldBlockRe(field) {
17
+ return new RegExp(`^${escRe(field)}:\\s*\\r?\\n((?:[ \\t]+-[^\\r\\n]*\\r?\\n?)*)`, 'm');
18
+ }
19
+
20
+ /** Matches a plain `field: value` scalar line, for any field name. */
21
+ function fieldScalarRe(field) {
22
+ return new RegExp(`^${escRe(field)}:[ \\t]*[^\\r\\n]*$`, 'm');
23
+ }
24
+
25
+ const YAML_SPECIAL_LEADING = /^[-?:,[\]{}#&*!|>'"%@`]/;
26
+ const YAML_LOOKS_LIKE_NUMBER = /^[+-]?(\d+\.?\d*|\.\d+)([eE][+-]?\d+)?$/;
27
+ const YAML_LOOKS_LIKE_KEYWORD = /^(true|false|null|~)$/i;
28
+
29
+ /**
30
+ * Serializes a scalar value (string/number/boolean) for a single YAML
31
+ * frontmatter line, quoting a string only when required to keep it a string.
32
+ * @returns {string} The serialized value, quoted if necessary.
33
+ */
34
+ export function serializeScalar(value) {
35
+ if (typeof value === 'boolean' || typeof value === 'number') return String(value);
36
+ const needsQuoting =
37
+ value === '' ||
38
+ /[\r\n]/.test(value) ||
39
+ value.includes(': ') ||
40
+ YAML_SPECIAL_LEADING.test(value) ||
41
+ YAML_LOOKS_LIKE_NUMBER.test(value) ||
42
+ YAML_LOOKS_LIKE_KEYWORD.test(value);
43
+ return needsQuoting ? JSON.stringify(value) : value;
44
+ }
45
+
46
+ function parseTagsFromBody(body) {
47
+ const inline = TAGS_INLINE_RE.exec(body);
48
+ if (inline) {
49
+ return inline[1]
50
+ .split(',')
51
+ .map(t => t.trim().replace(/^['"]|['"]$/g, ''))
52
+ .filter(Boolean);
53
+ }
54
+ const block = TAGS_BLOCK_RE.exec(body);
55
+ if (block) {
56
+ return block[1]
57
+ .split('\n')
58
+ .map(l => l.replace(/^[ \t]+-\s*/, '').trim())
59
+ .filter(Boolean);
60
+ }
61
+ return [];
62
+ }
63
+
64
+ /**
65
+ * Extracts tag list from YAML frontmatter (inline or block format).
66
+ * @returns {string[]} Tag names, or empty array if no frontmatter or tags found.
67
+ */
68
+ export function parseTags(content) {
69
+ const fm = FM_RE.exec(content);
70
+ return fm ? parseTagsFromBody(fm[1]) : [];
71
+ }
72
+
73
+ function buildTagsSection(tags) {
74
+ if (tags.length === 0) return 'tags: []';
75
+ return `tags:\n${tags.map(t => ` - ${t}`).join('\n')}`;
76
+ }
77
+
78
+ function replaceTagsInBody(body, tags) {
79
+ const newSection = buildTagsSection(tags);
80
+ if (TAGS_INLINE_RE.test(body)) {
81
+ return body.replace(TAGS_INLINE_RE, newSection);
82
+ }
83
+ if (TAGS_BLOCK_RE.test(body)) {
84
+ // Block regex captures trailing newlines in each entry; replace whole block + trailing newline
85
+ return body.replace(TAGS_BLOCK_RE, newSection + '\n');
86
+ }
87
+ return body.trimEnd() + '\n' + newSection;
88
+ }
89
+
90
+ /**
91
+ * Replaces the tags section in frontmatter, creating frontmatter if missing.
92
+ */
93
+ export function setTags(content, tags) {
94
+ const fm = FM_RE.exec(content);
95
+ if (!fm) {
96
+ return `---\n${buildTagsSection(tags)}\n---\n${content}`;
97
+ }
98
+ const newBody = replaceTagsInBody(fm[1], tags);
99
+ const after = content.slice(fm.index + fm[0].length);
100
+ return `---\n${newBody.trimEnd()}\n---${fm[2]}${after}`;
101
+ }
102
+
103
+ /**
104
+ * Adds tags to frontmatter, skipping duplicates. Returns content unchanged if all tags already exist.
105
+ */
106
+ export function addTags(content, tagsToAdd) {
107
+ const existing = parseTags(content);
108
+ const existingSet = new Set(existing);
109
+ const toAdd = tagsToAdd.filter(t => !existingSet.has(t));
110
+ if (!toAdd.length) return content;
111
+ return setTags(content, [...existing, ...toAdd]);
112
+ }
113
+
114
+ /**
115
+ * Removes tags from frontmatter. Returns content unchanged if no tags were removed.
116
+ */
117
+ export function removeTags(content, tagsToRemove) {
118
+ const existing = parseTags(content);
119
+ const removeSet = new Set(tagsToRemove);
120
+ const remaining = existing.filter(t => !removeSet.has(t));
121
+ if (remaining.length === existing.length) return content;
122
+ return setTags(content, remaining);
123
+ }
124
+
125
+ /**
126
+ * Renames a tag in frontmatter only. Returns content unchanged if tag not found.
127
+ */
128
+ export function renameFrontmatterTag(content, oldTag, newTag) {
129
+ const tags = parseTags(content);
130
+ if (!tags.includes(oldTag)) return content;
131
+ return setTags(content, tags.map(t => (t === oldTag ? newTag : t)));
132
+ }
133
+
134
+ /**
135
+ * Renames inline tags (e.g., #tag syntax) in content, respecting word boundaries.
136
+ * Matches tags preceded by whitespace or start-of-line, not followed by tag-valid characters.
137
+ */
138
+ export function renameInlineTag(content, oldTag, newTag) {
139
+ // Match #tag preceded by whitespace or start-of-line, not followed by tag-valid chars
140
+ // Tag-valid chars in Obsidian: [a-zA-Z0-9_/-]
141
+ const re = new RegExp(`(^|[ \\t])#${escRe(oldTag)}(?![a-zA-Z0-9_/\\-])`, 'gm');
142
+ return content.replace(re, (_, prefix) => `${prefix}#${newTag}`);
143
+ }
144
+
145
+ /**
146
+ * Renames a tag everywhere: in frontmatter and inline (#tag syntax).
147
+ */
148
+ export function renameTag(content, oldTag, newTag) {
149
+ let result = renameFrontmatterTag(content, oldTag, newTag);
150
+ result = renameInlineTag(result, oldTag, newTag);
151
+ return result;
152
+ }
153
+
154
+ /** Replaces a field with a new line, handling inline, block, scalar, or missing cases. */
155
+ function replaceFieldInBody(body, field, newLine) {
156
+ if (fieldInlineRe(field).test(body)) {
157
+ return body.replace(fieldInlineRe(field), newLine);
158
+ }
159
+ if (fieldBlockRe(field).test(body)) {
160
+ return body.replace(fieldBlockRe(field), newLine + '\n');
161
+ }
162
+ if (fieldScalarRe(field).test(body)) {
163
+ return body.replace(fieldScalarRe(field), newLine);
164
+ }
165
+ const trimmed = body.trimEnd();
166
+ return trimmed ? trimmed + '\n' + newLine : newLine;
167
+ }
168
+
169
+ /**
170
+ * Sets a single frontmatter field to a scalar value, creating the
171
+ * frontmatter block or the field itself if either is missing. Overwrites
172
+ * an existing inline, block, or scalar value for that field.
173
+ */
174
+ export function setFrontmatterField(content, field, value) {
175
+ const newLine = `${field}: ${serializeScalar(value)}`;
176
+ const fm = FM_RE.exec(content);
177
+ if (!fm) {
178
+ return `---\n${newLine}\n---\n${content}`;
179
+ }
180
+ const newBody = replaceFieldInBody(fm[1], field, newLine);
181
+ const after = content.slice(fm.index + fm[0].length);
182
+ return `---\n${newBody.trimEnd()}\n---${fm[2]}${after}`;
183
+ }
184
+
185
+ /** Removes a field and its value from the body; returns null if the field is not found. */
186
+ function removeFieldFromBody(body, field) {
187
+ if (fieldInlineRe(field).test(body)) {
188
+ return body.replace(new RegExp(`^${escRe(field)}:[ \\t]*\\[[^\\]]*\\][ \\t]*\\r?\\n?`, 'm'), '');
189
+ }
190
+ if (fieldBlockRe(field).test(body)) {
191
+ return body.replace(fieldBlockRe(field), '');
192
+ }
193
+ if (fieldScalarRe(field).test(body)) {
194
+ return body.replace(new RegExp(`^${escRe(field)}:[ \\t]*[^\\r\\n]*\\r?\\n?`, 'm'), '');
195
+ }
196
+ return null;
197
+ }
198
+
199
+ /**
200
+ * Removes a single frontmatter field (its inline, block, or scalar value).
201
+ * Returns content unchanged if the field or the frontmatter block itself
202
+ * doesn't exist.
203
+ */
204
+ export function removeFrontmatterField(content, field) {
205
+ const fm = FM_RE.exec(content);
206
+ if (!fm) return content;
207
+ const newBody = removeFieldFromBody(fm[1], field);
208
+ if (newBody === null) return content;
209
+ const after = content.slice(fm.index + fm[0].length);
210
+ const trimmed = newBody.trimEnd();
211
+ return trimmed ? `---\n${trimmed}\n---${fm[2]}${after}` : `---\n---${fm[2]}${after}`;
212
+ }
@@ -0,0 +1,51 @@
1
+ // Compare-and-swap write preconditions based on a file's last-modified time.
2
+ // No filesystem I/O here except the single stat assertUnmodified performs.
3
+
4
+ import fs from 'node:fs/promises';
5
+
6
+ const ISO_MTIME_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
7
+
8
+ /**
9
+ * Formats a fs.Stats mtime the same way read-note and list-notes already do,
10
+ * so there is exactly one implementation a client's expectedMtime is compared against.
11
+ */
12
+ export function formatMtime(stat) {
13
+ return stat.mtime.toISOString();
14
+ }
15
+
16
+ /**
17
+ * Throws if expectedMtime isn't the exact ISO 8601 string read-note/list-notes emit
18
+ * (a malformed value is rejected up front, before it's ever compared against a real
19
+ * file, so the failure clearly names the bad input rather than surfacing as a
20
+ * confusing mtime mismatch).
21
+ */
22
+ export function validateExpectedMtime(expectedMtime) {
23
+ if (typeof expectedMtime !== 'string' || !ISO_MTIME_RE.test(expectedMtime)) {
24
+ throw new Error(`Invalid expectedMtime "${expectedMtime}": must be an ISO 8601 timestamp as returned by read-note/list-notes`);
25
+ }
26
+ }
27
+
28
+ /**
29
+ * Asserts that the file at absPath's current mtime matches expectedMtime, throwing a
30
+ * precondition-failure error (naming both values) if it doesn't, or a distinct error if
31
+ * the file no longer exists at all. Used as a compare-and-swap guard immediately before
32
+ * a write, not as a lock — the window between this check and the write itself is not
33
+ * closed, deliberately, since the realistic conflict this guards against is an edit
34
+ * seconds or minutes old, not a same-instant race.
35
+ */
36
+ export async function assertUnmodified(absPath, expectedMtime) {
37
+ validateExpectedMtime(expectedMtime);
38
+ let stat;
39
+ try {
40
+ stat = await fs.stat(absPath);
41
+ } catch (err) {
42
+ if (err.code === 'ENOENT') {
43
+ throw new Error(`Precondition failed: file no longer exists (expected mtime ${expectedMtime})`);
44
+ }
45
+ throw err;
46
+ }
47
+ const actual = formatMtime(stat);
48
+ if (actual !== expectedMtime) {
49
+ throw new Error(`Precondition failed: file was modified (expected mtime ${expectedMtime}, actual ${actual}). Re-read and retry.`);
50
+ }
51
+ }