Docs / Serve your data from your own network

Serve your data from your own network

Run Maplith Bridge next to your exports: Maplith reads the files you choose, on your schedule, and never holds a password to your systems.

Keep your map fresh without uploading exports by hand, and without giving Maplith a password to any of your systems. You run Maplith Bridge, a small, read-only server, next to your data. Maplith reads the files it serves, on the schedule you set, and you can cut it off at any moment.

Maplith holds no database credential. You run the Bridge, inside your own network, and you decide which files it serves. The only thing Maplith holds is the token for it, which you create and can replace.

What the Bridge does

The Bridge is maplith bridge, a command of the npm package maplith (the file bridge.js; maplith-bridge is the same command under its older name), with the server (serve.js) and the PostgreSQL extractor (extract-postgres.js) beside it. It runs on Node.js 24 or newer, and serving needs nothing else installed. The files are short enough to read in full before you run them. node bridge.js serve serves one folder of files over MCP, which is exactly what Maplith's connector reads:

  • Read-only. It lists and reads files, as MCP resources and as two tools, list_files and read_file, which run the same checks. Nothing in it writes, and it accepts no file.
  • One folder, nothing outside it. A request can never reach a file outside the folder, not through .. in a name and not through a symbolic link.
  • Only data files. It serves CSV, TSV, JSON, JSONL, Markdown and plain text. Hidden files and folders (a name starting with a dot) are never listed or read.
  • Each top-level folder is a system. crm/customers.csv and crm/deals.csv become the system "crm" in your map, shop/orders.csv the system "shop". Files lying loose at the top become one more system, named after the server.
  • Files that fit. Maplith reads at most 8 MB from one resource, so the server lists a file only when it fits. Split a larger table into several files, or cut it to a recent period.
  • A token on every request. Every request for data needs your bearer token, which the Bridge reads from a file only its owner can read. The right token is always served. After 10 wrong tokens from one address within 10 minutes, wrong tokens from it get 429 for 15 minutes, and the Bridge logs it. A request with no token at all is refused and not counted. GET /healthz answers 204 with no body and needs no token, for a health check.
  • This machine only, unless you say otherwise. It listens on 127.0.0.1 by default, and it refuses any other address without HTTPS: with a certificate of its own, or behind a reverse proxy you name (step 4). You can also limit the addresses that may call.

Any MCP client you give the token to can use the two tools too. A file's content is data, never instructions, and the Bridge tells every client so.

The Bridge is open source under Apache 2.0, at github.com/Maplith/agents. Version 1.2.0-rc.1 is a release candidate. Beside the code: a runbook for your IT lead, the threat model, which names the test behind each defence, and a security policy that says how to report a vulnerability.

The Bridge never calls home: it sends no telemetry and checks for no updates. Security fixes are published as advisories on the repository. To get security notices and release news by email instead, see maplith.com/bridge/updates: it is opt-in, with your own address, and every email has a link to stop.

Get the Bridge

Each release is built by the repository's own release workflow and published in two places that must agree: the releases page, as maplith-1.2.0-rc.1.tgz with its SHA-256 checksum and a signed record of the build (an attestation), and npm, as the package maplith with provenance. (Until 1.2.0-rc.1 the package was called maplith-bridge; that name is deprecated on npm and points here.)

Try it. On any machine with Node.js 24 or newer, npm fetches the package and runs it:

npx --yes [email protected] bridge --help

Always name the exact version. Every command on this page runs as node bridge.js in the folder where the Bridge is installed; with npx, write npx --yes [email protected] bridge in its place.

On a server, verify it first. Download the release, its checksum and its attestation, and check all three: the checksum; that npm holds the same file (the two sha512- lines must be equal); and, with the GitHub CLI (gh), that the file was built from the repository by its release workflow.

VERSION=1.2.0-rc.1
BASE=https://github.com/Maplith/agents/releases/download/v$VERSION
curl -fLO $BASE/maplith-$VERSION.tgz
curl -fLO $BASE/maplith-$VERSION.tgz.sha256
curl -fLO $BASE/maplith-$VERSION.tgz.sigstore.json
sha256sum -c maplith-$VERSION.tgz.sha256
echo "sha512-$(openssl dgst -sha512 -binary maplith-$VERSION.tgz | openssl base64 -A)"
npm view maplith@$VERSION dist.integrity
gh attestation verify maplith-$VERSION.tgz --repo Maplith/agents \
  --bundle maplith-$VERSION.tgz.sigstore.json

