diff --git a/custom-skills/32-notion-writer/code/CLAUDE.md b/custom-skills/32-notion-writer/code/CLAUDE.md index 636c3d3..6a23d75 100644 --- a/custom-skills/32-notion-writer/code/CLAUDE.md +++ b/custom-skills/32-notion-writer/code/CLAUDE.md @@ -195,9 +195,8 @@ Standalone local-image lines (`![alt](./image.png)`) are auto-uploaded to Notion **Requirements:** - `ntn` CLI installed: `curl -fsSL https://ntn.dev | bash` -- Logged in: `ntn login` -Uploads are workspace-scoped — the file lands in whichever workspace the `ntn` CLI is authenticated to. Ensure this matches the workspace the Notion API token targets, or the image block will not attach. +Uploads run as the **same integration** that writes the page (`NOTION_API_KEY`). The script injects `NOTION_API_TOKEN=$NOTION_API_KEY` into every `ntn` subprocess, so the file upload and the page share one identity — no separate `ntn login` or workspace matching is needed. `ntn` only needs to be installed, not logged in. ### Engines diff --git a/custom-skills/32-notion-writer/code/scripts/ntn_files.py b/custom-skills/32-notion-writer/code/scripts/ntn_files.py index 992111b..437a13a 100644 --- a/custom-skills/32-notion-writer/code/scripts/ntn_files.py +++ b/custom-skills/32-notion-writer/code/scripts/ntn_files.py @@ -8,6 +8,7 @@ lifecycle (create -> send bytes -> complete) and prints the upload ID. from __future__ import annotations import json +import os import shutil import subprocess import sys @@ -17,6 +18,19 @@ from typing import Dict, List, Optional, Callable, Any _WORKSPACE: Optional[Dict[str, str]] = None +def _ntn_env(): + """Env for ntn subprocesses: force ntn to authenticate as the SAME + integration the script uses (NOTION_API_KEY/NOTION_TOKEN), so an uploaded + file and the page that references it share one identity. Notion scopes + file uploads to the creating integration, so a mismatch makes the upload + un-attachable.""" + env = dict(os.environ) + token = os.environ.get("NOTION_API_KEY") or os.environ.get("NOTION_TOKEN") + if token: + env["NOTION_API_TOKEN"] = token + return env + + class NtnUploadError(Exception): """Raised when `ntn` is unavailable or a file upload fails.""" @@ -45,7 +59,7 @@ def preflight() -> Dict[str, str]: result = subprocess.run( ["ntn", "api", "v1/users/me"], - capture_output=True, text=True, + capture_output=True, text=True, env=_ntn_env(), ) if result.returncode != 0: raise NtnUploadError( @@ -73,7 +87,7 @@ def upload(path: Path) -> str: with open(path, "rb") as fh: result = subprocess.run( ["ntn", "files", "create", "--plain"], - stdin=fh, capture_output=True, text=True, + stdin=fh, capture_output=True, text=True, env=_ntn_env(), ) if result.returncode != 0: raise NtnUploadError( diff --git a/custom-skills/32-notion-writer/code/scripts/test_ntn_files.py b/custom-skills/32-notion-writer/code/scripts/test_ntn_files.py index 5103c1e..96eee83 100644 --- a/custom-skills/32-notion-writer/code/scripts/test_ntn_files.py +++ b/custom-skills/32-notion-writer/code/scripts/test_ntn_files.py @@ -116,6 +116,18 @@ def test_materialize_missing_file_raises(): assert "nope.png" in str(e) or "nope.png" in e.path +def test_upload_passes_token_env(): + _reset_cache() + fake = subprocess.CompletedProcess(args=[], returncode=0, + stdout="ID123\tphoto.png\tuploaded\n", stderr="") + with mock.patch.dict("os.environ", {"NOTION_API_KEY": "tok-abc"}, clear=False), \ + mock.patch("builtins.open", mock.mock_open(read_data=b"x")), \ + mock.patch("subprocess.run", return_value=fake) as m: + ntn_files.upload(Path("/tmp/p.png")) + passed_env = m.call_args.kwargs["env"] + assert passed_env["NOTION_API_TOKEN"] == "tok-abc" + + def run_all(): tests = [ test_preflight_missing_ntn, @@ -126,6 +138,7 @@ def run_all(): test_materialize_local_uploaded, test_materialize_nested_children, test_materialize_missing_file_raises, + test_upload_passes_token_env, ] for t in tests: print(f"\n{t.__name__}")