How to let a few trusted people on other networks send their Amplifier sessions to your context-intelligence server, exposing the minimum possible: a single endpoint, over an encrypted overlay, with nothing else reachable and no public internet exposure.
This guide complements the existing options — it is the path for sharing with a handful of external people without standing up a public endpoint:
| Goal | Use |
|---|---|
| Lock a public/LAN port to trusted source IPs | README → "Network Access and Security" (firewall rule) |
| Local/dev HTTPS in front of the server | docs/service-setup.md §10 (Caddy) |
| Production, public, managed TLS | docs/azure-deployment.md |
| Share with a few external peers, privately | this guide |
Note: This guide uses Tailscale as the overlay network. The same principles (bind to loopback, expose one path, scope per peer) apply to any WireGuard-based mesh.
The Amplifier client hook sends exactly one kind of request:
POST /events Authorization: Bearer <API_KEY>
It never connects to Neo4j (bolt 7687 / browser 7474) and uses no other
endpoint. So a peer needs reachability to one path on one port — POST /events
— and nothing else. The entire design below follows from that fact.
The peer holds a raw bearer token; the server stores only its SHA-256 digest
and verifies each request by hashing the presented token (sha256(token)) and
looking the digest up. Issue each peer their own token via the api_keys keystore
— see docs/managing-api-keys.md. Keep auth enabled; it is
your application-layer gate even inside the tunnel.
peer's Amplifier ──WireGuard──► tailscale serve (HTTPS :443)
mount: ONLY /events ──► http://127.0.0.1:8000/events
(every other path → 404, never proxied)
server (gunicorn) bound 127.0.0.1:8000 only · Neo4j bound to loopback, never shared
A single tailscale serve mount does two jobs: it terminates TLS (with an
automatic MagicDNS certificate) and acts as a path allowlist. The second job
matters because a few server endpoints are intentionally unauthenticated. The
server is headless (API-only), so this is a single fixed set —
/status, /version, /docs, /openapi.json. That exempt set is defined in code
and is not configurable, so the only way to keep those endpoints away from
peers is to not route them — which scoping serve to /events does cleanly.
The path allowlist is the read boundary. Treat it as load-bearing. It matters for a second, larger reason than the unauthenticated endpoints: the server has no write-only credential.
require_readreturns early for any principal that passesrequire_write, and every static/human principal is write-capable by construction (context_intelligence_server/authz.py). So a peer's/eventstoken is equally able to callPOST /cypher— including{"workspace": "*"}, which skips workspace filtering entirely — andGET /blobs/{session_id}, which has no owner check at all. Nothing in the application layer stops it.Not routing those paths is the only thing that makes a peer contribute-only. If you later reach this server any other way — the default
server_host: 0.0.0.0LAN bind, a reverse proxy that forwards/, or the public Azure ingress in azure-deployment.md — every peer token you have already issued becomes a full read credential on the entire graph, across all workspaces. Re-verify with §4's gate after any change to how the server is exposed.
By default the server binds 0.0.0.0:8000, and a locally-run Neo4j may listen on
7474/7687. For a shared deployment, close both so Tailscale is the only
ingress.
- Server — set
server_host: 127.0.0.1in yourserver-config.yaml(loopback only). - Neo4j — bind it to loopback in
neo4j.conf:The server reaches it atserver.default_listen_address=127.0.0.1 # server.bolt.listen_address=127.0.0.1:7687 # server.http.listen_address=127.0.0.1:7474
bolt://localhost:7687.
Gate — prove it before continuing. From another machine on your LAN:
curl http://<this-host-lan-ip>:8000/statusmust be refused, andnc -vz <this-host-lan-ip> 7474 7687must be refused. Only127.0.0.1should answer.
tailscale serve --bg --https=443 --set-path=/events http://127.0.0.1:8000/events
tailscale serve statustailscale serve matches paths like Go's ServeMux: mounting only /events
leaves every other path unserved (404). It cannot filter by HTTP method, which is
fine — /events requires the bearer token, and other verbs return 405 from the app.
Important — verify the path rewrite. Tailscale's docs don't specify whether
--set-pathstrips the prefix before forwarding. Confirm with a request from a tailnet device:curl -s -o /dev/null -w '%{http_code}' https://<node>.<tailnet>.ts.net/eventsmust return 401 (reached the app's/events, correctly demanding a token). A 404 means it double-prefixed to/events/events— drop the path from the target (…:8000) or adjust--set-path, then re-test.
Important —
serveexposes the whole:443listener. Tailscale ACLs are port-level, not path-level. Any othertailscale servemount on this node (e.g. a different local app at/) is also reachable by anyone you share the node with. Only share a node whose entireservesurface you're comfortable exposing to those peers. To check:tailscale serve statusshould show only the/eventsmount (or only mounts you intend peers to reach).
There are two independent gates: sharing controls which machine a peer can reach; ACL grants control which ports on it.
In the Tailscale admin console: Machines → (your node) → Share → invite each peer by their Tailscale login email. Each accepts with their own (free) account. Sharing grants access to only that one machine, and is revocable per person.
The single most important lesson in this guide. A default tailnet policy contains an allow-all grant:
{ "src": ["*"], "dst": ["*"], "ip": ["*"] }
*includes shared/invited external users (it expands to includeautogroup:shared), and grants are additive — a more-specific grant never overrides a broader one; the policy engine applies the union. Therefore, if you leavesrc: ["*"]in place and add a narrowtcp:443grant for your peers, the peers still get every port on the shared machine. Your narrow grant is cosmetic.
The fix is to make the broad rule not match shared users. Change its source
from * to autogroup:member (all direct members of your own tailnet, which
excludes shared users) — or to an explicit group of your own users. Then the
only grant a shared peer matches is your narrow one.
Notes:
- Name peers explicitly (
peer@email) for the tightest, most auditable rule.autogroup:sharedalso works as asrcand auto-includes all current and future shared users — convenient, but broader. (autogroup:sharedis valid only as asrc, never adst.) - Reference the node in
dstvia ahostsalias or a tag — MagicDNS device names are not a documented grant selector. - Your existing
sshand Funnel (nodeAttrs) rules that key onautogroup:memberalready exclude shared users — peers get neither.
From a shared peer's machine: https://<node>.<tailnet>.ts.net/events connects
(401 without a token), while the server's other ports (8000, 7474, 7687) and
every other host on your tailnet are refused. A non-shared account
should be refused entirely.
| # | Gate | Pass |
|---|---|---|
| 0 | serve path rewrite | …/events (no token) → 401, not 404 |
| 1 | host lockdown | LAN/bridge curl to :8000, :7474, :7687 → refused |
| 2 | auth | POST /events no token → 401; OPTIONS/HEAD /events → 401 (no CORS/405 leak); with token → accepted |
| 3 | reboot persistence | after reboot, tailscale serve status still shows the /events mount and gate 1 still holds |
| 4 | ACL isolation | shared account reaches :443 only; non-shared account refused |
| 5 | end to end | a real Amplifier session via the client lands events in Neo4j (/cypher count increases) |
Onboard real peers only after gates 0–5 pass.
The shared graph is the whole value; Neo4j Community ships no backup agent. A minimal cold backup:
neo4j stop # graceful flush (do NOT kill -STOP — can corrupt the WAL)
tar czf neo4j-$(date +%F).tgz -C <neo4j-data-dir> . # e.g. /var/lib/neo4j/data
neo4j startRestore. Stop Neo4j, replace the data directory from the archive, then start it again. Run as the account that owns the Neo4j data files (the
neo4jservice user, or your user for a tarball install), or the overwrite fails with Permission denied and the restore silently does nothing:neo4j stop rm -rf <neo4j-data-dir>/* && tar xzf neo4j-YYYY-MM-DD.tgz -C <neo4j-data-dir> neo4j startTest one restore before you trust it. Also monitor disk — ingested data grows unbounded and there is no ingress size/rate cap.
This design relies on the peers being trusted. Known, accepted properties for a small group:
| Property | Implication | Harden later by |
|---|---|---|
| No write-only credential exists | Any data token that can POST /events is also read-capable at the application layer: POST /cypher (incl. workspace: "*") and GET /blobs/{session_id}. §4's path allowlist is the only thing keeping peers write-only — expose this server any other way and every issued peer token becomes a full read credential on the whole graph |
A require_write-only role for static/human principals (server change — authz.py) |
Client sets the workspace field |
A peer can post events tagged with any workspace, including yours; no server-side validation | Server-side workspace validation |
workspace is not an authorization boundary |
Nothing binds a credential to a workspace. It is a label used for filtering, and any read-capable caller can pass workspace: "*" to cross all of them |
Per-credential workspace scoping enforced on /cypher and blob reads (server change) |
| No ingress rate/size cap | A key holder can fill disk | A reverse-proxy allowlist (e.g. Caddy) or server change |
Per-peer keys are available and are what peer-onboarding.md
assumes — issue each peer their own token via the api_keys keystore
(managing-api-keys.md) so you can revoke or rotate one peer
without re-onboarding the rest. That fixes revocation granularity; it does not
change any row above, since every issued key carries identical capability.
For a handful of close collaborators these are usually acceptable. Make the call deliberately rather than by default.
- README → "Network Access and Security" —
server_hostbinding and the firewall-to-trusted-IPs option. - docs/service-setup.md §10 — self-hosted HTTPS with Caddy
(an alternative/complement to
tailscale servefor TLS). - docs/peer-onboarding.md — the guide to give your peers.
- docs/azure-deployment.md — the managed, public path.
{ "hosts": { "ci-server": "100.x.y.z" }, // your node's stable Tailscale IP (or use a tag) "grants": [ // Your own members keep full access. NOTE: src is autogroup:member, NOT "*". { "src": ["autogroup:member"], "dst": ["*"], "ip": ["*"] }, // Shared peers: ONLY tcp:443 on the server, nothing else. { "src": ["peer1@example.com", "peer2@example.com"], "dst": ["ci-server"], "ip": ["tcp:443"] } ], // Optional self-check. CAVEAT: a shared-user email as a test `src` is not // documented; if it errors on save, remove it and verify operationally (5c). "tests": [ { "src": "peer1@example.com", "accept": ["ci-server:443"], "deny": ["ci-server:8000"] } ] }