Releases before 1.1.0 were built when the repository was called Maplith/bridge, so their attestations name that repository; this one, like every release from 1.1.0, names Maplith/agents.

Then unpack it into one folder, for example /opt/maplith-bridge, and install the PostgreSQL client the extractor uses:

mkdir -p /opt/maplith-bridge && tar -xzf maplith-1.2.0-rc.1.tgz -C /opt/maplith-bridge --strip-components=1
cd /opt/maplith-bridge
npm ci --ignore-scripts --omit=dev
node bridge.js --version

npm ci installs one package, pg, pinned to the exact version Maplith tested (pg 8.23.1), and the packages it needs. The package's npm-shrinkwrap.json pins every file it installs by its checksum, and no install script runs. Success prints a line that starts with added, such as added 14 packages. Serving needs nothing but Node.js.

The runbook takes it from here on a Linux server, in about an hour: two system users, so the process the network reaches never holds the database password, the read-only role, a nightly extract, and a service behind HTTPS. The steps below say the same for each part.

Or let your own agent install it. Claude Code, or any agent that can run commands on the server, can follow the runbook for you: Install with your agent has a prompt to paste. The agent verifies the download in both places before it reads anything from it, stops for you at every decision and every sudo, and never asks for, sees or prints a password or the Bridge's token: where a step needs one, it gives you the command to run in your own terminal.

1. Put the files in a folder

Maplith reads only what you put in the folder, so what goes in is your choice. Use one subfolder per system, with the files your systems' own export buttons or jobs produce. The same recent period from every system gives the best map: a few months is enough.

By default a scan reads one source at a time. Put every system you want read together in this folder. Where combined scanning is available, a workspace admin can enable One map from your sources under Settings › Data & retention after queued and running scans finish. Each later scan reads all current connections and its new upload; earlier uploads keep their map without their files being read again. The switch is one way. Data sources › Scans shows which sources were read, partly read, failed, held or removed, with file counts. A failed source keeps its previous map; it is not treated as an empty export. A connection request can join a waiting combined scan without starting another one. If that upload fails, the joined connection request is queued without the upload. On Data sources › Scans, a combined map can only be restored from another combined scan.

Leave out what must not be processed. Maplith tries to mask personal data before anything reaches the model, but that is best effort: you decide what the folder contains.

Keeping it fresh. Refresh the folder on a schedule of your own, for example a nightly cron job, so it is complete before Maplith's scheduled scan (from 05:30 Copenhagen time). Write each file under a name starting with a dot and rename it when it is complete: the server never serves a hidden file, so a scan never reads half a file.

2. From a database: a read-only role on the tables you choose

If the data lives in a PostgreSQL database, fill the folder under a role that can read only the tables you choose: with Maplith's extractor, which you run in your own network next to the server, or with an export job of your own. Either way, the password stays with you: Maplith never holds it.

Run this as a database owner or administrator, with your own names in place of the angle brackets:

-- A login role for the export, and nothing else.
CREATE ROLE maplith_export LOGIN PASSWORD '<a long random password>'
  NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOBYPASSRLS;

-- Every session of the role is read-only, as a second safeguard.
-- The real guarantee is that the role is granted nothing that writes.
ALTER ROLE maplith_export SET default_transaction_read_only = on;

-- Access to the database and to the schema that holds the tables.
GRANT CONNECT ON DATABASE <your_database> TO maplith_export;
GRANT USAGE ON SCHEMA <your_schema> TO maplith_export;

-- Read access to the tables you choose, one by one.
GRANT SELECT ON <your_schema>.<table_one>, <your_schema>.<table_two> TO maplith_export;

-- Or only some columns of a table: leave out what must not be read.
GRANT SELECT (<column_a>, <column_b>, <column_c>) ON <your_schema>.<table_three> TO maplith_export;

Then check what the role can read, and that it can write nothing:

