xerj share gives one person read-only search over one indexed folder: a link,
a passcode and an expiry. The guest opens the link in a browser, types the
passcode, and gets a reading room — a search box, highlighted snippets and a
document view — over that one index and nothing else on the node.
The documents and the index stay on your machine. Nothing is uploaded, synced
or copied to a hosting service, and the guest installs nothing. What does leave
the machine is what the guest asks to see: search results and the documents
they open, sent to their browser. Who else can read that traffic depends on how
the link reaches your node: nobody on a loopback link, whoever is on the network
path for a plain-http address, and Cloudflare for a --tunnel link — see
--tunnel. The command says which of these applies when it prints
the link.
"Zero-token" in this document means search and reading. The reading room generates no text, summarises nothing and calls no model.
Every statement below points at the code or the test that makes it true. Primary sources:
| Area | File |
|---|---|
/_share API, records, passcode, claim limiter |
engine/crates/xerj-api/src/share.rs |
| Guest route allow-list, PIT refusal | engine/crates/xerj-api/src/authz.rs (guest_route_allowed, guest_body_denied) |
xerj share CLI and the tunnel supervisor |
engine/crates/xerj-server/src/share.rs |
| Guest page | xerj-ux/share/ |
| Guest page serving and its header set | engine/crates/xerj-console-api/src/spa.rs |
| Live security test | engine/crates/xerj-api/tests/live/share_security_live.py |
| Headless-browser test of the guest flow | xerj-ux/test/share-guest-flow.e2e.mjs |
| One script that runs all of it | .github/scripts/share-links-smoke.sh |
# 1. index the folder (boots a local node with authentication ON)
xerj brain ~/casefiles
# 2. share it — prints a link, a passcode, the expiry and how to revoke
xerj share ~/casefiles --label "for Dana" --expires 7d
# 3. for someone who is not on this machine: a temporary public address
xerj share ~/casefiles --label "for Dana" --tunnelxerj brain prints, in its closing summary, the exact xerj share command for
the folder it just indexed, including --url and --data-dir when they are not
the defaults. Each argument is quoted when it needs to be, so a folder called
case files pastes as one argument.
One open is one browser tab. The guest's key lives in that tab's
sessionStorage. Opening the same link again in the same tab keeps the session
and spends nothing; a closed tab is a spent open. With the default
--max-claims 1, a guest who closes the tab cannot come back, however long the
share has left to run. For someone who will read over several days, give a few:
--max-claims 5.
Send the link and the passcode through different channels — the link by email, the passcode by phone or a messenger. Either one alone opens nothing.
xerj share <index|folder> [OPTIONS] create a share
xerj share --list every share on this node
xerj share --revoke <handle> end one (invalidates its guest keys)
| Option | Meaning |
|---|---|
<index|folder> |
An index name (ax-notes, or a,b), or the folder you gave xerj brain. A folder resolves to the brain xerj brain built for it and to the indices that brain's meta document lists. |
--expires <D> |
Lifetime: 30m, 24h, 7d. Default 24h, longest 30d. |
--max-claims <N> |
How many times the link can be opened. Default 1, most 50. Each claim mints its own guest key. One open is one browser tab (see above). |
--passcode <CODE> |
Choose the passcode (6–128 characters). Default: a generated xxxx-xxxx. A passcode typed on the command line lands in your shell history; the generated one does not. |
--label <TEXT> |
What the guest sees as the title. |
--brain <NAME> |
Also share this brain's links. A folder argument sets it for you. |
--index |
Treat the argument as an index name even when a folder of that name exists. |
--tunnel |
Open a temporary public address with cloudflared, print the full guest link, run until Ctrl-C, then close the tunnel and revoke the share. The traffic passes through Cloudflare, which can read it; the command says so when it prints the link. |
--keep |
With --tunnel: leave the share active after Ctrl-C. |
--public-url <U> |
The https:// address this node is already reachable at; the printed link uses it. http:// is accepted with a printed warning: the passcode and the guest key would cross the network unencrypted. |
--url, --data-dir, --api-key |
The node (scheme included: http://localhost:9200), where its admin.key is, or the admin key itself (also XERJ_API_KEY). Same resolution order as xerj brain. |
--json |
Machine-readable output. |
Exit codes: 0 ok, 1 refused or failed, 2 usage, 130 interrupted before
the tunnel was up (nothing was shared).
Shares are managed with the node's admin key and nothing less. A scoped or
minted key gets 403 on POST /_share, GET /_share and
DELETE /_share/{handle} — otherwise a key could widen its own reach by minting
a share (share.rs, require_superuser). A wrong or non-admin key is reported
as a key problem whether the argument is an index or a folder.
A node started with --insecure (or auth.enabled = false) treats every
request as the superuser. A "read-only guest key" on such a node restricts
nothing: the guest, and anyone else who can reach the port, can already read,
change and delete every index without it. xerj share therefore refuses, says
why, and exits 1; the node answers POST /_share and the claim route with
409 (share.rs, open_node_refusal). Restart the node without --insecure —
authentication is on by default and the admin key is written to
<data_dir>/admin.key.
--tunnel runs your own cloudflared as a child process
(cloudflared tunnel --url http://127.0.0.1:<es port>), reads the
https://….trycloudflare.com hostname from its output, and prints the full
guest link. The tunnel comes up before the share is created, so a link is
never printed for an address that is not there. On Ctrl-C (and SIGTERM, and a
closed terminal) the tunnel is terminated and the share is revoked, which
invalidates every guest key it minted.
If cloudflared is not installed the command does not fail: it prints the exact
install steps for your platform and the local link. It looks at
XERJ_CLOUDFLARED, then PATH, then ~/.local/bin.
Verified on 2026-09-18 against a real quick tunnel: the guest page, a wrong and
a right passcode, a guest search and a refused _cat call all went through the
public hostname; after SIGINT the cloudflared process was gone, the share
listed as revoked, and the guest key answered 401. That run cannot be
repeated in CI — it needs the public internet and a third party — so CI covers
the hostname parser, the fallbacks and everything that does not need the tunnel
itself.
When cloudflared stops on its own, the command revokes the share and prints
cloudflared's last lines, which are the only explanation there is.
Three things to know about quick tunnels:
- The hostname is random and new on every start. A link stops working when the command stops. That is the intended lifetime, not a fault.
- Traffic passes through Cloudflare, which terminates TLS. The corpus is not
uploaded there. Everything the guest's browser and your node say to
each other does travel through Cloudflare's network, and the operator of a TLS
endpoint can read what passes through it: the passcode, the guest's API key,
every search and every document the guest opens.
xerj share --tunnelprints this with the link,--helpsays it under--tunnel, and the guest page shows a notice on atrycloudflare.comhostname before the passcode is typed. If that is not acceptable, publish the node at your own hostname behind your own TLS and use--public-url. - Cloudflare offers quick tunnels without an account and without an uptime guarantee. For a standing arrangement use a named tunnel or your own reverse proxy with a stable hostname.
--tunnel fronts a plain-http node on this machine only. For a node with TLS
on, run your own tunnel or proxy and pass its address as --public-url.
The guest's key carries one role, share:<handle>: read on the indices the
share names, plus read on the shared brain's edges index when a brain is
named. Two things confine it, both enforced server-side:
- The index grants — the per-index authorization every scoped key gets, in the middleware and again at the engine's index funnel (see SECURITY_MODEL.md).
- A route allow-list that applies to share guests only
(
authz.rs,guest_route_allowed). An ordinary scoped key is given a filtered view of_cat,_cluster/*and_nodesbecause Kibana needs one. A guest was handed one corpus by someone who did not agree to describe their node, so for a guest that surface is closed outright.
| A guest can | A guest cannot |
|---|---|
_search, _count, _msearch, _mget on the shared indices |
search, count, fetch or map any other index — by name, by pattern, by alias, or by naming it inside a request body |
GET _doc/{id} (and HEAD) on the shared indices |
write, update, delete, bulk, reindex, refresh, flush, close, or change a mapping or a setting |
_mapping and _field_caps on the shared indices |
read _cat/*, _cluster/*, _nodes, _snapshot, _tasks, _stats, _aliases, index templates, ingest pipelines, /v1/metrics, the native /v1/* router |
GET /_graph/{brain}/ego and /overview for the shared brain |
read or write another brain, write to the shared brain, use /_memory/* |
read GET /, _security/_authenticate, the health probes |
list, create or revoke shares; mint an API key; read the audit log |
| open a scroll, a point-in-time or an async search — nothing that parks state on your machine — or ride one that someone else opened | |
use the Console API (/_xerj-console/api/v1/*), which takes a session cookie and not an API key |
A pattern such as _all or * is not refused: it is expanded over what the
key holds, so for a guest _all means "all of yours". It never means more.
The allow-list covers both HTTP listeners (the ES-compatible port and the
native REST port). The gRPC listener is outside it: it applies the same
per-index grants. An in-process test drives the real gRPC interceptor and
handlers with a claimed guest key: it reads the shared index, and gets
PermissionDenied on every other index, on patterns, on system indices and on
index and delete (bulk_index needs a transport to call; it runs the same
per-item check as index), and Unauthenticated after a revoke
(xerj-server/src/grpc.rs, a_share_guest_key_reads_its_index_over_grpc_and_nothing_else).
No test opens a gRPC socket with a guest key. The listener binds to loopback by
default and is not reachable through --tunnel, which fronts the ES-compatible
port only.
The autoindex-catalog index, which lists every corpus on the node, is never
granted. A folder share does not include it, and it cannot be shared by name, in
a list or through an alias: POST /_share answers 400 (share.rs,
validate_share_index). The engine has no document-level security to filter it
with, and the guest page is handed the index name directly.
There is no GET /{index}/_source/{id} route on this node (404 for the admin
key), so it is not on the guest allow-list either; GET _doc/{id} returns the
source.
Every row of that table is a check in
engine/crates/xerj-api/tests/live/share_security_live.py, which runs against a
real node with authentication on. It includes the indirect routes: a terms
lookup, a more_like_this like-document, a percolate stored document, an
indexed_shape and a lookup runtime field that each name a private index from
inside an otherwise permitted search; an _msearch header and an _mget body
that name another index; aliases that point elsewhere; and the owner's own
point-in-time and scroll ids presented by the guest.
Naming an alias in xerj share (or POST /_share) records the concrete
indices it points at now. Re-pointing the alias later does not move, or widen,
what a guest already holds. The backing names go through the same validation as
any other, so an alias is not a way to share a system index.
A share names concrete indices of yours. Patterns and lists-as-patterns
(logs-*), _all, every dot-index (.xerj_sessions, .xerj_api_tokens,
.xerj_passkeys, .xerj_audit, …), the reserved .xerj-memory-* namespace and
the autoindex-catalog index are refused with 400. A brain's links are shared by naming the brain, which
grants its edges index and nothing else in the namespace.
| Threat | What stands in the way |
|---|---|
| Someone finds or guesses the link | The share id is 128 bits from the OS CSPRNG. The link alone opens nothing: the passcode is a second factor sent separately. |
| Someone has the link and guesses passcodes | A lockout per share, from anywhere: 10 attempts a minute and 30 an hour (SHARE_PER_MINUTE, SHARE_PER_HOUR). A generated passcode is 8 symbols from a 31-symbol alphabet — 852,891,037,441 codes, about 39.6 bits. Thirty guesses an hour for the 30-day maximum lifetime is 21,600 guesses. |
| The node's disk is read | shares.json (mode 0600) holds a SHA-256 digest of the share id and an Argon2id hash of the passcode (m=19456,t=2,p=1 in the records this build writes). No share id, passcode or guest key is written to shares.json, to api_keys.json or to the audit log. The CLI and the guest page never put a share id in a URL, so the node's own log does not hold one either, also with logging.access_log = true (the smoke script turns it on to check exactly that). |
| The link leaks into a log | The share id is never part of a URL the guest page requests. It rides in the URL fragment, which a browser does not send to a server, so the page request and every Referer are free of it. The page then sends it in the body of POST /_share/claim — not in the path, which is what an access log, a reverse proxy and a tunnel's edge record — and removes it from the address bar. The browser test asserts the id is in no requested URL and only in the claim body. A proxy configured to log request bodies would still record it, together with the passcode. The HTTP API's DELETE /_share/{handle} also accepts the full id; a caller who uses it that way puts the id in a request path, and revokes it. xerj share --revoke takes only the handle. |
| A proxy or browser caches the credential | Every /_share response — success or error, including the 401 and 403 the authentication and authorization layers produce — is Cache-Control: no-store. So is every response to a guest key (search results, documents, refusals), and so are the guest page and its assets. |
| A document tries to attack the guest | Document text is other people's email and is treated as hostile. The page places it with textContent only — there is no innerHTML, eval or HTML parsing in share.js, HTML email bodies are shown as source text, and URLs in documents are not made into links. Independently of that, the page is served with Content-Security-Policy: default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self'; connect-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none', so injected markup could neither run nor fetch. Referrer-Policy: no-referrer, X-Content-Type-Options: nosniff, X-Frame-Options: DENY. |
| The guest wanders | The route allow-list and the index grants above. |
| A link that outlives its purpose | Expiry (default 24 hours, at most 30 days), a claim limit (default 1), xerj share --revoke, and --tunnel revoking on exit. A revoke invalidates every key the share minted; the guest's next request is refused. |
| Junk traffic locks the real guest out | See the next section. |
POST /_share/claim is the only unauthenticated route on the node that hands
out a credential, so it is narrow on purpose: one exact path, POST only, a
body of at most 4 KiB, rate-limited, never cached, and every outcome — success,
wrong passcode, exhausted, expired, revoked, unknown, throttled — goes to the
audit log with the source address. Only the first refusal in a throttle window is audited, so a
flood cannot turn the audit log into its own denial of service.
There are two throttles, and they are deliberately not stacked:
- A claim against a known share is charged to that share's window, from anywhere. This is the passcode lockout, and it does not depend on knowing who is asking.
- A claim against an unknown id is charged to the source address's window (10 a minute, 100 an hour; IPv6 sources are keyed by their /64).
Behind --tunnel — or any reverse proxy on the same host — every request
arrives from 127.0.0.1. A limiter that charged the source address first would
collapse every guest into one bucket, and ten junk requests a minute from
anyone who had found the hostname would answer every real guest with 429.
The fix is not to believe X-Forwarded-For: a direct client writes that header
itself. It is to stop the collapsed bucket from mattering — a real share id is
governed by its own window, which junk cannot touch, and junk is governed by the
source window, where collapsing every tunnel client into one bucket only makes
the bound tighter. The live test floods the source bucket from the same address
as the guest, with a rotating spoofed X-Forwarded-For, and then claims
successfully.
To record each guest's real address in the audit log, declare the proxy the way the Console already requires:
[server]
trusted_proxies = ["127.0.0.1", "::1"]Then, and only then, X-Forwarded-For is read, right to left, skipping declared
proxies — the same reading as the Console's client_ip.rs (#76 S5-4). An
unconfigured node believes nobody. xerj share --tunnel tells you which of the
two your node is doing.
One consequence to accept: someone who has the link can spend that share's 30 guesses an hour and lock the real guest out for the rest of the hour. They cannot get in, and they need the 128-bit link to do it. The alternative — no lockout for holders of the link — is a passcode that can be brute-forced.
- The guest. Read access is read access. A guest can copy, screenshot or retype anything they can open, and there is no watermarking or download control. Share an index you are content for that person to read in full.
- No per-document or per-field restriction. A share grants whole indices. The engine has no document-level or field-level security (SECURITY_MODEL.md). If a folder holds things the guest must not see, index a folder that does not.
- Link and passcode sent together. Whoever holds both is the guest, until the share expires, is used up, or is revoked.
- An observer on the path. A local link is plain
httpon loopback. A link to a LAN or publichttp://address is readable by anyone on the path, and the command says so. A--tunnellink is HTTPS to Cloudflare, which terminates it and can read the passcode, the guest key and the documents (see above). For end-to-end TLS you control, publish the node behind your own certificate. - A compromised guest browser. The guest key lives in that tab's
sessionStorageuntil the tab closes or the guest signs out. - Successful reads are only partly audited. The audit log records every
claim outcome, every
_search, and every request an authenticated key was refused (403) — including a guest's attempt to list, create or revoke a share or to mint an API key. It does not record a successful_msearch,_mget,_count,_mapping,_field_capsorGET _doc: a guest can read the whole shared index through those without leaving a line. It records nothing for an unauthenticated request (401) other than a claim (SECURITY_MODEL.md).
XERJ makes no legal claim about any of this. Whether sharing documents this way satisfies a professional, contractual or regulatory duty is a question for the people involved and their advisers, not for this document.
The Console's own sign-in is a passkey. A passkey is bound to the hostname it was created on — that binding is what makes it phishing-resistant — and a quick tunnel gets a new random hostname every time it starts. A passkey enrolled through one tunnel would be unusable through the next, so a guest cannot be given one over a quick tunnel.
That is why a share is a link plus a passcode. The path to guest passkeys is a
stable hostname: a named Cloudflare Tunnel on your own domain (optionally
with Cloudflare Access in front of it), or any reverse proxy with your own
certificate, passed to xerj share as --public-url. Guest passkeys are not
built; the stable hostname is the prerequisite for building them.
The page is xerj-ux/share/, bundled into the binary and served at
/_xerj-console/share without a session — the guest has no credential until the
page has exchanged the passcode for one. It is buildless and self-contained: no
third-party script, font, stylesheet or image, and the browser test asserts that
every request it makes goes to the node itself.
-
It reads the share id from
location.hash. -
It asks for the passcode and posts
{id, passcode}toPOST /_share/claim. The id is in the body, never in a URL. -
On success it stores the session record, in
sessionStorage(scoped to the tab, gone when it closes):sessionStorage['xerj.share'] = JSON.stringify({api_key, index, brain, expires_at, label})
The Console's guest mode reads the same record, which is why its shape is fixed. Beside it the page keeps
xerj.share.link: the share's public handle (the 12 charactersxerj share --listshows), which cannot be turned back into the link. That is how the page recognises the same link opened again in the same tab and keeps the session instead of asking for — and spending — another claim. A different link asks for its own passcode, and the session the tab holds is replaced only when that claim succeeds. A link pasted over another one changes only the fragment; the page listens for that. -
It learns the mapping and searches: the
hybridquery (BM25 fused with the node's embedder) where the index has asemantic_textfield,multi_matchover the text fields otherwise. It says which on the page. The default embedder is lexical feature hashing, so hybrid here is not "semantic AI": it ranks by word and sub-word overlap. A hybrid hit that contains none of the query's words is labelled as ranked by vector similarity, and when no listed hit contains them the page says "No document contains …" before the list. -
It shows the expiry, a read-only notice, and a sign-out that clears
sessionStorage. On atrycloudflare.comhostname it also shows that Cloudflare carries the connection and can read it.
| Route | Who | Body / result |
|---|---|---|
POST /_share |
admin key | {index: "a" | ["a","b"], brain?, expires_in?: "24h", max_claims?: 1, passcode?, label?} → the record plus share_id, url_path, passcode — the only time either plaintext is shown |
GET /_share |
admin key | {shares: [{handle, label, indices, brain, created_at, expires_at, max_claims, claims, claims_left, status}]} — no ids, no hashes |
DELETE /_share/{handle} |
admin key | {handle, revoked: true, keys_invalidated}. The full share id is accepted in place of the handle, but then it is in the request path; the CLI sends only the handle. |
POST /_share/claim |
anyone, rate-limited | {id, passcode} → {api_key, index, indices, brain, expires_at, label, claims_left}; 401 wrong passcode, 404 unknown link, 410 expired / used up / revoked, 429 with Retry-After, 409 on an open node. The share id is in the body so that it is not in the request line an access log records. |
api_key is ready for Authorization: ApiKey <api_key>. index is the
comma-joined list, which is a valid multi-index expression for
/{index}/_search.
(cd engine && cargo build --release -p xerj-server)
bash .github/scripts/share-links-smoke.shThe script boots throwaway nodes on 127.0.0.1:9510–9512 (override with
XERJ_SHARE_PORT), and refuses to run against a port that already answers. It
runs the live security test with and without a trusted proxy (the first node
with logging.access_log = true, so that "no share id in the node's log" is
checked against a node that logs), the CLI — including --tunnel against a stub
cloudflared — the headless-browser guest flow (Chrome or Chromium;
CHROME_BIN to point at one), the open-node refusal, and the xerj brain →
xerj share <folder> path. Do not start it on port 10080 or any other port
browsers refuse (ERR_UNSAFE_PORT): the browser phase cannot reach the node
there. CI
runs the same script in the usecase-smoke job.
The record of what was run on this branch, on which build, with which result — including the by-hand quick-tunnel run that CI cannot repeat — is docs/usecases/share-links/VERIFICATION.md.