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
54 changes: 33 additions & 21 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,9 @@ prints only the configured server URL on success; progress/errors use stderr. `-
terminal line, or plain lines when redirected. Failed uploads remain private,
immutable pending submissions; `sync --retry` recovers them without the checkout.
Cancelling a wait does not cancel an accepted server job.
After the destination account has been verified, rotating its API token preserves
pending-upload recovery. Captures made before any successful authentication stay
bound to their original credential until the account is verified.

`diffx .` and piped diffs always run locally, even with remote config. Sync
and plugins require a remote URL and bearer token; they never bootstrap a local
Expand Down Expand Up @@ -175,13 +178,13 @@ mise run docker:down
Compose publishes HTTP on `127.0.0.1:7980`, outside the local 7981–7990 range,
and preserves the named SQLite volume when stopped or recreated. Set
`DIFFX_SERVER_PORT=7979` to publish another host port. The project is named
`diffx`, so its volume is shared by every checkout. Retrieve
the initial credential with `docker compose cp server:/data/state.db.admin-token ./admin-token`.
To choose the credential instead, put `DIFFX_SERVER_TOKEN=…` in a git-ignored
`.env` beside `compose.yaml` before the first start. Any non-empty token works;
prefer a long random one (`openssl rand -hex 32`) when exposing the server.
It only applies to a new database; a different token for an existing account
fails startup.
`diffx`, so its volume is shared by every checkout. Before first start, put
`DIFFX_SERVER_USERNAME=alice` and `DIFFX_SERVER_PASSWORD=…` in a git-ignored
`.env` beside `compose.yaml`. No default account/password is generated. These
bootstrap credentials create the first account once; restarts preserve password
changes. Signup is disabled by default. Compose explicitly enables insecure
cookies for loopback HTTP development; set `DIFFX_SERVER_INSECURE_COOKIES=false`
and deploy behind HTTPS for remote access.
See [server development](docs/development.md#server-development) for configuration
and persistence details. Deploy behind HTTPS when exposing the server remotely.

Expand All @@ -190,12 +193,13 @@ Or build and run the image directly:
```sh
mise run docker:build
docker run --name diffx-server --read-only -p 127.0.0.1:7980:7980 \
--env-file /path/to/private-server.env \
--mount source=diffx-data,target=/data diffx-server:local
```

The volume contains observations, jobs and credentials. First
startup writes the admin token to `/data/state.db.admin-token`; copy it with
`docker cp diffx-server:/data/state.db.admin-token ./admin-token`.
The env file supplies bootstrap username/password; plain HTTP development also
requires `DIFFX_SERVER_INSECURE_COOKIES=true`. The volume contains observations,
jobs, accounts and credential hashes.
The image runs without Git or a repository mount, as an unprivileged user.
SIGTERM drains HTTP and stops the worker; durable unfinished jobs recover after
their leases expire. Back up the database while stopped.
Expand All @@ -204,22 +208,30 @@ Build/install the remote server with Go:

```sh
go install github.com/flexdinesh/diffx/cmd/diffx-server@main
DIFFX_SERVER_HOST=127.0.0.1 DIFFX_SERVER_PORT=7980 DIFFX_SERVER_STATE=./test-data/state.db diffx-server
DIFFX_SERVER_USERNAME=alice DIFFX_SERVER_PASSWORD=… \
DIFFX_SERVER_INSECURE_COOKIES=true DIFFX_SERVER_STATE=./test-data/state.db diffx-server
```

First startup creates `admin` with a generated token saved privately at
`<database>.admin-token`; startup prints its path. The token persists across
restarts. API/MCP requests use bearer authentication; the browser uses HTTP Basic
with the username and token as password. Deploy behind TLS. Multiple individual
users share one SQLite database; credentials select and isolate each user's data.
The database stores credential hashes. To add a user, stop the server, run:
Sign in with username/password, then open the top-right account settings to create
a named API token. `collector` tokens support sync and plugins; `api` tokens also
support the user's review REST/MCP APIs. Both expire after 90 days by default
(selectable 1–365 days), can be revoked, and show their secret only once. Browser
sessions use secure cookies; API/MCP and collectors use bearer tokens. Multiple
users share one SQLite database with private data per account. Local `diffx`
automatically supplies its owner and has no login or account settings.

Set `DIFFX_SERVER_SIGNUP_ENABLED=true` to allow signup; invitations are not yet
supported. For operator provisioning or password recovery, stop the server, run:

```sh
diffx-server user create --name alice --state /data/state.db
diffx-server user create --username bob --state /data/state.db --password-stdin < /path/to/private-password
diffx-server user reset-password --username alice --state /data/state.db --password-stdin < /path/to/private-password
```

Save the printed token, then restart the server. `--account` / `DIFFX_SERVER_ACCOUNT` and `DIFFX_SERVER_TOKEN`
can supply initial bootstrap credentials; existing credentials are never replaced.
Then restart the server. Password reset revokes browser sessions; API token
ownership is independent of passwords. Bootstrap requires both
`DIFFX_SERVER_USERNAME` and `DIFFX_SERVER_PASSWORD`; it never overwrites an existing
account's password. Deploy behind TLS with secure cookies.
Use `--retention-days 14` or config/environment to change server retention.
The server defaults to `127.0.0.1:7980` and `./data/state.db`, independent of
personal CLI settings. Its image defaults to `0.0.0.0:7980` and `/data/state.db`;
Expand Down Expand Up @@ -310,7 +322,7 @@ WebMCP while the page is open; see [docs/webmcp.md](docs/webmcp.md).

Local data lives in `$XDG_STATE_HOME/diffx/state.db`, or
`~/.local/state/diffx/state.db`. `--state memory` disables persistence.
Current schema 9 state persists across restarts. Older, newer or unrecognized
Current schema 10 state persists across restarts. Older, newer or unrecognized
databases are refused without an automatic reset. To start fresh, stop the
server, back up its state directory, and explicitly choose a new database path.
Legacy commands and pending producer payloads are retired.
Expand Down
2 changes: 1 addition & 1 deletion apps/web/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
document.documentElement.dataset.theme = theme;
})();
</script>
<title>diffx · Local diff</title>
<title>diffx · Code review</title>
</head>
<body>
<div id="root"></div>
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
DropdownMenuRadioItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { AccountButton } from "./account.tsx";
import { DeleteContextButton } from "./DeleteContextButton.tsx";
import { ProjectsToggle } from "./ProjectsNav.tsx";
import { useDiffSource } from "./app-state.tsx";
Expand Down Expand Up @@ -143,6 +144,7 @@ export function Header() {
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
<AccountButton />
</div>
</header>
);
Expand Down
259 changes: 259 additions & 0 deletions apps/web/src/account-tokens.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,259 @@
import {
api,
errorDetail,
readAccountTokens,
readCreatedToken,
type ApiAccountToken,
} from "@diffx/api";
import { useEffect, useState, type FormEvent } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";

function date(value: number) {
return new Date(value).toLocaleDateString();
}

export function AccountTokens() {
const [tokens, setTokens] = useState<ApiAccountToken[]>([]);
const [loaded, setLoaded] = useState(false);
const [attempt, setAttempt] = useState(0);
const [name, setName] = useState("");
const [purpose, setPurpose] = useState<"collector" | "api">("collector");
const [expiresInDays, setExpiry] = useState<30 | 90 | 365>(90);
const [created, setCreated] = useState<{
token: ApiAccountToken;
secret: string;
} | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [copyStatus, setCopyStatus] = useState("");
useEffect(() => {
const controller = new AbortController();
setError("");
void api
.GET("/api/v2/account/tokens", { signal: controller.signal })
.then(({ data, error }) => {
if (controller.signal.aborted) return;
if (!data)
throw new Error(
errorDetail(error, "Unable to load API tokens. Try again."),
);
setTokens(readAccountTokens(data));
setLoaded(true);
})
.catch((failure: unknown) => {
if (!controller.signal.aborted)
setError(
errorDetail(failure, "Unable to load API tokens. Try again."),
);
});
return () => controller.abort();
}, [attempt]);
async function create(event: FormEvent) {
event.preventDefault();
if (busy) return;
setBusy(true);
setError("");
setCreated(null);
setCopyStatus("");
try {
const { data, error } = await api.POST("/api/v2/account/tokens", {
body: { name, purpose, expiresInDays },
});
if (!data)
throw new Error(
errorDetail(error, "Unable to create token. Try again."),
);
const created = readCreatedToken(data);
setCreated(created);
setTokens((value) => [...value, created.token]);
setName("");
} catch (failure: unknown) {
setError(errorDetail(failure, "Unable to create token. Try again."));
} finally {
setBusy(false);
}
}
async function revoke(id: string) {
setBusy(true);
setError("");
try {
const { response, error } = await api.DELETE(
"/api/v2/account/tokens/{tokenId}",
{ params: { path: { tokenId: id } } },
);
if (!response.ok)
throw new Error(
errorDetail(error, "Unable to revoke token. Try again."),
);
setTokens((value) => value.filter((token) => token.id !== id));
if (created?.token.id === id) setCreated(null);
} catch (failure: unknown) {
setError(errorDetail(failure, "Unable to revoke token. Try again."));
} finally {
setBusy(false);
}
}
const configuration = created
? JSON.stringify(
{ remote: { url: window.location.origin, token: created.secret } },
null,
2,
)
: "";
async function copy() {
try {
await navigator.clipboard.writeText(
created?.token.purpose === "collector"
? configuration
: (created?.secret ?? ""),
);
setCopyStatus("Copied");
} catch {
setCopyStatus("Copy unavailable. Select and copy the text below.");
}
}
return (
<section className="account-section" aria-labelledby="tokens-title">
<h2 id="tokens-title">API tokens</h2>
<p>
Connect collectors or review API clients to your account. Secrets appear
once.
</p>
{error && (
<p className="account-error" role="alert">
{error}
</p>
)}
{!loaded && !error && <p role="status">Loading tokens…</p>}
{!loaded && error && (
<Button
variant="outline"
onClick={() => setAttempt((value) => value + 1)}
>
Retry loading tokens
</Button>
)}
{loaded && (
<>
{tokens.length === 0 ? (
<p>
No tokens yet. Create a collector token to publish your first
review.
</p>
) : (
<ul className="account-token-list">
{tokens.map((token) => (
<li key={token.id}>
<div>
<strong>{token.name}</strong>
<span>
{token.purpose === "collector"
? "Collector"
: "Review API"}{" "}
· Created {date(token.createdAt)} ·{" "}
{token.expiresAt <= Date.now() ? "Expired" : "Expires"}{" "}
{date(token.expiresAt)}
</span>
<span>
{token.lastUsedAt === null
? "Never used"
: `Last used ${date(token.lastUsedAt)}`}
</span>
</div>
<Button
variant="destructive"
disabled={busy}
aria-label={`Revoke ${token.name}`}
onClick={() => void revoke(token.id)}
>
Revoke
</Button>
</li>
))}
</ul>
)}
<form className="account-form token-form" onSubmit={create}>
<label htmlFor="token-name">Token name</label>
<Input
id="token-name"
required
maxLength={64}
placeholder="My laptop"
value={name}
onChange={(event) => setName(event.target.value)}
disabled={busy}
/>
<label htmlFor="token-purpose">Access</label>
<select
id="token-purpose"
value={purpose}
disabled={busy}
onChange={(event) =>
setPurpose(event.target.value === "api" ? "api" : "collector")
}
>
<option value="collector">Collector</option>
<option value="api">Review API / MCP</option>
</select>
<p className="account-hint">
{purpose === "collector"
? "Upload changes and check their ingestion status."
: "Read and update your reviews through the API or MCP."}
</p>
<label htmlFor="token-expiry">Expires after</label>
<select
id="token-expiry"
value={expiresInDays}
disabled={busy}
onChange={(event) =>
setExpiry(
event.target.value === "30"
? 30
: event.target.value === "365"
? 365
: 90,
)
}
>
<option value={30}>30 days</option>
<option value={90}>90 days</option>
<option value={365}>365 days</option>
</select>
<Button type="submit" disabled={busy} aria-busy={busy}>
Create token
</Button>
</form>
</>
)}
{created && (
<div className="token-secret" role="status">
<h3>Save your token now</h3>
<p>This secret will disappear when you leave this page.</p>
{created.token.purpose === "collector" && (
<p>
Merge this remote section into your diffx JSON config, then run{" "}
<code>diffx sync</code>.
</p>
)}
<pre tabIndex={0}>
{created.token.purpose === "collector"
? configuration
: created.secret}
</pre>
<div className="account-actions">
<Button variant="outline" onClick={() => void copy()}>
{created.token.purpose === "collector"
? "Copy configuration"
: "Copy token"}
</Button>
<Button variant="ghost" onClick={() => setCreated(null)}>
Dismiss secret
</Button>
</div>
{copyStatus && <p>{copyStatus}</p>}
</div>
)}
</section>
);
}
Loading
Loading