-- Every column the role can read: only the tables and columns you chose.
SELECT table_schema, table_name, column_name, privilege_type
FROM information_schema.column_privileges
WHERE grantee = 'maplith_export'
ORDER BY 1, 2, 3, 4;

-- Anything but reading: this should return no rows.
SELECT table_schema, table_name, privilege_type
FROM information_schema.role_table_grants
WHERE grantee = 'maplith_export' AND privilege_type <> 'SELECT';

Every row of the first answer should say SELECT, on a column you chose.

Do not give the role a blanket read role. pg_read_all_data reads every table, view and sequence in the database, including the ones you add next year. The same goes for pg_read_server_files, pg_monitor and pg_read_all_stats, for superuser, for BYPASSRLS (which ignores row security), for GRANT SELECT ON ALL TABLES IN SCHEMA, and for ALTER DEFAULT PRIVILEGES … GRANT SELECT, which grants every future table without anyone deciding. Grant table by table, so a new table is read only when you add it.

Run the extractor

The extractor is one more file in the Bridge, extract-postgres.js, that you run with node bridge.js extract postgres. It is short enough to read before you run it. It connects as maplith_export and writes one CSV file per table into the folder the server serves.

  • It refuses a role with any privilege on its list. Before it reads a row, it checks the role and every role it is a member of. It stops, and names what it found, if the role: - is a superuser, may create roles or databases, bypasses row security, or may replicate; - is a member of a built-in role such as pg_read_all_data; - may change any table, view, column, sequence, large object, schema or database, or create objects in one; - may pass any of its access on: a grant option (WITH GRANT OPTION) on anything, or the admin option on a group (WITH ADMIN OPTION); - owns anything: a table, function, type, large object, schema, database, or another catalog object, such as a publication for replication or an extension; - may run a function with its owner's rights (SECURITY DEFINER), by a grant to the role or a group it is in; - may use a foreign-data wrapper or a foreign server; - on PostgreSQL 15 or newer, may change a server parameter: a grant of SET or ALTER SYSTEM on it.

It also stops when large-object access checks are switched off (lo_compat_privileges), and when the session logged in as another role and switched to this one. That is the list it checks; together with the read-only transaction below, it is what the extractor promises, not a proof that PostgreSQL offers no other way to write.

  • It runs only its own statements. It reads in one read-only transaction, with a time limit on every statement and a deadline for the run. It takes no SQL from you: what it reads is what you granted.
  • Your password stays on your machine. The extractor hands it to the PostgreSQL client and to nothing else. It never prints it, never writes it to a file, and connects to your database and nowhere else.
  • What it reads. The tables, views and materialized views the role can read, and only the columns you granted. Foreign tables are left out, and named in its output: a read there would run on another server. Each becomes <schema>/<table>.csv, so each schema is a system in your map. With --system <name>, every table goes to <name>/<schema>.<table>.csv instead, as one system.
  • The same files every time. The rows are written in primary-key order, or in the order of every column when a table has none, so two runs on the same data write the same CSV files. A table is cut at 50,000 rows (--max-rows), or sooner if the file would pass the 8 MB the server serves. A cut file opens with one line that says so; for a table with a single column, the cut is recorded in .maplith-extract.json instead.
  • Each file appears whole. It is written under a hidden name and renamed when complete. Beside the files, the hidden .maplith-extract.json records what was read: the columns and their types, the declared keys, the catalog's estimate of each table's rows, how many values are empty or distinct, and which tables were cut. It holds counts and names, never a value, and the server never serves it. The estimates come from PostgreSQL's sampled statistics, so they can change after your database runs ANALYZE even when the data has not; the CSV files do not.
  • What it removes. A table you stop granting has its file removed on the next run. It removes only files it wrote itself, never others. A file of your own at the same path as a table's file, for example a hand export of that table, is overwritten.

What you need: Node.js 24 or newer (node --version prints v24 or higher), and the Bridge installed as in Get the Bridge, with npm ci run once in its folder: the extractor needs the PostgreSQL client it installs. Commands below run in that folder.

