Skip to content
Draft
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
10 changes: 10 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,14 @@ _Avoid_: Fetch, load
The discovered structure of a connected graph database — vertex types, edge types, their attributes, and how they connect. Populated by Schema Sync when a Connection is first used; not user-defined.
_Avoid_: Model, structure

**URL Connection Params**:
Connection details carried in the query string of the `#/connect` route (`graphDbUrl`, `queryEngine`, `awsRegion`, `serviceType`, `name`) that let an external link pre-configure or activate a Connection. Resolved once when the Connect route is entered; the route redirects to the Graph View afterward so the params do not linger in history.
_Avoid_: deep link, connection link, auto-connect

**URL Connection Intent**:
The action a set of URL Connection Params resolves to against the current Connections: `none` (the params target the already-active Connection — do nothing), `activate` (the params match an inactive Connection — switch to it, replacing the Session), `create` (no match — open the create form pre-filled from the params), or `invalid` (a link carrying a `graphDbUrl` that fails validation — warn and ignore it). A Connection matches only when its `graphDbUrl`, `queryEngine`, and auth posture (IAM on/off, and when on, `awsRegion` and `serviceType`) all agree — auth posture is identity-bearing, so a link requesting different auth than any existing Connection resolves to `create` rather than silently reusing one.
_Avoid_: connection action, deep-link mode

## Relationships

- A **Connection** has exactly one **Query Language**
Expand All @@ -78,6 +86,8 @@ _Avoid_: Model, structure
- **Neighbors** are **Vertices** one hop away from a given **Vertex**
- **User Preferences** are scoped per **Vertex Type** and **Edge Type**
- The **Graph View**, **Data Table View**, and **Schema View** all render from the same **Session** and **Schema**
- **URL Connection Params** resolve to a **URL Connection Intent** against the current **Connections** and active **Connection**
- Activating a different **Connection** replaces the current **Session** — so an `activate` or `create` **URL Connection Intent** resets the **Session**, while a `none` intent leaves it untouched

## Example dialogue

Expand Down
98 changes: 98 additions & 0 deletions docs/adr/20260612-connection-links.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
# Connection links via a dedicated `#/connect` route

Date: 2026-06-12

## Status

Accepted

## Context

External applications want to deep-link into Graph Explorer with a connection
already configured — for example, a console that lists Neptune clusters and
offers an "Open in Graph Explorer" link. The link carries the endpoint and auth
details as URL parameters. Graph Explorer stores all connections client-side and
has no server, so the link is the only channel for this hand-off.

Three decisions in this design are non-obvious and would otherwise invite "why
is it like this?" later.

## Decisions

### A dedicated `#/connect` route, not an interstitial gate

An earlier version mounted a `UrlConnectionGate` component high in the tree that
read `window.location`, froze the resolved intent at mount with `useState`, and
stripped the params via `history.replaceState`. That coupled a reactive
computation to a one-shot lifecycle and put connection-handling logic in the app
shell.

Connection links are now a first-class route, `#/connect?graphDbUrl=…`. The
route resolves the params once on entry and redirects (router `navigate`, with
`replace`) to the graph canvas on completion. Because Graph Explorer uses a hash
router, the parameters sit **after** the `#` like every other route — third-party
integrators build the link the same way they would any in-app link, and
`window.location.search` (everything before the `#`) is no longer a trap. The
redirect leaves no `#/connect` entry in history, so refresh and back behave
normally without any manual param stripping.

### Auth posture is part of connection identity

A link resolves to one of four intents against the existing connections:
`none` (it targets the active connection — do nothing), `activate` (it matches an
inactive connection — switch to it), `create` (no match — open a pre-filled
form), or `invalid` (the link's `graphDbUrl` failed validation — warn and ignore
it). A connection matches only when its `graphDbUrl`, `queryEngine`, **and auth
posture** all agree, where auth posture is IAM on/off and, when on, the region
and service type.

Auth posture is identity-bearing because activating the wrong-auth connection
would silently connect with credentials the link did not ask for. A link
requesting IAM in `us-east-1` must not reuse a plaintext connection to the same
endpoint, and vice versa. When posture differs, the link falls through to the
`create` form rather than silently reusing a connection.

