From e660910a9887f7903d0eedb443162b8d8c6a3c27 Mon Sep 17 00:00:00 2001 From: Cl0ver Date: Thu, 1 Oct 2026 11:41:54 -0400 Subject: [PATCH 1/3] fix(api): keep a visitor's library when another site frames the demo A Hugging Face Space shows the app on huggingface.co inside a frame from hf.space, a different site. Browsers drop SameSite=Lax cookies there, so every uploaded file started a new library and the import found an empty batch. Over HTTPS the cookie is now SameSite=None, Secure, and Partitioned, which keeps it apart for each site that frames the demo. The X-ChatLore header still guards every change. Starlette only writes Partitioned on Python 3.14, so the header is written directly, and removed with the same attributes since a partitioned cookie is only replaced by one. --- CHANGELOG.md | 6 ++++++ docs/hosting.md | 4 +++- src/chatlore/api.py | 28 +++++++++++++++++++--------- tests/test_uploads.py | 19 +++++++++++++++++++ 4 files changed, 47 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c67d22..43f92c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- Visitors' own libraries work when the demo is shown in another site's frame, as on a Hugging + Face Space's page: over HTTPS the library cookie is `SameSite=None`, `Secure`, and + `Partitioned`. It used to be dropped there, so every upload started a new, empty library. + ## [0.2.0] - 2026-09-30 ### Added diff --git a/docs/hosting.md b/docs/hosting.md index a1a0d0c..918da87 100644 --- a/docs/hosting.md +++ b/docs/hosting.md @@ -72,7 +72,9 @@ chatlore --home ~/.chatlore-demo serve --public --uploads --host 0.0.0.0 --port own, and everything they then see, search, and ask is theirs alone. Everyone else still sees the server's library. - The library is tied to the visitor's browser by a random token in a cookie - (HttpOnly, SameSite=Lax, and Secure over HTTPS). Its folder is named after a + (HttpOnly; over HTTPS also Secure, SameSite=None, and Partitioned, so it works + when another site shows the demo in a frame, as a Hugging Face Space's page + does, kept apart for each such site). Its folder is named after a hash of the token, so the server's files do not give the token away. - It is deleted after 24 hours, or `--keep-hours`, and at once when the visitor chooses **Delete my library**; an import still running is stopped first. The diff --git a/src/chatlore/api.py b/src/chatlore/api.py index cf17750..62d8e65 100644 --- a/src/chatlore/api.py +++ b/src/chatlore/api.py @@ -217,6 +217,21 @@ def _cookie(scope: Scope, name: str) -> str | None: return None +def _library_cookie(request: Request, token: str, max_age: int) -> str: + """The ``Set-Cookie`` value that gives the visitor's browser its library token. + + Over HTTPS the cookie is also sent inside another site's frame, as when a + Hugging Face Space shows the demo on its page, and ``Partitioned`` keeps it + apart for each site that frames it. Every change still needs the + ``X-ChatLore`` header, which another site cannot send. + """ + if request.url.scheme == "https": + attributes = "SameSite=None; Secure; Partitioned" + else: + attributes = "SameSite=Lax" + return f"{COOKIE}={token}; Max-Age={max_age}; Path=/; HttpOnly; {attributes}" + + def _forget_stale_batches(uploads: Path) -> None: """Delete batches that were uploaded but never imported, a day on.""" if not uploads.exists(): @@ -370,14 +385,8 @@ def _target(request: Request, response: Response) -> tuple[Path, Callable[[], Gr space = _visitor.get() if space is None: token, space = spaces.create() - response.set_cookie( - COOKIE, - token, - max_age=int(spaces.keep.total_seconds()), - httponly=True, - samesite="lax", - secure=request.url.scheme == "https", - ) + max_age = int(spaces.keep.total_seconds()) + response.headers.append("set-cookie", _library_cookie(request, token, max_age)) return space.home, space.open_store async def _receive(request: Request, path: Path, room: int) -> int: @@ -511,7 +520,8 @@ def delete_library(request: Request, response: Response) -> dict[str, str]: raise HTTPException(403, "the server's library cannot be deleted from here") raise HTTPException(404, "you have no library of your own on this server") imports.cancel(space.home, then=lambda: _forget(space)) - response.delete_cookie(COOKIE) + # Removed with the same attributes, or a partitioned cookie would stay. + response.headers.append("set-cookie", _library_cookie(request, '""', 0)) return {"status": "deleted"} @app.get("/stats") diff --git a/tests/test_uploads.py b/tests/test_uploads.py index fc0ee60..e594e71 100644 --- a/tests/test_uploads.py +++ b/tests/test_uploads.py @@ -255,6 +255,25 @@ def test_a_visitor_can_delete_their_library( assert public.get("/library").json()["own"] is False +def test_over_https_the_library_also_works_in_another_sites_frame( + home: Path, spaces: Spaces, fixtures: Path +) -> None: + app = create_app(home, public=True, spaces=spaces) + with TestClient(app, base_url="https://demo.example") as client: + started = upload(client, fixtures / "claude" / "conversations.json") + finished(client) + own = client.get("/stats").json() + deleted = client.delete("/library", headers=CHANGE) + after = client.get("/library").json() + + given = started.headers["set-cookie"] + removed = deleted.headers["set-cookie"] + assert all(part in given for part in ("SameSite=None", "Secure", "Partitioned", "HttpOnly")) + assert own["conversations"] == 3 + assert "Max-Age=0" in removed and "Partitioned" in removed + assert after["own"] is False + + def test_the_servers_own_library_cannot_be_deleted(private: TestClient) -> None: assert private.delete("/library", headers=CHANGE).status_code == 403 From 68d073b52f2c75ba38798c4a00a76295ead96857 Mon Sep 17 00:00:00 2001 From: Cl0ver Date: Thu, 1 Oct 2026 11:41:54 -0400 Subject: [PATCH 2/3] ci: deploy the hosted demo to a Hugging Face Space A free Space builds the existing Dockerfile and keeps the container running with a disk, which visitors' libraries and background imports need. The workflow uploads only what the image builds from, with a README whose front matter configures the Space, and creates the Space on the first run. It runs for every released version and on request, and is skipped until HF_SPACE is set, so forks are not affected. --- .github/workflows/deploy.yml | 70 ++++++++++++++++++++++++++++++++++++ CHANGELOG.md | 5 +++ docs/hosting.md | 26 ++++++++++++++ 3 files changed, 101 insertions(+) create mode 100644 .github/workflows/deploy.yml diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..b51e17c --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,70 @@ +name: Deploy + +# Deploys the hosted demo to a Hugging Face Space, which builds the Dockerfile +# and runs it. Runs for every released version and on request. Needs the +# repository variable HF_SPACE (owner/name) and the secret HF_TOKEN, a token +# with write access; the Space is created on the first run. Steps in +# docs/hosting.md. + +on: + push: + tags: ["v*"] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: deploy + cancel-in-progress: false + +jobs: + space: + name: Hugging Face Space + if: vars.HF_SPACE != '' + runs-on: ubuntu-latest + environment: + name: demo + url: https://huggingface.co/spaces/${{ vars.HF_SPACE }} + env: + HF_SPACE: ${{ vars.HF_SPACE }} + HF_TOKEN: ${{ secrets.HF_TOKEN }} + steps: + - name: Check out + uses: actions/checkout@v4 + + - name: Set up uv + uses: astral-sh/setup-uv@v6 + + - name: Gather what the Space builds + run: | + mkdir space + cp -r Dockerfile .dockerignore pyproject.toml uv.lock LICENSE src space/ + # The Space reads its settings from the front matter of its README. + { + cat <<'EOF' + --- + title: ChatLore + emoji: πŸ•ΈοΈ + colorFrom: indigo + colorTo: blue + sdk: docker + app_port: 7860 + license: mit + short_description: All your AI conversations, one graph, one chat. + --- + + EOF + cat README.md + } > space/README.md + + - name: Create the Space + run: > + uvx --from "huggingface_hub>=1.32,<2" hf repos create "$HF_SPACE" + --type space --space-sdk docker --public --exist-ok + + - name: Upload + run: > + uvx --from "huggingface_hub>=1.32,<2" hf upload "$HF_SPACE" space . + --type space --delete "src/*" + --commit-message "Deploy ${GITHUB_REF_NAME} (${GITHUB_SHA::7})" diff --git a/CHANGELOG.md b/CHANGELOG.md index 43f92c4..6dfe155 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- A Deploy workflow that puts the hosted demo on a Hugging Face Space, creating it on the first + run, for every released version and on request. Steps in `docs/hosting.md`. + ### Fixed - Visitors' own libraries work when the demo is shown in another site's frame, as on a Hugging diff --git a/docs/hosting.md b/docs/hosting.md index 918da87..07f3da8 100644 --- a/docs/hosting.md +++ b/docs/hosting.md @@ -102,6 +102,32 @@ visitor's cookie. Any service that builds and runs a Dockerfile from a Git repository works, and gives the container an HTTPS address. ChatGPT only connects to HTTPS. +### Hugging Face Spaces + +The repository deploys the demo to a [Hugging Face Space](https://huggingface.co/docs/hub/spaces-sdks-docker), +which is free on the basic CPU hardware (2 vCPUs, 16 GB of memory), with the +workflow in `.github/workflows/deploy.yml`: + +1. Create a Hugging Face [access token](https://huggingface.co/settings/tokens) + with write access, and add it to the GitHub repository as the secret + `HF_TOKEN` (Settings β†’ Secrets and variables β†’ Actions). +2. Add the repository variable `HF_SPACE` with the Space's name, such as + `your-name/chatlore`, on the same page. +3. Run the **Deploy** workflow from the Actions tab. It creates the Space on the + first run and uploads the Dockerfile with what it builds; the Space then + builds the image and starts it, which takes a few minutes. Every version + tagged afterwards is deployed the same way. +4. In the Space's settings, add the secret `OPENROUTER_API_KEY` for asking, with + a credit limit on the key. The Space restarts with it. + +The demo is then at `https://huggingface.co/spaces//`, and on its +own at `https://-.hf.space`, which is the address to give +assistants: `https://-.hf.space/mcp`. + +A free Space sleeps after two days without visitors and wakes when someone +opens it. Its disk is emptied whenever it restarts, which also deletes +visitors' libraries early; the demo library is in the image and comes back. + CI builds the image on every pull request, starts it, and checks the web interface, the API, and a tool call over MCP. It then uploads an export as a visitor, waits for the import, checks that only that visitor sees it, downloads From f87b59be61540aa772509f81a91c5d2f7d0dbf45 Mon Sep 17 00:00:00 2001 From: Cl0ver Date: Thu, 1 Oct 2026 11:41:54 -0400 Subject: [PATCH 3/3] docs: update the roadmap for the hosted demo --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 3b8cea0..d30f98b 100644 --- a/README.md +++ b/README.md @@ -100,7 +100,7 @@ extraction is an optional enrichment you can re-run with a better model later. | M6 | Web UI: conversations, graph explorer, chat | basic version done | | M7 | MCP server for Claude Desktop, Claude Code, Cursor, ChatGPT | done; ChatGPT through /mcp once the demo is online | | M8 | Easy to try: PyPI package, demo library, export and import | done, v0.1.0 | -| M9 | Hosted demo | public mode, MCP over HTTP, and Docker image done; deployment next | +| M9 | Hosted demo | public mode, MCP over HTTP, Docker image, and Hugging Face Space deployment done; going live next | | M10 | FalkorDB backend | done | | M11 | Your own data in the web interface: upload, export, private visitor libraries | done | | M12 | Import anything: documents, email, data files, folders, and archives | done |