Set the connection. Run the extractor as its own system user, not the one that runs the server, so the password never sits next to the process the internet reaches. Give that user the password in a ~/.pgpass file only it can read (chmod 600), typed at a prompt that does not echo it, so it is in no command line, environment variable or script. A : or \ in the password is written \: or \\:

bash -c 'umask 077; read -rs -p "Password: " pw; echo;
  printf "%s:%s:%s:%s:%s\n" "<host>" 5432 "<your_database>" maplith_export "$pw" > ~/.pgpass'
export PGHOST=<host> PGPORT=5432 PGDATABASE=<your_database>
export PGUSER=maplith_export PGSSLMODE=verify-full

The PostgreSQL client reads ~/.pgpass itself. PGPASSWORD and DATABASE_URL work too, but they put the password in the environment.

TLS. The extractor connects with a verified certificate to any database that is not on the same machine, unless you choose a mode yourself with PGSSLMODE or a parameter in DATABASE_URL. A DATABASE_URL with no host follows PGHOST. Choose verify-full. In the PostgreSQL client the extractor uses, sslmode=no-verify, and uselibpqcompat=true together with sslmode=require or prefer, turn certificate checking off.

The files it writes are readable by their owner and group only (0640), and the folders it creates 0750. Give the folder to the extractor's user, with the server's user as its group, and chmod 2750 it, so the server can read the files and nobody else can.

Check the role. --check connects, runs every check and lists the tables it would read, and writes nothing:

node bridge.js extract postgres --out /path/to/exports --check

Success lists one line per table and ends with OK: the role is read-only and can read <n> tables. Nothing was written. A table you expected and do not see is not granted to maplith_export.

Run it.

node bridge.js extract postgres --out /path/to/exports

Success prints one line per file, such as shop/orders.csv: 1200 rows, and ends with OK: wrote <n> files to /path/to/exports. The exit code is 0. Any failure prints one message and exits with 1. Every file it finished is whole; tables it did not reach keep the file from the last good run, so the folder can hold parts of two runs until the next run succeeds.

Keep it fresh. Schedule the run, for example nightly with cron, so it finishes before Maplith's scheduled scan (from 05:30 Copenhagen time):

# crontab -e, with the variables above set in the job's environment
0 3 * * * cd /opt/maplith-bridge && node bridge.js extract postgres --out /path/to/exports

The options:

OptionWhat it does
--out <folder> or EXPORT_DIRThe folder the server serves. Required.
--system <name>Put every table under one system folder.
--max-rows <n>Rows per table at most. Default 50,000.
--statement-timeout <seconds>The limit on each statement. Default 300.
--deadline <minutes>The limit on the whole run. Default 60.
--checkCheck the role and list the tables; write nothing.

If it refuses. The message starts with Refusing to run and lists each thing it found, one per line. Fix each one as a database owner or administrator, then run --check again. <role> and <object> are the names in the message. When <role> is not maplith_export, it is a role maplith_export is a member of: revoke the privilege from that role, or end the membership with REVOKE <role> FROM maplith_export;.

