fix(self-host): unblock Memory tab when dash is opened via LAN IP

supermemory-server only auto-applies the local API key when Host is
localhost/127.0.0.1/::1. The dash Memory tab never sends Authorization,
so documents and stats 401 on a LAN hostname (#1538).

Document the Host-based auth gap and add a small proxy that injects the
banner API key so the UI works at http://<lan-ip>:6768.
This commit is contained in:
Souravrajvi0 2026-09-02 06:39:46 +00:00
parent 17eab43cd4
commit a503a5a373
5 changed files with 375 additions and 0 deletions

View file

@ -184,6 +184,7 @@
"self-hosting/overview",
"self-hosting/quickstart",
"self-hosting/configuration",
"self-hosting/lan-access",
"self-hosting/embeddings",
"self-hosting/providers",
"self-hosting/local-vs-enterprise"

View file

@ -116,6 +116,12 @@ SUPERMEMORY_EMBEDDING_RAM_LIMIT=4gb ./supermemory-server
Raise the limit and concurrency on machines with spare RAM for faster bulk imports; lower them on small VPSes where you want the server to stay lean and don't mind adds draining slowly.
## Dashboard auth (localhost vs LAN)
The self-hosted binary auto-applies the printed API key only when the request **Host** is `localhost`, `127.0.0.1`, or `::1`. The local dashboard Memory tab does not send `Authorization`, so opening `http://<lan-ip>:6767` loads the overview but **documents / stats return 401**.
Workarounds, a LAN proxy, and why this happens: **[Access the dashboard over LAN](/self-hosting/lan-access)**.
## Telemetry
The self-hosted binary sends no analytics — there is nothing to opt out of. The only related switch:

View file

@ -0,0 +1,91 @@
---
title: "Access the dashboard over LAN"
sidebarTitle: "LAN access"
description: "Why the Memory tab returns 401 off localhost, and how to open the local dash from another device on your network."
icon: "wifi"
---
<Info>
Tracked in [issue #1538](https://github.com/supermemoryai/supermemory/issues/1538) (supermemory-server v0.0.8).
</Info>
The self-hosted dash at `http://localhost:6767` works without pasting the API key. Opening the same UI via the machine's LAN or public IP — `http://192.168.x.x:6767` — still renders the overview, but switching to the **Memory** tab shows **401 Unauthorized** for documents and stats.
## What is going on
On boot the server prints:
```
the api key above is auto-applied for unauthenticated localhost requests.
```
That auto-auth checks the request **Host**, not whether you are physically on the same box. Only these hosts qualify:
- `localhost`
- `127.0.0.1`
- `::1`
A LAN hostname such as `192.168.1.20` or `10.0.0.5` does **not**.
The Memory tab (`/local-console.js`) then calls:
- `POST /v3/documents/documents`
- `GET /v3/container-tags/list`
with **no `Authorization` header**. On localhost those succeed; off localhost they return `{"error":"Unauthorized"}`. Sending the banner key as `Authorization: Bearer sm_…` succeeds on the same IP — which is why a request modifier that injects the header unblocks the UI.
```bash
# 200 on loopback, no header
curl -sS -X POST http://127.0.0.1:6767/v3/documents/documents \
-H 'Content-Type: application/json' -d '{"page":1}'
# 401 on a LAN Host, no header
curl -sS -X POST http://192.168.1.20:6767/v3/documents/documents \
-H 'Content-Type: application/json' -d '{"page":1}'
# 200 on LAN Host once the banner key is sent
curl -sS -X POST http://192.168.1.20:6767/v3/documents/documents \
-H "Authorization: Bearer sm_…" \
-H 'Content-Type: application/json' -d '{"page":1}'
```
SDK and curl clients that already send the key are unaffected. This is a dashboard-only gap.
## Recommended: SSH tunnel (no extra attack surface)
From the laptop you browse on:
```bash
ssh -L 6767:127.0.0.1:6767 user@the-linux-box
```
Open `http://localhost:6767` locally. Host stays loopback, auto-auth applies, the Memory tab works.
## Option: inject the API key for LAN browsing
If you need to open the dash at `http://<lan-ip>:…` in a browser, run the proxy in this repo in front of supermemory-server. It forwards traffic and adds `Authorization: Bearer <api-key>` when the Memory tab omits it.
<Warning>
Anyone who can reach the proxy can use the local API as the instance owner. Bind it to a trusted network only — do not expose it to the public internet.
</Warning>
```bash
# supermemory-server already running on :6767
SUPERMEMORY_DATA_DIR=./.supermemory \
node scripts/lan-dashboard-proxy.mjs --listen 0.0.0.0:6768
```
Then open `http://<this-machine-ip>:6768` (not `:6767`).
| Flag | Default | Meaning |
| --- | --- | --- |
| `--target` | `http://127.0.0.1:6767` | Upstream supermemory-server |
| `--listen` | `0.0.0.0:6768` | Address the browser should use |
| `--data-dir` | `SUPERMEMORY_DATA_DIR` or `./.supermemory` | Directory with the `api-key` file |
| `--api-key` | unset | Override instead of reading `api-key` |
You can keep using a request modifier that adds the same `Authorization` header directly against `:6767`; the proxy is that behavior without a browser extension.
## What a first-class server option would look like
A durable fix belongs in supermemory-server: either send the key from `/local-console.js`, or treat additional Hosts as local behind an explicit allowlist (for example `SUPERMEMORY_TRUSTED_HOSTS`). Until that ships in a `server-v*` release, use a tunnel or the proxy above.

180
scripts/lan-dashboard-proxy.mjs Executable file
View file

@ -0,0 +1,180 @@
#!/usr/bin/env node
/**
* Reverse proxy that injects the local supermemory-server API key so the
* dashboard Memory tab works when you open it via a LAN / public IP.
*
* supermemory-server (v0.0.8) only auto-applies that key when the request
* Host is localhost / 127.0.0.1 / ::1. The dash JS never sends Authorization,
* so POST /v3/documents/documents returns 401 off-loopback.
*
* Usage:
* SUPERMEMORY_DATA_DIR=./.supermemory node scripts/lan-dashboard-proxy.mjs
* bun scripts/lan-dashboard-proxy.mjs --target http://127.0.0.1:6767 --listen 0.0.0.0:6768
*
* Then open http://<this-machine-ip>:6768 instead of :6767.
*/
import http from "node:http"
import fs from "node:fs"
import path from "node:path"
import { pathToFileURL } from "node:url"
const HOP_BY_HOP = new Set([
"connection",
"keep-alive",
"proxy-authenticate",
"proxy-authorization",
"te",
"trailers",
"transfer-encoding",
"upgrade",
])
/** Hostnames the self-hosted binary treats as local (loopback auto-auth). */
export function isLoopbackHostname(hostname) {
const host = hostname.replace(/^\[|\]$/g, "").toLowerCase()
return host === "localhost" || host === "127.0.0.1" || host === "::1"
}
export function pickAuthorization(existingHeader, apiKey) {
const existing = existingHeader?.trim()
if (existing) return existing
if (!apiKey) return null
return `Bearer ${apiKey.trim()}`
}
export function resolveApiKeyFile(dataDir = process.env.SUPERMEMORY_DATA_DIR) {
const dir = dataDir?.trim() || path.resolve(".supermemory")
return path.join(dir, "api-key")
}
export function readApiKeyFile(filePath) {
const raw = fs.readFileSync(filePath, "utf8").trim()
if (!raw) throw new Error(`API key file is empty: ${filePath}`)
return raw
}
export function parseListen(value) {
const raw = value?.trim() || "0.0.0.0:6768"
const idx = raw.lastIndexOf(":")
if (idx <= 0 || idx === raw.length - 1) {
throw new Error(`Invalid --listen value: ${value}`)
}
const host = raw.slice(0, idx)
const port = Number(raw.slice(idx + 1))
if (!Number.isInteger(port) || port < 1 || port > 65535) {
throw new Error(`Invalid --listen port: ${value}`)
}
return { host, port }
}
export function parseArgs(argv) {
const out = {
target: process.env.SUPERMEMORY_PROXY_TARGET || "http://127.0.0.1:6767",
listen: process.env.SUPERMEMORY_PROXY_LISTEN || "0.0.0.0:6768",
dataDir: process.env.SUPERMEMORY_DATA_DIR,
apiKey: process.env.SUPERMEMORY_API_KEY,
}
for (let i = 0; i < argv.length; i++) {
const arg = argv[i]
const next = argv[i + 1]
if (arg === "--target" && next) {
out.target = next
i++
} else if (arg === "--listen" && next) {
out.listen = next
i++
} else if (arg === "--data-dir" && next) {
out.dataDir = next
i++
} else if (arg === "--api-key" && next) {
out.apiKey = next
i++
} else if (arg === "--help" || arg === "-h") {
out.help = true
}
}
return out
}
function copyRequestHeaders(incoming, apiKey) {
const headers = {}
for (const [key, value] of Object.entries(incoming.headers)) {
if (value == null) continue
if (HOP_BY_HOP.has(key.toLowerCase())) continue
headers[key] = value
}
const auth = pickAuthorization(
Array.isArray(incoming.headers.authorization)
? incoming.headers.authorization[0]
: incoming.headers.authorization,
apiKey,
)
if (auth) headers.authorization = auth
return headers
}
export function createLanDashboardProxy({ target, apiKey }) {
const targetUrl = new URL(target)
return http.createServer((req, res) => {
const incomingUrl = new URL(req.url || "/", `http://${req.headers.host}`)
const options = {
protocol: targetUrl.protocol,
hostname: targetUrl.hostname,
port: targetUrl.port || (targetUrl.protocol === "https:" ? 443 : 80),
method: req.method,
path: `${incomingUrl.pathname}${incomingUrl.search}`,
headers: copyRequestHeaders(req, apiKey),
}
const upstream = http.request(options, (up) => {
res.writeHead(up.statusCode ?? 502, up.headers)
up.pipe(res)
})
upstream.on("error", (err) => {
if (!res.headersSent) {
res.writeHead(502, { "content-type": "application/json" })
}
res.end(JSON.stringify({ error: "Bad gateway", details: err.message }))
})
req.pipe(upstream)
})
}
function printHelp() {
process.stdout.write(`Inject the local API key so the Memory tab works off localhost.
Usage:
node scripts/lan-dashboard-proxy.mjs [--target URL] [--listen HOST:PORT] [--data-dir DIR]
Options:
--target supermemory-server URL (default http://127.0.0.1:6767)
--listen bind address (default 0.0.0.0:6768)
--data-dir directory containing api-key (default SUPERMEMORY_DATA_DIR or ./.supermemory)
--api-key override key instead of reading api-key file
`)
}
function isMain() {
try {
return import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href
} catch {
return false
}
}
if (isMain()) {
const args = parseArgs(process.argv.slice(2))
if (args.help) {
printHelp()
process.exit(0)
}
const apiKey =
args.apiKey?.trim() || readApiKeyFile(resolveApiKeyFile(args.dataDir))
const listen = parseListen(args.listen)
const server = createLanDashboardProxy({ target: args.target, apiKey })
server.listen(listen.port, listen.host, () => {
process.stdout.write(
`lan-dashboard-proxy listening on http://${listen.host}:${listen.port} → ${args.target}\n`,
)
})
}

View file

@ -0,0 +1,97 @@
import { describe, it } from "node:test"
import assert from "node:assert/strict"
import http from "node:http"
import {
createLanDashboardProxy,
isLoopbackHostname,
parseArgs,
parseListen,
pickAuthorization,
} from "./lan-dashboard-proxy.mjs"
describe("isLoopbackHostname", () => {
it("matches the self-hosted binary's local-auth Hosts", () => {
assert.equal(isLoopbackHostname("localhost"), true)
assert.equal(isLoopbackHostname("127.0.0.1"), true)
assert.equal(isLoopbackHostname("::1"), true)
assert.equal(isLoopbackHostname("[::1]"), true)
})
it("rejects LAN and public Hosts that currently 401", () => {
assert.equal(isLoopbackHostname("192.168.1.10"), false)
assert.equal(isLoopbackHostname("172.31.13.46"), false)
assert.equal(isLoopbackHostname("10.0.0.5"), false)
assert.equal(isLoopbackHostname("example.local"), false)
})
})
describe("pickAuthorization", () => {
it("keeps a caller-supplied header", () => {
assert.equal(pickAuthorization("Bearer already", "sm_new"), "Bearer already")
})
it("injects the local key when the Memory tab sends none", () => {
assert.equal(pickAuthorization(undefined, "sm_local"), "Bearer sm_local")
assert.equal(pickAuthorization(" ", "sm_local"), "Bearer sm_local")
})
it("does not invent a header without a key", () => {
assert.equal(pickAuthorization(undefined, ""), null)
})
})
describe("parseListen / parseArgs", () => {
it("parses host:port", () => {
assert.deepEqual(parseListen("0.0.0.0:6768"), { host: "0.0.0.0", port: 6768 })
})
it("reads CLI flags", () => {
assert.deepEqual(
parseArgs(["--target", "http://127.0.0.1:9", "--listen", "127.0.0.1:9"]),
{
target: "http://127.0.0.1:9",
listen: "127.0.0.1:9",
dataDir: process.env.SUPERMEMORY_DATA_DIR,
apiKey: process.env.SUPERMEMORY_API_KEY,
},
)
})
})
describe("createLanDashboardProxy", () => {
it("injects Authorization for unauthenticated Memory-tab requests", async () => {
let seenAuth
const upstream = http.createServer((req, res) => {
seenAuth = req.headers.authorization
res.writeHead(200, { "content-type": "application/json" })
res.end(JSON.stringify({ documents: [], via: req.url }))
})
await new Promise((resolve) => upstream.listen(0, "127.0.0.1", resolve))
const upstreamPort = upstream.address().port
const proxy = createLanDashboardProxy({
target: `http://127.0.0.1:${upstreamPort}`,
apiKey: "sm_test_key",
})
await new Promise((resolve) => proxy.listen(0, "127.0.0.1", resolve))
const proxyPort = proxy.address().port
const res = await fetch(
`http://127.0.0.1:${proxyPort}/v3/documents/documents`,
{
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ page: 1 }),
},
)
assert.equal(res.status, 200)
assert.equal(seenAuth, "Bearer sm_test_key")
assert.deepEqual(await res.json(), {
documents: [],
via: "/v3/documents/documents",
})
proxy.close()
upstream.close()
})
})