feat: resolve daily-note path from Obsidian config for scoped tokens
Build and Push Docker Container / build-and-push (push) Successful in 4m15s

- Add daily_note_path_for_vault to read .obsidian/daily-notes.json

- Honor configured folder and date format for daily commands

- Add moment_to_strftime to translate Obsidian date tokens

- Authorize pathless daily:* commands against the resolved path

- Pick vault root from token vault, cwd, or default vault

- Normalize n8n httpRequest response to avoid circular JSON output

- Force returnFullResponse false and parse body to plain result

- Bump n8n-nodes-obsidian-cli-api to 0.2.0

- Add tests for allowed and denied scoped daily commands
This commit is contained in:
2026-06-24 11:01:57 +02:00
parent e21760949a
commit be78e3aa00
5 changed files with 135 additions and 6 deletions
+56 -2
View File
@@ -1,6 +1,8 @@
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
import json
from app.config import DEFAULT_VAULT_NAME, REGISTERED_VAULTS from app.config import DEFAULT_VAULT_NAME, REGISTERED_VAULTS
@@ -35,6 +37,7 @@ PATH_REQUIRED_WHEN_RESTRICTED = {
"aliases", "backlinks", "links", "outlinks", "tags", "tasks", "task", "aliases", "backlinks", "links", "outlinks", "tags", "tasks", "task",
} }
PATHLESS_ALLOWED_WHEN_RESTRICTED = {"help", "version"} PATHLESS_ALLOWED_WHEN_RESTRICTED = {"help", "version"}
DAILY_NOTE_COMMANDS = {"daily:path", "daily:read", "daily:append", "daily:prepend"}
def tuple_claim(value:Any, default:tuple[str, ...]) -> tuple[str, ...]: def tuple_claim(value:Any, default:tuple[str, ...]) -> tuple[str, ...]:
if value is None: if value is None:
@@ -120,6 +123,53 @@ def extract_command_target(args:list[str]) -> CommandTarget:
return CommandTarget(command=command, vault=vault, paths=tuple(paths)) return CommandTarget(command=command, vault=vault, paths=tuple(paths))
def moment_to_strftime(format_string:str) -> str:
replacements = [
("YYYY", "%Y"),
("YY", "%y"),
("MMMM", "%B"),
("MMM", "%b"),
("MM", "%m"),
("DDDD", "%j"),
("DD", "%d"),
("dddd", "%A"),
("ddd", "%a"),
]
converted = format_string or "YYYY-MM-DD"
for moment_token, strftime_token in replacements:
converted = converted.replace(moment_token, strftime_token)
return converted
def daily_note_path_for_vault(vault_root:Path, now:datetime|None = None) -> str:
settings_path = vault_root / ".obsidian" / "daily-notes.json"
folder = ""
format_string = "YYYY-MM-DD"
if settings_path.exists() and settings_path.is_file():
try:
settings = json.loads(settings_path.read_text(encoding="utf-8"))
if isinstance(settings, dict):
folder_value = settings.get("folder")
format_value = settings.get("format")
if isinstance(folder_value, str):
folder = folder_value.strip().strip("/")
if isinstance(format_value, str) and format_value.strip():
format_string = format_value.strip()
except (OSError, json.JSONDecodeError):
pass
note_name = (now or datetime.now()).strftime(moment_to_strftime(format_string))
if not note_name.endswith(".md"):
note_name += ".md"
return f"{folder}/{note_name}" if folder else note_name
def vault_root_for_target(target:CommandTarget, cwd:Path) -> Path:
if target.vault and target.vault in REGISTERED_VAULTS:
return Path(REGISTERED_VAULTS[target.vault]).resolve()
vault_name = vault_for_path(cwd) or DEFAULT_VAULT_NAME
return Path(REGISTERED_VAULTS.get(vault_name, cwd)).resolve()
def authorize_command(policy:TokenPolicy|None, args:list[str], cwd:Path) -> None: def authorize_command(policy:TokenPolicy|None, args:list[str], cwd:Path) -> None:
if policy is None: if policy is None:
return return
@@ -138,13 +188,17 @@ def authorize_command(policy:TokenPolicy|None, args:list[str], cwd:Path) -> None
if policy.allows_all_paths(): if policy.allows_all_paths():
return return
if not target.paths: paths = list(target.paths)
if not paths and command in DAILY_NOTE_COMMANDS:
paths.append(daily_note_path_for_vault(vault_root_for_target(target, cwd)))
if not paths:
if command in PATHLESS_ALLOWED_WHEN_RESTRICTED: if command in PATHLESS_ALLOWED_WHEN_RESTRICTED:
return return
if command in PATH_REQUIRED_WHEN_RESTRICTED: if command in PATH_REQUIRED_WHEN_RESTRICTED:
raise PermissionError("token has path restrictions; command must include an allowed path/file/folder/name argument") raise PermissionError("token has path restrictions; command must include an allowed path/file/folder/name argument")
raise PermissionError(f"token has path restrictions; command is not allowed without an explicit path: {command}") raise PermissionError(f"token has path restrictions; command is not allowed without an explicit path: {command}")
denied = [path for path in target.paths if not is_path_allowed(path, policy.paths)] denied = [path for path in paths if not is_path_allowed(path, policy.paths)]
if denied: if denied:
raise PermissionError(f"token is not allowed to access path: {denied[0]}") raise PermissionError(f"token is not allowed to access path: {denied[0]}")
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "n8n-nodes-obsidian-cli-api", "name": "n8n-nodes-obsidian-cli-api",
"version": "0.1.1", "version": "0.2.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "n8n-nodes-obsidian-cli-api", "name": "n8n-nodes-obsidian-cli-api",
"version": "0.1.1", "version": "0.2.0",
"license": "MIT", "license": "MIT",
"devDependencies": { "devDependencies": {
"@types/node": "^24.0.0", "@types/node": "^24.0.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "n8n-nodes-obsidian-cli-api", "name": "n8n-nodes-obsidian-cli-api",
"version": "0.1.1", "version": "0.2.0",
"description": "n8n community nodes for the Obsidian API Docker service backed by the official Obsidian CLI.", "description": "n8n community nodes for the Obsidian API Docker service backed by the official Obsidian CLI.",
"license": "MIT", "license": "MIT",
"homepage": "https://git.yiprawr.dev/Docker/obsidian-api", "homepage": "https://git.yiprawr.dev/Docker/obsidian-api",
@@ -61,9 +61,11 @@ export async function executeObsidianCommand(
timeout: options.timeout, timeout: options.timeout,
}, },
json: true, json: true,
returnFullResponse: false,
}; };
const result = await context.helpers.httpRequest(request) as ObsidianCommandResult; const response = await context.helpers.httpRequest(request) as unknown;
const result = normalizeCommandResult(response);
if (result.exit_code !== 0) { if (result.exit_code !== 0) {
throw new NodeOperationError( throw new NodeOperationError(
@@ -75,3 +77,38 @@ export async function executeObsidianCommand(
return result; return result;
} }
function normalizeCommandResult(response: unknown): ObsidianCommandResult {
const data = extractResponseBody(response);
const result = typeof data === 'string' ? JSON.parse(data) : data;
if (!isObject(result)) {
throw new Error('Obsidian API returned a non-object response');
}
return {
id: String(result.id ?? ''),
command: Array.isArray(result.command) ? result.command.map(String) : [],
cwd: String(result.cwd ?? ''),
exit_code: Number(result.exit_code ?? 0),
stdout: String(result.stdout ?? ''),
stderr: String(result.stderr ?? ''),
duration_ms: Number(result.duration_ms ?? 0),
timed_out: Boolean(result.timed_out),
};
}
function extractResponseBody(response: unknown): unknown {
if (!isObject(response)) return response;
// Some n8n versions/helpers can return a full response object. Never pass that
// through to node output because it contains circular fields like res.socket.
if ('body' in response) return response.body;
if ('data' in response) return response.data;
return response;
}
function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null;
}
+38
View File
@@ -165,3 +165,41 @@ def test_token_policy_allows_allowed_path(monkeypatch):
# "read" is not a Python flag, which is fine for this policy test. # "read" is not a Python flag, which is fine for this policy test.
assert response.status_code == 200 assert response.status_code == 200
assert response.json()["command"] == ["python", "read", "path=Inbox/Allowed.md"] assert response.json()["command"] == ["python", "read", "path=Inbox/Allowed.md"]
def test_path_restricted_token_allows_daily_command_from_obsidian_config(monkeypatch, tmp_path):
vault = tmp_path / "vault"
config = vault / ".obsidian"
config.mkdir(parents=True)
(config / "daily-notes.json").write_text(json.dumps({"folder": "00. Journal", "format": "YYYY-MM-DD"}))
monkeypatch.setattr("app.policy.REGISTERED_VAULTS", {"work": str(vault)})
monkeypatch.setattr("app.policy.DEFAULT_VAULT_NAME", "work")
token = use_jwt_auth(monkeypatch, {"sub": "daily", "commands": ["daily:append"], "vaults": ["work"], "paths": ["00. Journal/"]})
response = client.post(
"/commands",
headers={"Authorization": f"Bearer {token}"},
json={"args": ["vault=work", "daily:append", "content=ok"], "cwd": str(vault)},
)
assert response.status_code == 200
assert response.json()["command"] == ["python", "vault=work", "daily:append", "content=ok"]
def test_path_restricted_token_denies_daily_command_outside_obsidian_config(monkeypatch, tmp_path):
vault = tmp_path / "vault"
config = vault / ".obsidian"
config.mkdir(parents=True)
(config / "daily-notes.json").write_text(json.dumps({"folder": "Journal", "format": "YYYY-MM-DD"}))
monkeypatch.setattr("app.policy.REGISTERED_VAULTS", {"work": str(vault)})
monkeypatch.setattr("app.policy.DEFAULT_VAULT_NAME", "work")
token = use_jwt_auth(monkeypatch, {"sub": "daily", "commands": ["daily:append"], "vaults": ["work"], "paths": ["00. Journal/"]})
response = client.post(
"/commands",
headers={"Authorization": f"Bearer {token}"},
json={"args": ["vault=work", "daily:append", "content=ok"], "cwd": str(vault)},
)
assert response.status_code == 403
assert response.json()["detail"].startswith("token is not allowed to access path: Journal/")