The message saysDo this
<role> has the attribute SUPERUSER (or CREATEROLE, CREATEDB, BYPASSRLS, REPLICATION)ALTER ROLE <role> NOSUPERUSER; (or NOCREATEROLE, NOCREATEDB, NOBYPASSRLS, NOREPLICATION). For a superuser, create the separate maplith_export role from step 2 instead.
the role is a member of pg_…REVOKE pg_… FROM maplith_export;
<role> may change <schema>.<table>REVOKE INSERT, UPDATE, DELETE, TRUNCATE, REFERENCES, TRIGGER ON <schema>.<table> FROM <role>; On PostgreSQL 17 or newer, add MAINTAIN to the list. If the grant was made to PUBLIC, revoke it from PUBLIC.
<role> may advance or set the sequence <object>REVOKE USAGE, UPDATE ON SEQUENCE <object> FROM <role>;
<role> owns <object>Give the object another owner, for example ALTER TABLE <object> OWNER TO <another_role>;. A login role for the extractor should own nothing.
<role> may create objects in, or owns, the schema <object>REVOKE CREATE ON SCHEMA <object> FROM <role>; If it is public on PostgreSQL 14 or older: REVOKE CREATE ON SCHEMA public FROM PUBLIC;
<role> may create schemas in, or owns, the database <object>REVOKE CREATE ON DATABASE <object> FROM <role>;
<role> may write the large object <oid>REVOKE UPDATE ON LARGE OBJECT <oid> FROM <role>; If the grant was made to PUBLIC, revoke it from PUBLIC.
lo_compat_privileges is onAs a superuser: ALTER SYSTEM SET lo_compat_privileges = off; SELECT pg_reload_conf(); (or ALTER ROLE <role> RESET lo_compat_privileges; where it was set for one role).
<role> may run the function <object> with its owner's rightsREVOKE EXECUTE ON FUNCTION <object> FROM <role>;
<role> may grant <privilege> on <object> to other roles (WITH GRANT OPTION)REVOKE GRANT OPTION FOR <privilege> ON <object> FROM <role>; The privilege itself stays.
<role> may add other roles to <group> (WITH ADMIN OPTION)REVOKE ADMIN OPTION FOR <group> FROM <role>;
<role> may change the server parameter <object>REVOKE SET, ALTER SYSTEM ON PARAMETER <object> FROM <role>;
<role> may use the foreign-data wrapper <object> (or the foreign server)REVOKE USAGE ON FOREIGN DATA WRAPPER <object> FROM <role>; (or REVOKE USAGE ON FOREIGN SERVER <object> FROM <role>;)
the session logged in as … and switched to …Connect with PGUSER=maplith_export itself, not as another user who then switches role.
the transaction is not read-onlyWrite to [email protected] with the message.

If it warns. A line that starts with Warning: every role in this database names functions that run with their owner's rights (SECURITY DEFINER), which every role may call because PostgreSQL grants EXECUTE on a function to PUBLIC unless it was revoked. Such a function can write, whatever its volatility, so the extractor names them. It still runs: it never calls them, and its transaction is read-only, so a write inside one would fail. It is a property of the whole database rather than of maplith_export, so there is no grant to that role to take back. If no application needs to call a function through PUBLIC, close it for every role: REVOKE EXECUTE ON FUNCTION <object> FROM PUBLIC;, then grant it to the roles that call it.

Other messages.

The message saysDo this
The pg package did not loadRun npm ci --ignore-scripts --omit=dev in the folder that holds the files.
password authentication failedCheck PGUSER and PGPASSWORD. The password in the message, if any, is shown as [redacted].
A certificate error, such as self-signed certificateYour database's certificate is not trusted by Node.js. Point NODE_EXTRA_CA_CERTS at your certificate authority's file. Do not turn verification off.
Stopped: the run passed its deadlineRaise --deadline, or grant fewer tables.
canceling statement due to statement timeoutA table took longer than --statement-timeout to read. Raise it, or lower --max-rows.

Or export by hand

Export each table to a CSV file in its system's folder. With psql, for example, which writes the file on your side:

psql "host=<host> dbname=<your_database> user=maplith_export sslmode=verify-full" \
  -c "\copy (SELECT <column_a>, <column_b>, <column_c> FROM <your_schema>.<table_three>) TO 'exports/<system>/.<table_three>.csv' WITH (FORMAT csv, HEADER)"
mv exports/<system>/.<table_three>.csv exports/<system>/<table_three>.csv

Name the columns rather than SELECT * where you granted only some of them.

3. Run the server

Create the token. The Bridge writes a long random one to a file only its owner can read, and prints where it is, never the token itself:

node bridge.js rotate-token --token-file /etc/maplith-bridge/token

Then start the server on the folder:

node bridge.js serve --dir /path/to/exports --token-file /etc/maplith-bridge/token

It prints its address, where the token is and the steps to connect it in Maplith. It never prints the token: copy it from the file when you connect. It refuses a token file other users can read, one inside the folder it serves, one that is not a regular file of at most 1 KB, and any token but one rotate-token wrote: 64 random lowercase hexadecimal characters, with patterned strings refused. Do not write a token by hand. It checks the file again each time it changes.

The server listens on 127.0.0.1:8787 by default, so it is not reachable from outside until you serve it over HTTPS (step 4). node bridge.js serve --help lists every option.

