Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
@@ -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})"
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,17 @@ 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
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
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
30 changes: 29 additions & 1 deletion docs/hosting.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -100,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/<owner>/<name>`, and on its
own at `https://<owner>-<name>.hf.space`, which is the address to give
assistants: `https://<owner>-<name>.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
Expand Down
28 changes: 19 additions & 9 deletions src/chatlore/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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")
Expand Down
19 changes: 19 additions & 0 deletions tests/test_uploads.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading