Skip to content

Commit 2edc240

Browse files
committed
Add connection links via a dedicated #/connect route
External applications can deep-link into Graph Explorer with a connection preconfigured: #/connect?graphDbUrl=...&queryEngine=...&awsRegion=...&serviceType=...&name=... The route resolves the params against existing connections into one of four intents and acts on it, then redirects to the graph canvas so the connect URL never lingers in history: - none — the params target the active connection; nothing to do. - activate — the params match an existing connection; switch to it silently, the same no-prompt operation as clicking it in the connections list. - create — no match; open the create form prefilled but fully editable. This is the trust gate for the untrusted endpoint details a link can carry, and the only path that can introduce a new database. - invalid — the link's graphDbUrl failed validation; warn with a toast and ignore it. Matching identity folds in auth posture (IAM on/off, and when on, region and service type) alongside graphDbUrl and queryEngine, so a link requesting IAM never silently reuses a plaintext connection to the same endpoint, or vice versa — a mismatch falls through to the create form. graphDbUrl is validated as an http(s) URL with zod; a malformed or non-http link is ignored. The proxy base URL is derived from document.baseURI rather than window.location.origin so a link produces a working proxy connection on path-hosted deployments (e.g. a Neptune notebook at /proxy/9250/explorer/ resolves the proxy to /proxy/9250). Contract core lives in core/urlConnectionParams.ts (parse/match/build/resolve) and core/useUrlConnectionIntent.ts. Documented in the connections feature page, the security reference, CONTEXT.md, and an ADR.
1 parent d061472 commit 2edc240

21 files changed

Lines changed: 1522 additions & 33 deletions