### Switching to an existing connection needs no confirmation

The `activate` path switches connections with no dialog. This matches how the
connections list already works — clicking a connection there activates it on a
single click, resetting the graph session, with no prompt. The link only ever
activates a connection the user already created and validated, so there is
nothing new to confirm. Adding a prompt here would guard an operation the rest
of the app treats as routine.

The `create` path keeps its friction: the pre-filled form is fully editable and
the user must submit it. This is the deliberate trust gate for the untrusted
endpoint details a link can carry — it is the only path that can introduce a new
database, so it is the only one that asks the user to confirm.

## Consequences

- The contract other code and external integrators depend on is the parameter
set (`graphDbUrl`, `queryEngine`, `awsRegion`, `serviceType`, `name`) and the
four-intent model, both in `core/urlConnectionParams.ts`. Parameters are
validated with zod; `graphDbUrl` must be an http(s) URL or the link is ignored.
- A connection from a link always proxies through the same host that serves
Graph Explorer. The proxy base URL is derived from `document.baseURI` rather
than `window.location.origin`, so it keeps the path prefix of path-hosted
deployments (e.g. a Neptune notebook at `/proxy/9250/explorer/` resolves the
proxy to `/proxy/9250`). There is no parameter to target a different proxy
host or to make a direct, non-proxy connection. (When the connection model
drops the explicit proxy `url` in favor of always-relative requests — see
PR #1773 — this derivation goes away and links inherit that behavior.)
- A link can switch to or pre-fill a connection, but it can never create or
connect to a new database without the user submitting the form. The proxy
server's `PROXY_SERVER_ALLOWED_DB_ORIGINS` allowlist remains the unconditional
backstop regardless of what a link requests. See
[security reference](../references/security.md).
- Parameters are plaintext, not an encoded token. This was deliberate: links are
meant to be human-readable and constructible by any integrator. The trust gate
is the create form plus the proxy allowlist, not obscurity.
- Active-connection state is currently global. A separate workstream makes it
per-tab; this route is written against the current global behavior and does
not bake in cross-tab assumptions.

## User-facing documentation

[Connections feature → Connection Links](../features/connections.md#connection-links)
documents the parameters, an example link, and the resolved behavior.
42 changes: 42 additions & 0 deletions docs/features/connections.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,45 @@ When a connection is created, Graph Explorer will perform a scan of the graph to
### Data Table

Under a listed node type, you can click on the ">" arrow to get to the [Data Table](./data-table.md) view. This allows you to see a sample list of nodes under this type and choose one or more nodes to "Send to Explorer" for getting started quickly if you are new to the data. You can also navigate directly to the Data Table view using the "Data Table" link in the navigation bar.

## Connection Links

External applications can link directly to Graph Explorer with a connection pre-configured by opening the `#/connect` route with query parameters. Graph Explorer reads the parameters, then either switches to the matching connection or opens a pre-filled create form for a new one, and redirects to the graph view.

### Parameters

| Parameter | Required | Default | Description |
| ------------- | -------- | ----------------------------- | ---------------------------------------------------------------------------------------------- |
| `graphDbUrl` | Yes | — | The graph database endpoint, URL-encoded. |
| `queryEngine` | No | `gremlin` | One of `gremlin`, `openCypher`, or `sparql`. Invalid values fall back to `gremlin`. |
| `awsRegion` | No | — | AWS region for the connection. Providing a region enables IAM auth (SigV4 signed requests). |
| `serviceType` | No | `neptune-db` (when IAM is on) | One of `neptune-db` or `neptune-graph`. Only applies when IAM auth is enabled via `awsRegion`. |
| `name` | No | The endpoint's hostname | Display label for the connection. Defaults to the full hostname of `graphDbUrl`. |

The parameters belong to the `#/connect` route, so they go _after_ the `#` (Graph Explorer uses hash-based routing). `graphDbUrl` must be URL-encoded. Most languages provide this via `encodeURIComponent()` (JavaScript), `urllib.parse.quote()` (Python), or `URLEncoder.encode()` (Java).

### Example

```
https://[GRAPH_EXPLORER_HOST]/#/connect?graphDbUrl=https%3A%2F%2Fmy-cluster.us-east-1.neptune.amazonaws.com%3A8182&queryEngine=gremlin&awsRegion=us-east-1&serviceType=neptune-db&name=My%20Database
```

### Behavior

When you open a connection link, Graph Explorer does one of the following:

- **The link matches your active connection** — nothing changes.
- **The link matches a different existing connection** — Graph Explorer switches to it, the same as selecting it in the connections list. No prompt: the connection was already created and validated by you, so there is nothing new to confirm.
- **The link matches no existing connection** — the create-connection form opens, pre-filled with the link's details so you can review or edit any setting before creating it.
- **The link's details are invalid** (for example, a `graphDbUrl` that is not a valid `http`/`https` URL) — the link is ignored and a notification explains what went wrong.

In all cases Graph Explorer redirects to the graph view once the link is handled, so the `#/connect` URL does not linger in your history and refreshing behaves normally.

#### What counts as a match

A link matches an existing connection only when its endpoint, query engine, **and authentication posture** all agree:

- the same `graphDbUrl` (compared case-insensitively) and the same `queryEngine`, and
- the same auth posture — whether IAM is on (a link enables it by providing `awsRegion`), and when it is on, the same `awsRegion` and `serviceType`.

Authentication is part of a connection's identity: a link requesting IAM in a region is a _different_ connection from a plaintext one to the same endpoint, and vice versa. A link whose auth posture differs from every existing connection never silently reuses one — it opens the pre-filled create form instead, where you can review the authentication settings before connecting.
4 changes: 4 additions & 0 deletions docs/references/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,10 @@ By default, the proxy server forwards requests to any database URL specified by
>
> This check only applies to requests routed through the proxy server. Connections configured to contact the database directly (bypassing the proxy) are not subject to the allowlist.

> [!NOTE]
>
> [Connection links](../features/connections.md#connection-links) never connect to a new database without your confirmation: a link whose details do not match an existing connection only pre-fills the create form for you to review. A link may switch to a connection you already created, but it cannot create one on your behalf. And in every case the proxy still rejects forwarding to any origin outside `PROXY_SERVER_ALLOWED_DB_ORIGINS`, so a crafted link cannot reach an arbitrary database.

## HTTP Redirects

The proxy server does not follow HTTP redirects from the database. If the database responds with a redirect (3xx status), the proxy returns an error to the client instead of following it. This prevents a compromised or misconfigured database endpoint from redirecting the proxy to an unrelated internal service.
Expand Down
2 changes: 2 additions & 0 deletions packages/graph-explorer/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Route, Routes } from "react-router";

import Redirect from "./components/Redirect";
import Connect from "./routes/Connect";
import Connections from "./routes/Connections";
import DataExplorer from "./routes/DataExplorer";
import DefaultLayout from "./routes/DefaultLayout";
Expand All @@ -16,6 +17,7 @@ export default function App() {
return (
<Routes>
<Route element={<DefaultLayout />}>
<Route path="/connect" element={<Connect />} />
<Route path="/connections" element={<Connections />} />
<Route path="/data-explorer" element={<DataExplorer />} />
<Route path="/data-explorer/:vertexType" element={<DataExplorer />} />
Expand Down
80 changes: 80 additions & 0 deletions packages/graph-explorer/src/core/AppStatusLoader.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
// @vitest-environment happy-dom
import { render, screen, waitFor } from "@testing-library/react";
import { MemoryRouter, Route, Routes } from "react-router";

import { TooltipProvider } from "@/components";
import { getAppStore } from "@/core";
import { createQueryClient } from "@/core/queryClient";
import { TestProvider } from "@/utils/testing";

import type {
ConfigurationId,
RawConfiguration,
} from "./ConfigurationProvider";

const matchingUrl = "https://default-match.neptune.amazonaws.com";

const defaultConnection: RawConfiguration = {
id: "Default Connection" as ConfigurationId,
displayLabel: "Default Connection",
connection: {
url: "https://localhost",
queryEngine: "gremlin",
proxyConnection: true,
graphDbUrl: matchingUrl,
},
};

vi.mock("./defaultConnection", () => ({
fetchDefaultConnection: vi.fn().mockResolvedValue([defaultConnection]),
}));

function searchFor(graphDbUrl: string, queryEngine = "gremlin") {
return `?graphDbUrl=${encodeURIComponent(graphDbUrl)}&queryEngine=${queryEngine}`;
}

describe("AppStatusLoader URL params + default connection", () => {
test("does not prompt to create when a loading default connection matches the connect URL", async () => {
const AppStatusLoader = (await import("./AppStatusLoader")).default;
const Connect = (await import("@/routes/Connect")).default;
const store = getAppStore();
const queryClient = createQueryClient();

// Enter the connect route before the default connection has loaded. The
// loader gates the route behind a spinner until the default arrives, so the
// route must resolve against the loaded default (a no-op) rather than
// prompting to create a duplicate.
render(
<TestProvider client={queryClient} store={store}>
<TooltipProvider>
<MemoryRouter initialEntries={[`/connect${searchFor(matchingUrl)}`]}>
<AppStatusLoader>
<Routes>
<Route path="/connect" element={<Connect />} />
<Route
path="/graph-explorer"
element={<div>graph canvas</div>}
/>
</Routes>
</AppStatusLoader>
</MemoryRouter>
</TooltipProvider>
</TestProvider>,
);

// Once the default connection loads, the route is a no-op and redirects to
// the graph canvas.
await waitFor(() => {
expect(screen.getByText("graph canvas")).toBeInTheDocument();
});

// The URL targets the connection the default provides, so we must NOT
// see a create-connection prompt.
expect(
screen.queryByText("Create connection from link"),
).not.toBeInTheDocument();
expect(
screen.queryByRole("button", { name: "Add Connection" }),
).not.toBeInTheDocument();
});
});
1 change: 0 additions & 1 deletion packages/graph-explorer/src/core/AppStatusLoader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,6 @@ function LoadDefaultConfig({ children }: PropsWithChildren) {
);
}

// Loading from config file if exists
if (
configuration.size === 0 &&
defaultConnectionConfigs &&
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
// @vitest-environment happy-dom
import { act } from "@testing-library/react";

import { configurationAtom } from "@/core";
import { getAppStore } from "@/core/StateProvider/appStore";
import {
createTestableVertex,
DbState,
renderHookWithState,
} from "@/utils/testing";
import { createRandomRawConfiguration } from "@/utils/testing/randomData";

import { nodesAtom } from "./nodes";
import { activeConfigurationAtom } from "./storageAtoms";
import useActivateConnection from "./useActivateConnection";

describe("useActivateConnection", () => {
test("sets the active connection and resets the graph session", () => {
const state = new DbState();
const vertex = createTestableVertex();
state.addTestableVertexToGraph(vertex);

const other = createRandomRawConfiguration();

const { result } = renderHookWithState(
() => useActivateConnection(),
state,
);
const store = getAppStore();
store.set(configurationAtom, prev => {
const updated = new Map(prev);
updated.set(other.id, other);
return updated;
});

expect(store.get(nodesAtom).size).toBeGreaterThan(0);

act(() => result.current(other.id));

expect(store.get(activeConfigurationAtom)).toBe(other.id);
expect(store.get(nodesAtom).size).toBe(0);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { useAtomCallback } from "jotai/utils";
import { useCallback } from "react";

import type { ConfigurationId } from "@/core";

import { logger } from "@/utils";

import { activeConfigurationAtom } from "./storageAtoms";
import useResetState from "./useResetState";

/**
* Returns a callback that activates a connection and resets the graph session,
* the same behavior as manually switching connections. Use this whenever the
* active connection changes to a different connection.
*/
export default function useActivateConnection() {
const resetState = useResetState();
return useAtomCallback(
useCallback(
(_get, set, configId: ConfigurationId) => {
logger.debug("Setting active connection to", configId);
set(activeConfigurationAtom, configId);
resetState();
},
[resetState],
),
);
}
Loading
Loading