4. Serve it over HTTPS

Maplith's connector reads only over HTTPS, and only from a public address: it refuses a name that resolves to a private network. The Bridge refuses to listen beyond this machine in plain HTTP, so choose one of two:

  • HTTPS in the Bridge. Give it a certificate and its key, and a public address: node bridge.js serve --dir /path/to/exports --token-file /etc/maplith-bridge/token --host 0.0.0.0 --port 8443 --tls-cert cert.pem --tls-key key.pem. Its address is then https://<your-host>:8443/mcp. Restart it after you renew the certificate.
  • A reverse proxy in front, such as Caddy or nginx, or your platform's HTTPS edge. Name the proxy's address with --behind-proxy, for example --behind-proxy 127.0.0.1 for a proxy on the same machine: the Bridge then takes requests from the proxy only, and counts failed token attempts by the caller address the proxy forwards. A proxy on another host is named by its addresses, in ranges no wider than /12 for IPv4 or /29 for IPv6, and an IPv4 proxy by its IPv4 address. --behind-proxy any is accepted only with --host 127.0.0.1, for a proxy on the same machine.

--allow <addresses> serves only callers from those IP addresses or ranges. Maplith does not publish fixed addresses it calls from yet, so use it for your own MCP clients, not to name Maplith. The token is the gate: a request without it gets 401.

Check it before you connect it, and never copy the token to another machine to do so. From outside your network, without the token, the answer must be 401: that proves the name, the certificate and the route.

curl -s -o /dev/null -w '%{http_code}\n' -X POST https://<your-host>/mcp \
  -H 'Content-Type: application/json' -d '{"jsonrpc":"2.0","id":1,"method":"ping"}'

On the Bridge's own host, read the token from its file with a shell builtin, so it appears in no command line:

printf 'Authorization: Bearer %s\n' "$(< /etc/maplith-bridge/token)" | \
  curl -s -X POST http://127.0.0.1:8787/mcp -H @- -H 'Content-Type: application/json' \
  -d '{"jsonrpc":"2.0","id":1,"method":"resources/list"}'

The answer lists your files as exports://<system>/<file>. With the token from outside, Test connection in Maplith is the check (step 5).

5. Connect it in Maplith

A workspace admin or builder does this, in the app:

  1. Open Data sources › Systems and choose Add MCP connection.
  2. Give it a label, paste the HTTPS URL as MCP URL, and type the token from the token file as Bearer token. Type the token in the app only, never into a chat with an assistant. Maplith encrypts it at rest, never shows it again and never gives it to an agent.
  3. Test connection shows how many files the server lists.
  4. Review the scan shows the systems and files Maplith recognised, and Start scan builds the map. Follow it in Data sources › Scans.
  5. To keep the map fresh, set Rebuild this map under Settings › Data & retention to every day or every week. Maplith then reads the folder again and reports what changed as signals.

What Maplith reads, and what it keeps

  • Only what the server lists. Maplith reads the files in your folder through the server, and nothing else: not your database, not other folders.
  • Files pass through for a build. The worker reads them, builds the map and deletes them after the scan, exactly as it does with an upload.
  • The map is metadata. It holds structure, not rows: objects, fields, how your systems join and how well each join was measured, findings. A finding can quote evidence and a field description can quote an example value; see Security & access model for what can appear and how masking works.
  • Nothing is left out in silence. Data sources › Scans names any file the connector could not read, and why.

Revoke access

You stay in control, from either side:

  • On your side, at once: run node bridge.js rotate-token with your token file, or stop the server. A running Bridge refuses the old token from its next request, with no restart, and from that moment Maplith can read nothing. To let Maplith read again, remove the connection under Data sources › Systems and add it again with the new token. Maplith checks its connections twice a day, and then raises a link_dead signal, so your agents know the map has stopped following the business.
  • In Maplith: a workspace admin chooses Remove on the connection under Data sources › Systems. The token is deleted with it.
  • Less data: remove a file or a system's folder, and the next scan no longer reads it.
  • The database role: DROP OWNED BY maplith_export; DROP ROLE maplith_export; removes its grants and the role (run the first part in each database you granted access in).

← All docs · Markdown version