CONTEXT.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,14 @@ _Avoid_: Save state (ambiguous with Session)
8686
The UI element in the nav bar (after the page title) that renders Persistence Status. It surfaces only on `failed` — a standing danger "Changes not saved" button — and stays absent at `idle` and `saving`. Clicking it opens a dialog showing the raw failure records (key, reason, attempt count, last attempt, and the underlying error's name/message/cause) in a read-only JSON editor. The dialog offers to save the configuration to a file via `saveLocalForageToFile` (`core/StateProvider/localDb.ts`) when storage is full (quota) — IndexedDB is still readable then — but not when storage is inaccessible (private mode, blocked), since the database never opened and there is nothing to read. Recovery scope is retry (transient failures) plus that backup (terminal-quota failures) — it does not guarantee the write eventually lands.
8787
_Avoid_: Save-status indicator
8888

89+
**URL Connection Params**:
90+
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.
91+
_Avoid_: deep link, connection link, auto-connect
92+
93+
**URL Connection Intent**:
94+
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.
95+
_Avoid_: connection action, deep-link mode
96+
8997
## Relationships
9098

9199
- Each browser tab has at most one **Active Connection**; different tabs may have different ones
@@ -100,6 +108,8 @@ _Avoid_: Save-status indicator
100108
- **Neighbors** are **Vertices** one hop away from a given **Vertex**
101109
- **Styles** are scoped per **Vertex Type** (**Vertex Styles**) and **Edge Type** (**Edge Styles**)
102110
- The **Graph View**, **Data Table View**, and **Schema View** all render from the same **Session** and **Schema**
111+
- **URL Connection Params** resolve to a **URL Connection Intent** against the current **Connections** and active **Connection**
112+
- 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
103113

104114
## Example dialogue
105115

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
# Connection links via a dedicated `#/connect` route
2+
3+
Date: 2026-06-12
4+
5+
## Status
6+
7+
Accepted
8+
9+
## Context
10+
11+
External applications want to deep-link into Graph Explorer with a connection
12+
already configured — for example, a console that lists Neptune clusters and
13+
offers an "Open in Graph Explorer" link. The link carries the endpoint and auth
14+
details as URL parameters. Graph Explorer stores all connections client-side and
15+
has no server, so the link is the only channel for this hand-off.
16+
17+
Three decisions in this design are non-obvious and would otherwise invite "why
18+
is it like this?" later.
19+
20+
## Decisions
21+
22+
### A dedicated `#/connect` route, not an interstitial gate
23+
24+
An earlier version mounted a `UrlConnectionGate` component high in the tree that
25+
read `window.location`, froze the resolved intent at mount with `useState`, and
26+
stripped the params via `history.replaceState`. That coupled a reactive
27+
computation to a one-shot lifecycle and put connection-handling logic in the app
28+
shell.
29+
30+
Connection links are now a first-class route, `#/connect?graphDbUrl=…`. The
31+
route resolves the params once on entry and redirects (router `navigate`, with
32+
`replace`) to the graph canvas on completion. Because Graph Explorer uses a hash
33+
router, the parameters sit **after** the `#` like every other route — third-party
34+
integrators build the link the same way they would any in-app link, and
35+
`window.location.search` (everything before the `#`) is no longer a trap. The
36+
redirect leaves no `#/connect` entry in history, so refresh and back behave
37+
normally without any manual param stripping.
38+
39+
### Auth posture is part of connection identity
40+
41+
A link resolves to one of four intents against the existing connections:
42+
`none` (it targets the active connection — do nothing), `activate` (it matches an
43+
inactive connection — switch to it), `create` (no match — open a pre-filled
44+
form), or `invalid` (the link's `graphDbUrl` failed validation — warn and ignore
45+
it). A connection matches only when its `graphDbUrl`, `queryEngine`, **and auth
46+
posture** all agree, where auth posture is IAM on/off and, when on, the region
47+
and service type.
48+
49+
Auth posture is identity-bearing because activating the wrong-auth connection
50+
would silently connect with credentials the link did not ask for. A link
51+
requesting IAM in `us-east-1` must not reuse a plaintext connection to the same
52+
endpoint, and vice versa. When posture differs, the link falls through to the
53+
`create` form rather than silently reusing a connection.
54+
55+
### Switching to an existing connection needs no confirmation
56+
57+
The `activate` path switches connections with no dialog. This matches how the
58+
connections list already works — clicking a connection there activates it on a
59+
single click, resetting the graph session, with no prompt. The link only ever
60+
activates a connection the user already created and validated, so there is
61+
nothing new to confirm. Adding a prompt here would guard an operation the rest
62+
of the app treats as routine.
63+
64+
The `create` path keeps its friction: the pre-filled form is fully editable and
65+
the user must submit it. This is the deliberate trust gate for the untrusted
66+
endpoint details a link can carry — it is the only path that can introduce a new
67+
database, so it is the only one that asks the user to confirm.
68+
69+
## Consequences
70+
71+
- The contract other code and external integrators depend on is the parameter
72+
set (`graphDbUrl`, `queryEngine`, `awsRegion`, `serviceType`, `name`) and the
73+
four-intent model, both in `core/urlConnectionParams.ts`. Parameters are
74+
validated with zod; `graphDbUrl` must be an http(s) URL or the link is ignored.
75+
- A connection from a link always proxies through the same host that serves
76+
Graph Explorer. The proxy base URL is derived from `document.baseURI` rather
77+
than `window.location.origin`, so it keeps the path prefix of path-hosted
78+
deployments (e.g. a Neptune notebook at `/proxy/9250/explorer/` resolves the
79+
proxy to `/proxy/9250`). There is no parameter to target a different proxy
80+
host or to make a direct, non-proxy connection. (When the connection model
81+
drops the explicit proxy `url` in favor of always-relative requests — see
82+
PR #1773 — this derivation goes away and links inherit that behavior.)
83+
- A link can switch to or pre-fill a connection, but it can never create or
84+
connect to a new database without the user submitting the form. The proxy
85+
server's `PROXY_SERVER_ALLOWED_DB_ORIGINS` allowlist remains the unconditional
86+
backstop regardless of what a link requests. See
87+
[security reference](../references/security.md).
88+
- Parameters are plaintext, not an encoded token. This was deliberate: links are
89+
meant to be human-readable and constructible by any integrator. The trust gate
90+
is the create form plus the proxy allowlist, not obscurity.
91+
- Active-connection state is currently global. A separate workstream makes it
92+
per-tab; this route is written against the current global behavior and does
93+
not bake in cross-tab assumptions.
94+
95+
## User-facing documentation
96+
97+
[Connections feature → Connection Links](../features/connections.md#connection-links)
98+
documents the parameters, an example link, and the resolved behavior.

docs/features/connections.md

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,3 +37,45 @@ When a connection is created, Graph Explorer will perform a scan of the graph to
3737
### Data Table
3838

3939
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.
40+
41+
## Connection Links
42+
43+
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.
44+
45+
### Parameters
46+
47+
| Parameter | Required | Default | Description |
48+
| ------------- | -------- | ----------------------------- | ---------------------------------------------------------------------------------------------- |
49+
| `graphDbUrl` | Yes || The graph database endpoint, URL-encoded. |
50+
| `queryEngine` | No | `gremlin` | One of `gremlin`, `openCypher`, or `sparql`. Invalid values fall back to `gremlin`. |
51+
| `awsRegion` | No || AWS region for the connection. Providing a region enables IAM auth (SigV4 signed requests). |
52+
| `serviceType` | No | `neptune-db` (when IAM is on) | One of `neptune-db` or `neptune-graph`. Only applies when IAM auth is enabled via `awsRegion`. |
53+
| `name` | No | The endpoint's hostname | Display label for the connection. Defaults to the full hostname of `graphDbUrl`. |
54+
55+
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).
56+
57+
### Example
58+
59+
```
60+
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
61+
```
62+
63+
### Behavior
64+
65+
When you open a connection link, Graph Explorer does one of the following:
66+
67+
- **The link matches your active connection** — nothing changes.
68+
- **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.
69+
- **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.
70+
- **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.
71+
72+
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.
73+
74+
#### What counts as a match
75+
76+
A link matches an existing connection only when its endpoint, query engine, **and authentication posture** all agree:
77+
78+
- the same `graphDbUrl` (compared case-insensitively) and the same `queryEngine`, and
79+
- 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`.
80+
81+
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.

docs/references/security.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,10 @@ By default, the proxy server forwards requests to any database URL specified by
101101
>
102102
> 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.
103103
104+
> [!NOTE]
105+
>
106+
> [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.
107+
104108
## HTTP Redirects
105109

106110
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.

packages/graph-explorer/src/App.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { Route, Routes } from "react-router";
22

33
import Redirect from "./components/Redirect";
4+
import Connect from "./routes/Connect";
45
import Connections from "./routes/Connections";
56
import DataExplorer from "./routes/DataExplorer";
67
import DefaultLayout from "./routes/DefaultLayout";
@@ -16,6 +17,7 @@ export default function App() {
1617
return (
1718
<Routes>
1819
<Route element={<DefaultLayout />}>
20+
<Route path="/connect" element={<Connect />} />
1921
<Route path="/connections" element={<Connections />} />
2022
<Route path="/data-explorer" element={<DataExplorer />} />
2123
<Route path="/data-explorer/:vertexType" element={<DataExplorer />} />
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
// @vitest-environment happy-dom
2+
import { render, screen, waitFor } from "@testing-library/react";
3+
import { MemoryRouter, Route, Routes } from "react-router";
4+
5+
import { TooltipProvider } from "@/components";
6+
import { getAppStore } from "@/core";
7+
import { createQueryClient } from "@/core/queryClient";
8+
import { TestProvider } from "@/utils/testing";
9+
10+
import type {
11+
ConfigurationId,
12+
RawConfiguration,
13+
} from "./ConfigurationProvider";
14+
15+
const matchingUrl = "https://default-match.neptune.amazonaws.com";
16+
17+
const defaultConnection: RawConfiguration = {
18+
id: "Default Connection" as ConfigurationId,
19+
displayLabel: "Default Connection",
20+
connection: {
21+
url: "https://localhost",
22+
queryEngine: "gremlin",
23+
proxyConnection: true,
24+
graphDbUrl: matchingUrl,
25+
},
26+
};
27+
28+
vi.mock("./defaultConnection", () => ({
29+
fetchDefaultConnection: vi.fn().mockResolvedValue([defaultConnection]),
30+
}));
31+
32+
function searchFor(graphDbUrl: string, queryEngine = "gremlin") {
33+
return `?graphDbUrl=${encodeURIComponent(graphDbUrl)}&queryEngine=${queryEngine}`;
34+
}
35+
36+
describe("AppStatusLoader URL params + default connection", () => {
37+
test("does not prompt to create when a loading default connection matches the connect URL", async () => {
38+
const AppStatusLoader = (await import("./AppStatusLoader")).default;
39+
const Connect = (await import("@/routes/Connect")).default;
40+
const store = getAppStore();
41+
const queryClient = createQueryClient();
42+
43+
// Enter the connect route before the default connection has loaded. The
44+
// loader gates the route behind a spinner until the default arrives, so the
45+
// route must resolve against the loaded default (a no-op) rather than
46+
// prompting to create a duplicate.
47+
render(
48+
<TestProvider client={queryClient} store={store}>
49+
<TooltipProvider>
50+
<MemoryRouter initialEntries={[`/connect${searchFor(matchingUrl)}`]}>
51+
<AppStatusLoader>
52+
<Routes>
53+
<Route path="/connect" element={<Connect />} />
54+
<Route
55+
path="/graph-explorer"
56+
element={<div>graph canvas</div>}
57+
/>
58+
</Routes>
59+
</AppStatusLoader>
60+
</MemoryRouter>
61+
</TooltipProvider>
62+
</TestProvider>,
63+
);
64+
65+
// Once the default connection loads, the route is a no-op and redirects to
66+
// the graph canvas.
67+
await waitFor(() => {
68+
expect(screen.getByText("graph canvas")).toBeInTheDocument();
69+
});
70+
71+
// The URL targets the connection the default provides, so we must NOT
72+
// see a create-connection prompt.
73+
expect(
74+
screen.queryByText("Create connection from link"),
75+
).not.toBeInTheDocument();
76+
expect(
77+
screen.queryByRole("button", { name: "Add Connection" }),
78+
).not.toBeInTheDocument();
79+
});
80+
});

packages/graph-explorer/src/core/AppStatusLoader.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,6 @@ function LoadDefaultConfig({ children }: PropsWithChildren) {
7575
);
7676
}
7777

78-
// Loading from config file if exists
7978
if (
8079
configuration.size === 0 &&
8180
defaultConnectionConfigs &&
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
// @vitest-environment happy-dom
2+
import { act } from "@testing-library/react";
3+
4+
import { configurationAtom } from "@/core";
5+
import { getAppStore } from "@/core/StateProvider/appStore";
6+
import {
7+
createTestableVertex,
8+
DbState,
9+
renderHookWithState,
10+
} from "@/utils/testing";
11+
import { createRandomRawConfiguration } from "@/utils/testing/randomData";
12+
13+
import { nodesAtom } from "./nodes";
14+
import { activeConfigurationAtom } from "./storageAtoms";
15+
import useActivateConnection from "./useActivateConnection";
16+
17+
describe("useActivateConnection", () => {
18+
test("sets the active connection and resets the graph session", () => {
19+
const state = new DbState();
20+
const vertex = createTestableVertex();
21+
state.addTestableVertexToGraph(vertex);
22+
23+
const other = createRandomRawConfiguration();
24+
25+
const { result } = renderHookWithState(
26+
() => useActivateConnection(),
27+
state,
28+
);
29+
const store = getAppStore();
30+
store.set(configurationAtom, prev => {
31+
const updated = new Map(prev);
32+
updated.set(other.id, other);
33+
return updated;
34+
});
35+
36+
expect(store.get(nodesAtom).size).toBeGreaterThan(0);
37+
38+
act(() => result.current(other.id));
39+
40+
expect(store.get(activeConfigurationAtom)).toBe(other.id);
41+
expect(store.get(nodesAtom).size).toBe(0);
42+
});
43+
});
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { useAtomCallback } from "jotai/utils";
2+
import { useCallback } from "react";
3+
4+
import type { ConfigurationId } from "@/core";
5+
6+
import { logger } from "@/utils";
7+
8+
import { activeConfigurationAtom } from "./storageAtoms";
9+
import useResetState from "./useResetState";
10+
11+
/**
12+
* Returns a callback that activates a connection and resets the graph session,
13+
* the same behavior as manually switching connections. Use this whenever the
14+
* active connection changes to a different connection.
15+
*/
16+
export default function useActivateConnection() {
17+
const resetState = useResetState();
18+
return useAtomCallback(
19+
useCallback(
20+
(_get, set, configId: ConfigurationId) => {
21+
logger.debug("Setting active connection to", configId);
22+
set(activeConfigurationAtom, configId);
23+
resetState();
24+
},
25+
[resetState],
26+
),
27+
);
28+
}

0 commit comments

Comments
 (0)