) => void;
readonly onToggleEnvironment: (environmentId: EnvironmentId, checked: boolean) => void;
@@ -317,7 +315,7 @@ function ConnectionStep({
const directEnvironments = environments.filter(
(environment) => !cloudEnabled || !isOnboardingRelayEnvironment(environment),
);
- const [pairingOpen, setPairingOpen] = useState(expandPairingInitially);
+ const [pairingOpen, setPairingOpen] = useState(false);
const [isPairing, setIsPairing] = useState(false);
const { ready, skippedIds } = resolveOnboardingSetup(environments, selectedIds);
const skippedLabels = environments
@@ -382,47 +380,47 @@ function ConnectionStep({
) : null}
- {cloudEnabled ? (
-
setPairingOpen(true)}
+ cloudContent={
+
+ Loading sign-in…
+
+ }
+ >
+
+
+ }
/>
- ) : null}
-
-
-
+
- }
+ onClick={() => setPairingOpen(false)}
>
-
- Add a computer
-
-
-
-
-
{
- setPairingOpen(false);
- onPaired(environmentId);
- requestAnimationFrame(() => continueRef.current?.focus());
- }}
- />
-
-
-
-
+ Back
+
+ {
+ setPairingOpen(false);
+ onPaired(environmentId);
+ requestAnimationFrame(() => continueRef.current?.focus());
+ }}
+ />
+
+ )}
{skippedLabels.length > 0 ? (
@@ -447,7 +445,7 @@ function ConnectionStep({
+ }
+ />
+ ))
+ : null}
+ {error || state.error ? (
+
+ {error ?? state.error}
+
+ ) : null}
+
+ );
+}
diff --git a/apps/web/src/components/settings/pairingUrls.test.ts b/apps/web/src/components/settings/pairingUrls.test.ts
index c5c45bcf1ce76ff1cfcd09cecbd0b52d7b048eed..ae8301ce33cee16249eb77f057c53149c4cfe128 100644
--- a/apps/web/src/components/settings/pairingUrls.test.ts
+++ b/apps/web/src/components/settings/pairingUrls.test.ts
@@ -14,6 +14,12 @@ describe("settings pairing URL helpers", () => {
);
});
+ it("keeps the environment prefix in direct pairing links", () => {
+ expect(
+ resolveDesktopPairingUrl("https://hub.example/prefix/environments/one/", "PAIRCODE"),
+ ).toBe("https://hub.example/prefix/environments/one/pair#token=PAIRCODE");
+ });
+
it("uses hosted pairing URLs for HTTPS endpoints", () => {
vi.stubEnv("VITE_HOSTED_APP_URL", "https://preview.t3.codes");
diff --git a/apps/web/src/components/settings/pairingUrls.ts b/apps/web/src/components/settings/pairingUrls.ts
index 891fe04ad6b11582db8a4f7af3d935aa489f2109..f0f7ffd24401d3ce9adf1a001046b08f139c84d6 100644
--- a/apps/web/src/components/settings/pairingUrls.ts
+++ b/apps/web/src/components/settings/pairingUrls.ts
@@ -1,9 +1,9 @@
+import { environmentEndpointUrl } from "@t3tools/shared/advertisedEndpoint";
import { buildHostedPairingUrl } from "../../hostedPairing";
import { setPairingTokenOnUrl } from "../../pairingUrl";
export function resolveDesktopPairingUrl(endpointUrl: string, credential: string): string {
- const url = new URL(endpointUrl);
- url.pathname = "/pair";
+ const url = new URL(environmentEndpointUrl(endpointUrl, "/pair"));
return setPairingTokenOnUrl(url, credential).toString();
}
diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts
index a63ef8209cf3d742e92afbbd4bbbeaccb9a0e3a3..e66849efff227fd6d3a3634c0df814bd70359d1c 100644
--- a/apps/web/src/components/settings/settingsSearch.ts
+++ b/apps/web/src/components/settings/settingsSearch.ts
@@ -998,7 +998,9 @@ export const SETTINGS_SEARCH_ITEMS = [
id: "remote-environments",
title: "Environments",
to: "/settings/connections",
- searchTerms: ["add pair backend host code ssh config agent tunnel saved t3 connect"],
+ searchTerms: [
+ "add pair backend host code ssh config agent tunnel saved t3 connect self hosted self-hosted server hub",
+ ],
},
{
id: "load-balancing",
diff --git a/apps/web/src/components/threadActionMenu.logic.test.ts b/apps/web/src/components/threadActionMenu.logic.test.ts
index 6bae63cffec6c0e7133f2440b3bff7e3d0e59a8c..315114481fc4dac70abc4ad1d66a54e24926ca87 100644
--- a/apps/web/src/components/threadActionMenu.logic.test.ts
+++ b/apps/web/src/components/threadActionMenu.logic.test.ts
@@ -8,6 +8,7 @@ import {
const baseState: ThreadActionMenuState = {
canOperate: true,
+ canOrganize: true,
branch: null,
projectFilter: null,
isPinned: false,
@@ -46,6 +47,7 @@ describe("buildThreadActionMenuItems", () => {
const items = buildThreadActionMenuItems({
...baseState,
canOperate: false,
+ canOrganize: false,
isPinned: reversed,
isSettled: reversed,
isSnoozed: reversed,
@@ -80,7 +82,12 @@ describe("buildThreadActionMenuItems", () => {
);
it("preserves local actions and restores mutations after a grant", () => {
- const denied = buildThreadActionMenuItems({ ...baseState, canOperate: false, branch: "main" });
+ const denied = buildThreadActionMenuItems({
+ ...baseState,
+ canOperate: false,
+ canOrganize: false,
+ branch: "main",
+ });
expect(denied.filter((item) => !item.disabled).map((item) => item.id)).toEqual([
"new-thread-on-branch",
"mark-unread",
@@ -244,3 +251,13 @@ describe("buildDraftActionMenuItems", () => {
expect(items.at(-1)).toMatchObject({ label: "Discard draft", destructive: true });
});
});
+
+it("permits central organization while execution and deletion remain disabled", () => {
+ const items = buildThreadActionMenuItems({ ...baseState, canOperate: false, canOrganize: true });
+ for (const id of ["pin", "settle", "snooze", "auto-settle"]) {
+ expect(items.find((item) => item.id === id)?.disabled).not.toBe(true);
+ }
+ for (const id of ["rename", "regenerate-title", "archive", "delete"]) {
+ expect(items.find((item) => item.id === id)?.disabled).toBe(true);
+ }
+});
diff --git a/apps/web/src/components/threadActionMenu.logic.ts b/apps/web/src/components/threadActionMenu.logic.ts
index f51328266cabd8eb63a4ea436db357743a491323..bb150f56706d099001a3bcb1cccf98eb29198577 100644
--- a/apps/web/src/components/threadActionMenu.logic.ts
+++ b/apps/web/src/components/threadActionMenu.logic.ts
@@ -71,6 +71,7 @@ export function buildDraftActionMenuItems(options: {
export interface ThreadActionMenuState {
readonly canOperate: boolean;
+ readonly canOrganize: boolean;
readonly branch: string | null;
/**
* Project scoping for the thread list. Null on surfaces with no scoped
@@ -114,6 +115,14 @@ export function threadActionRequiresOperate(action: ThreadActionMenuId): boolean
].includes(action);
}
+export function threadActionIsOrganization(action: ThreadActionMenuId): boolean {
+ return (
+ ["pin", "unpin", "settle", "unsettle", "unsnooze", "snooze", "auto-settle"].includes(action) ||
+ action.startsWith("snooze:") ||
+ action.startsWith("auto-settle:")
+ );
+}
+
/**
* Single source for the per-thread action menu: the sidebar row's right-click
* menu and the chat header menu share labels, ordering, and capability gating.
@@ -249,17 +258,16 @@ export function buildThreadActionMenuItems(
icon: "trash",
},
];
- return state.canOperate
- ? items
- : items.map((item) =>
- threadActionRequiresOperate(item.id)
- ? {
- ...item,
- disabled: true,
- ...(item.children
- ? { children: item.children.map((child) => ({ ...child, disabled: true })) }
- : {}),
- }
- : item,
- );
+ return items.map((item) =>
+ threadActionRequiresOperate(item.id) &&
+ !(threadActionIsOrganization(item.id) ? state.canOrganize : state.canOperate)
+ ? {
+ ...item,
+ disabled: true,
+ ...(item.children
+ ? { children: item.children.map((child) => ({ ...child, disabled: true })) }
+ : {}),
+ }
+ : item,
+ );
}
diff --git a/apps/web/src/connection/hostedAuth.test.ts b/apps/web/src/connection/hostedAuth.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..90099e415b0a10a430952ebcff2d4ab212960b0b
--- /dev/null
+++ b/apps/web/src/connection/hostedAuth.test.ts
@@ -0,0 +1,201 @@
+import { normalizeHostedServer } from "@t3tools/shared/hostedServerUrl";
+import { afterEach, describe, expect, it, vi } from "vite-plus/test";
+import { forgetHostedWebSession, hostedAuthHeaders } from "./hostedAuth";
+
+afterEach(() => {
+ forgetHostedWebSession();
+ vi.unstubAllGlobals();
+ vi.useRealTimers();
+});
+
+const server = "https://knife.example.test";
+const account = (csrfToken: string, expiresAt: string) => ({
+ accountId: "owner",
+ deviceId: "browser",
+ csrfToken,
+ expiresAt,
+});
+
+describe("hosted account client custody", () => {
+ it("uses same-origin cookies and renews with CSRF without requesting a bearer token", async () => {
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date("2026-10-17T00:00:00Z"));
+ vi.stubGlobal("window", { location: { origin: server } });
+ const fetch = vi
+ .fn()
+ .mockResolvedValueOnce(Response.json(account("first-csrf", "2026-10-17T00:00:30Z")))
+ .mockResolvedValueOnce(Response.json(account("renewed-csrf", "2026-10-17T01:00:00Z")));
+ vi.stubGlobal("fetch", fetch);
+ expect(await hostedAuthHeaders(server)).toEqual({ "x-csrf-token": "first-csrf" });
+ expect(await hostedAuthHeaders(server)).toEqual({ "x-csrf-token": "renewed-csrf" });
+ expect(fetch.mock.calls[0]).toEqual([
+ `${server}/api/auth/account`,
+ { credentials: "same-origin", redirect: "error" },
+ ]);
+ expect(fetch.mock.calls[1]).toEqual([
+ `${server}/api/auth/session/refresh`,
+ {
+ method: "POST",
+ credentials: "same-origin",
+ redirect: "error",
+ headers: { "x-csrf-token": "first-csrf" },
+ },
+ ]);
+ });
+
+ it("shares concurrent browser renewal requests", async () => {
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date("2026-10-17T00:00:00Z"));
+ vi.stubGlobal("window", { location: { origin: server } });
+ const fetch = vi
+ .fn()
+ .mockResolvedValueOnce(Response.json(account("first", "2026-10-17T00:15:00Z")))
+ .mockResolvedValueOnce(Response.json(account("renewed", "2026-10-17T00:29:30Z")));
+ vi.stubGlobal("fetch", fetch);
+ await hostedAuthHeaders(server);
+ vi.setSystemTime(new Date("2026-10-17T00:14:30Z"));
+ expect(await Promise.all([hostedAuthHeaders(server), hostedAuthHeaders(server)])).toEqual([
+ { "x-csrf-token": "renewed" },
+ { "x-csrf-token": "renewed" },
+ ]);
+ expect(fetch).toHaveBeenCalledTimes(2);
+ });
+
+ it("recovers a second tab's stale CSRF after another tab rotates the shared cookie", async () => {
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date("2026-10-17T00:00:00Z"));
+ vi.stubGlobal("window", { location: { origin: server } });
+ let session = 1;
+ const calls: Array<{ path: string; csrf: string | null }> = [];
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(async (url: string, init: RequestInit) => {
+ const path = new URL(url).pathname;
+ const csrf = new Headers(init.headers).get("x-csrf-token");
+ calls.push({ path, csrf });
+ if (path === "/api/auth/account")
+ return Response.json(account(`opaque-${session}`, "2026-10-17T00:15:00Z"));
+ if (csrf !== `opaque-${session}`) return new Response(null, { status: 401 });
+ if (path === "/api/auth/session/refresh") {
+ session++;
+ return Response.json(account(`opaque-${session}`, "2026-10-17T00:30:00Z"));
+ }
+ return Response.json({ revoked: true });
+ }),
+ );
+ vi.resetModules();
+ const firstTab = await import("./hostedAuth");
+ vi.resetModules();
+ const secondTab = await import("./hostedAuth");
+ expect(firstTab).not.toBe(secondTab);
+ await firstTab.hostedAuthHeaders(server);
+ await secondTab.hostedAuthHeaders(server);
+ await firstTab.hostedAccountRequest(server, "/api/auth/session/refresh", "POST");
+ expect(
+ (await secondTab.hostedAccountRequest(server, "/api/auth/devices/other/revoke", "POST")).ok,
+ ).toBe(true);
+ expect(calls.slice(-3)).toEqual([
+ { path: "/api/auth/devices/other/revoke", csrf: "opaque-1" },
+ { path: "/api/auth/account", csrf: null },
+ { path: "/api/auth/devices/other/revoke", csrf: "opaque-2" },
+ ]);
+ });
+
+ it("serializes each tab's concurrent renewals and repairs a shared-cookie rotation once", async () => {
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date("2026-10-17T00:00:00Z"));
+ vi.stubGlobal("window", { location: { origin: server } });
+ let session = 1;
+ const refreshProofs: string[] = [];
+ const reads: number[] = [];
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(async (url: string, init: RequestInit) => {
+ if (new URL(url).pathname === "/api/auth/account") {
+ reads.push(session);
+ return Response.json(
+ account(
+ `opaque-${session}`,
+ session === 1 ? "2026-10-17T00:15:00Z" : "2026-10-17T00:30:00Z",
+ ),
+ );
+ }
+ const csrf = new Headers(init.headers).get("x-csrf-token")!;
+ refreshProofs.push(csrf);
+ if (csrf !== `opaque-${session}`) return new Response(null, { status: 401 });
+ session++;
+ return Response.json(account(`opaque-${session}`, "2026-10-17T00:30:00Z"));
+ }),
+ );
+ vi.resetModules();
+ const firstTab = await import("./hostedAuth");
+ vi.resetModules();
+ const secondTab = await import("./hostedAuth");
+ await Promise.all([firstTab.hostedAuthHeaders(server), secondTab.hostedAuthHeaders(server)]);
+ vi.setSystemTime(new Date("2026-10-17T00:14:30Z"));
+ const results = await Promise.all([
+ firstTab.hostedAuthHeaders(server),
+ firstTab.hostedAuthHeaders(server),
+ secondTab.hostedAuthHeaders(server),
+ secondTab.hostedAuthHeaders(server),
+ ]);
+ expect(results[0]).toEqual(results[1]);
+ expect(results[2]).toEqual(results[3]);
+ expect(refreshProofs).toEqual(["opaque-1", "opaque-1", "opaque-2"]);
+ expect(reads).toEqual([1, 1, 2]);
+ expect(results[2]).toEqual({ "x-csrf-token": "opaque-3" });
+ });
+
+ it("bounds expired-cookie recovery to one metadata request without repeating a mutation", async () => {
+ vi.stubGlobal("window", { location: { origin: server } });
+ const fetch = vi
+ .fn()
+ .mockResolvedValueOnce(Response.json(account("expired-proof", "2035-01-01T00:00:00Z")))
+ .mockResolvedValueOnce(new Response(null, { status: 401 }))
+ .mockResolvedValueOnce(new Response(null, { status: 401 }));
+ vi.stubGlobal("fetch", fetch);
+ vi.resetModules();
+ const tab = await import("./hostedAuth");
+ await expect(
+ tab.hostedAccountRequest(server, "/api/auth/devices/other/revoke", "POST"),
+ ).rejects.toThrow("Sign in");
+ expect(fetch).toHaveBeenCalledTimes(3);
+ });
+
+ it("never sends browser cookies or CSRF to another origin", async () => {
+ vi.stubGlobal("window", { location: { origin: server } });
+ const fetch = vi.fn();
+ vi.stubGlobal("fetch", fetch);
+ await expect(hostedAuthHeaders("https://other.example.test")).rejects.toThrow(
+ "Open this server",
+ );
+ expect(fetch).not.toHaveBeenCalled();
+ });
+
+ it("obtains ephemeral native access from main and refreshes it on each request", async () => {
+ const getHostedAccessToken = vi
+ .fn()
+ .mockResolvedValueOnce("ephemeral-one")
+ .mockResolvedValueOnce("ephemeral-two");
+ vi.stubGlobal("window", {
+ location: { origin: "http://localhost:3773" },
+ desktopBridge: { getHostedAccessToken },
+ });
+ const fetch = vi.fn();
+ vi.stubGlobal("fetch", fetch);
+ expect(await hostedAuthHeaders(server)).toEqual({ authorization: "Bearer ephemeral-one" });
+ expect(await hostedAuthHeaders(server)).toEqual({ authorization: "Bearer ephemeral-two" });
+ expect(fetch).not.toHaveBeenCalled();
+ expect(getHostedAccessToken.mock.calls).toEqual([[server], [server]]);
+ });
+
+ it.each([
+ "http://knife.example.test",
+ `${server}/path`,
+ "https://user:secret@knife.example.test",
+ `${server}?token=secret`,
+ `${server}#token`,
+ ])("rejects unsafe server addresses: %s", (value) => {
+ expect(() => normalizeHostedServer(value)).toThrow();
+ });
+});
diff --git a/apps/web/src/connection/hostedAuth.ts b/apps/web/src/connection/hostedAuth.ts
new file mode 100644
index 0000000000000000000000000000000000000000..511df6fcb398299d84377d26b09e6ffac342ab9b
--- /dev/null
+++ b/apps/web/src/connection/hostedAuth.ts
@@ -0,0 +1,112 @@
+import { HostedAuthAccount, HostedAuthDevice } from "@t3tools/contracts";
+import * as DateTime from "effect/DateTime";
+import * as Schema from "effect/Schema";
+
+const decodeAccount = Schema.decodeUnknownSync(Schema.toCodecJson(HostedAuthAccount));
+const decodeDevices = Schema.decodeUnknownSync(Schema.toCodecJson(Schema.Array(HostedAuthDevice)));
+
+let account: { server: string; value: HostedAuthAccount } | null = null;
+let browserRequest: { server: string; value: Promise } | null = null;
+
+export async function hostedAuthHeaders(
+ server: string,
+ rejectedCsrf?: string,
+): Promise> {
+ const desktop = window.desktopBridge;
+ if (desktop?.getHostedAccessToken) {
+ return { authorization: `Bearer ${await desktop.getHostedAccessToken(server)}` };
+ }
+ if (new URL(server).origin !== window.location.origin) {
+ throw new Error("Open this server in your browser to sign in.");
+ }
+ if (
+ rejectedCsrf !== undefined &&
+ account?.server === server &&
+ account.value.csrfToken === rejectedCsrf
+ ) {
+ account = null;
+ }
+ if (
+ account?.server === server &&
+ DateTime.toEpochMillis(account.value.expiresAt) > Date.now() + 60_000
+ ) {
+ return { "x-csrf-token": account.value.csrfToken };
+ }
+ if (browserRequest?.server !== server) {
+ const previous = account?.server === server ? account.value : null;
+ const pending = (async () => {
+ let response = await fetch(
+ `${server}/api/auth/${previous === null ? "account" : "session/refresh"}`,
+ {
+ ...(previous === null
+ ? {}
+ : { method: "POST", headers: { "x-csrf-token": previous.csrfToken } }),
+ credentials: "same-origin",
+ redirect: "error",
+ },
+ );
+ if (response.status === 401 && previous !== null) {
+ const current = await fetch(`${server}/api/auth/account`, {
+ credentials: "same-origin",
+ redirect: "error",
+ });
+ if (!current.ok) throw new Error("Sign in to your server again to reconnect.");
+ const refreshed = decodeAccount(await current.json());
+ response = await fetch(`${server}/api/auth/session/refresh`, {
+ method: "POST",
+ headers: { "x-csrf-token": refreshed.csrfToken },
+ credentials: "same-origin",
+ redirect: "error",
+ });
+ }
+ if (!response.ok) throw new Error("Sign in to your server again to reconnect.");
+ return decodeAccount(await response.json());
+ })();
+ browserRequest = { server, value: pending };
+ }
+ const current = browserRequest;
+ try {
+ const value = await current.value;
+ if (browserRequest === current) account = { server, value };
+ return { "x-csrf-token": value.csrfToken };
+ } finally {
+ if (browserRequest === current) browserRequest = null;
+ }
+}
+
+export async function hostedAccountRequest(server: string, path: string, method = "GET") {
+ const request = (headers: Record) =>
+ fetch(`${server}${path}`, {
+ method,
+ headers,
+ credentials: "same-origin",
+ redirect: "error",
+ });
+ const headers = await hostedAuthHeaders(server);
+ let response = await request(headers);
+ if (response.status === 401 && headers["x-csrf-token"]) {
+ response = await request(await hostedAuthHeaders(server, headers["x-csrf-token"]));
+ }
+ if (!response.ok) throw new Error("Couldn't update this server connection. Try again.");
+ return response;
+}
+
+export async function hostedDevices(server: string) {
+ const response = await hostedAccountRequest(server, "/api/auth/devices");
+ return decodeDevices(await response.json());
+}
+
+export async function beginHostedSignIn(server: string) {
+ if (window.desktopBridge?.beginHostedAuth) {
+ await window.desktopBridge.beginHostedAuth(server);
+ return;
+ }
+ const url = new URL(`${server}/api/auth/oidc/start`);
+ url.searchParams.set("returnTo", "/");
+ window.location.assign(url.href);
+}
+
+export function forgetHostedWebSession() {
+ account = null;
+ browserRequest = null;
+}
diff --git a/apps/web/src/connection/hub.ts b/apps/web/src/connection/hub.ts
new file mode 100644
index 0000000000000000000000000000000000000000..20adbe9997c59050029d7597e10ceb82041853b6
--- /dev/null
+++ b/apps/web/src/connection/hub.ts
@@ -0,0 +1,5 @@
+import { createHubConnectionAtoms } from "@t3tools/client-runtime/state/hub";
+
+import { connectionAtomRuntime } from "./runtime";
+
+export const hubConnection = createHubConnectionAtoms(connectionAtomRuntime);
diff --git a/apps/web/src/connection/platform.ts b/apps/web/src/connection/platform.ts
index 63c6563aea700d1595009434a88dcc65e08f74bf..d975572d2fbddde2d5bdb8ff166a79eceab75e22 100644
--- a/apps/web/src/connection/platform.ts
+++ b/apps/web/src/connection/platform.ts
@@ -40,6 +40,7 @@ import * as Ref from "effect/Ref";
import * as Stream from "effect/Stream";
import { FetchHttpClient } from "effect/http";
+import { hostedAuthHeaders } from "./hostedAuth";
import { APP_VERSION } from "../branding";
import { readDesktopPrimaryBearerToken } from "../environments/primary/desktopAuth";
import * as PrimaryEnvironmentHttpLayer from "../environments/primary/httpLayer";
@@ -69,6 +70,18 @@ function currentNetworkStatus(): "unknown" | "offline" | "online" {
return navigator.onLine ? "online" : "offline";
}
+const layerHostedAuthAccess = Layer.succeed(ClientCapabilities.HostedAuthAccess, {
+ headers: (serverUrl) =>
+ Effect.tryPromise({
+ try: () => hostedAuthHeaders(serverUrl),
+ catch: () =>
+ new ConnectionBlockedError({
+ reason: "authentication",
+ detail: "Sign in to your server again to reconnect.",
+ }),
+ }),
+});
+
const layerConnectivity = Connectivity.layer({
status: Effect.sync(currentNetworkStatus),
changes: Stream.callback((queue) =>
@@ -745,9 +758,9 @@ const layerPlatformConnectionSource = Layer.effect(
const layerEnvironmentOwnedDataCleanup = Layer.succeed(
Persistence.EnvironmentOwnedDataCleanup,
Persistence.EnvironmentOwnedDataCleanup.of({
- clear: (environmentId) =>
+ clear: (environmentId, options) =>
Effect.sync(() => {
- clearComposerDraftsEnvironment(environmentId);
+ if (!options?.retainDrafts) clearComposerDraftsEnvironment(environmentId);
}),
}),
);
@@ -770,6 +783,7 @@ const layerRpcRequestObserver = Layer.succeed(
type ConnectionPlatformLayerSource =
| typeof ConnectionStorage.layer
| typeof layerConnectivity
+ | typeof layerHostedAuthAccess
| typeof layerWakeups
| typeof layerCapabilities
| typeof layerPlatformConnectionSource
@@ -782,6 +796,7 @@ export const layer: Layer.Layer<
Layer.Services
> = Layer.mergeAll(
ConnectionStorage.layer,
+ layerHostedAuthAccess,
layerConnectivity,
layerWakeups,
layerCapabilities,
diff --git a/apps/web/src/connection/storage.test.ts b/apps/web/src/connection/storage.test.ts
index 87d78dddbf8c14b827b0c6261651718c1eea0a3c..8b2dbd71515c0c1c5114fa975b3dfdbd77a0bf28 100644
--- a/apps/web/src/connection/storage.test.ts
+++ b/apps/web/src/connection/storage.test.ts
@@ -1,4 +1,10 @@
import {
+ BearerConnectionCredential,
+ BearerConnectionProfile,
+ BearerConnectionRegistration,
+ BearerConnectionTarget,
+ CredentialStore,
+ HubConnectionCredential,
ConnectionTransientError,
PrimaryConnectionTarget,
} from "@t3tools/client-runtime/connection";
@@ -31,6 +37,63 @@ afterEach(() => {
});
describe("ConnectionStorage.makeCatalogStore", () => {
+ it.effect("restores hub route pointers and the single bearer after reopening storage", () =>
+ Effect.gen(function* () {
+ let saved: string | null = null;
+ vi.stubGlobal("window", {
+ desktopBridge: {
+ getConnectionCatalog: async () => saved,
+ setConnectionCatalog: async (raw: string) => {
+ saved = raw;
+ return true;
+ },
+ },
+ });
+ const connectionId = "hub:hub:source";
+ const hubCredentialId = "hub-session:hub";
+ const credential = new HubConnectionCredential({ connectionId: hubCredentialId });
+ const bearer = new BearerConnectionCredential({ token: "single-hub-secret" });
+ const registration = new BearerConnectionRegistration({
+ target: new BearerConnectionTarget({
+ connectionId,
+ environmentId: EnvironmentId.make("source"),
+ label: "Source",
+ }),
+ profile: new BearerConnectionProfile({
+ connectionId,
+ environmentId: EnvironmentId.make("source"),
+ label: "Source",
+ httpBaseUrl: "https://hub.example/environments/source/",
+ wsBaseUrl: "wss://hub.example/environments/source/",
+ }),
+ credential,
+ });
+ yield* Effect.gen(function* () {
+ const credentials = yield* CredentialStore.ConnectionCredentialStore;
+ const registrations = yield* Persistence.ConnectionRegistrationStore;
+ yield* credentials.put(hubCredentialId, bearer);
+ yield* registrations.register(registration, [registration.target]);
+ expect(yield* credentials.get(connectionId)).toEqual(Option.some(credential));
+ }).pipe(Effect.provide(ConnectionStorage.layer), Effect.scoped);
+
+ yield* Effect.gen(function* () {
+ const credentials = yield* CredentialStore.ConnectionCredentialStore;
+ expect(yield* credentials.get(connectionId)).toEqual(Option.some(credential));
+ expect(yield* credentials.get(hubCredentialId)).toEqual(Option.some(bearer));
+ }).pipe(Effect.provide(ConnectionStorage.layer), Effect.scoped);
+ expect(JSON.parse(saved!).credentials).toEqual([
+ {
+ connectionId: hubCredentialId,
+ credential: { _tag: "BearerConnectionCredential", token: bearer.token },
+ },
+ {
+ connectionId,
+ credential: { _tag: "HubConnectionCredential", connectionId: hubCredentialId },
+ },
+ ]);
+ }),
+ );
+
it.effect("quarantines malformed catalogs and starts from an empty document", () =>
Effect.gen(function* () {
const writes: string[] = [];
diff --git a/apps/web/src/environments/primary/bootstrap.test.ts b/apps/web/src/environments/primary/bootstrap.test.ts
index c9da4dab7051279cad852ee5c2ab46fd2233ea22..6f8e4ea3e40ec96a7fdcdad4ec935ac1aa8889a2 100644
--- a/apps/web/src/environments/primary/bootstrap.test.ts
+++ b/apps/web/src/environments/primary/bootstrap.test.ts
@@ -127,6 +127,21 @@ describe("environmentBootstrap", () => {
);
});
+ it("keeps configured environment paths in primary descriptor and auth URLs", async () => {
+ vi.stubEnv("VITE_HTTP_URL", "https://hub.example/prefix/environments/one/?old=1#fragment");
+ await installDescriptorApi();
+ await expect(resolveInitialPrimaryEnvironmentDescriptor()).resolves.toEqual(BASE_ENVIRONMENT);
+ expect(resolvePrimaryEnvironmentHttpUrl("/.well-known/t3/environment")).toBe(
+ "https://hub.example/prefix/environments/one/.well-known/t3/environment",
+ );
+ expect(readPrimaryEnvironmentTarget()?.target.wsBaseUrl).toBe(
+ "wss://hub.example/prefix/environments/one/",
+ );
+ expect(resolvePrimaryEnvironmentHttpUrl("/api/auth/session", { token: "ticket" })).toBe(
+ "https://hub.example/prefix/environments/one/api/auth/session?token=ticket",
+ );
+ });
+
it("derives the websocket url when only VITE_HTTP_URL is configured", async () => {
vi.stubEnv("VITE_HTTP_URL", "https://remote.example.com");
await installDescriptorApi();
@@ -155,6 +170,16 @@ describe("environmentBootstrap", () => {
});
});
+ it.each(["wss://hub.example/prefix/ws", "wss://hub.example/prefix/"])(
+ "derives HTTP base paths from a configured WebSocket address: %s",
+ (wsBaseUrl) => {
+ vi.stubEnv("VITE_WS_URL", wsBaseUrl);
+ expect(resolvePrimaryEnvironmentHttpUrl("/api/auth/session")).toBe(
+ "https://hub.example/prefix/api/auth/session",
+ );
+ },
+ );
+
it("keeps an uppercase wss scheme secure when deriving the http url", () => {
vi.stubEnv("VITE_WS_URL", "WSS://remote.example.com");
diff --git a/apps/web/src/environments/primary/target.ts b/apps/web/src/environments/primary/target.ts
index 7e574bb7cd2098c82bf77ba7e15463d2784492ae..70834670b317e096890e72ce7b9fed23f827fd48 100644
--- a/apps/web/src/environments/primary/target.ts
+++ b/apps/web/src/environments/primary/target.ts
@@ -1,4 +1,9 @@
import { PRIMARY_LOCAL_ENVIRONMENT_ID, type DesktopEnvironmentBootstrap } from "@t3tools/contracts";
+import {
+ deriveWsBaseUrl,
+ environmentEndpointUrl,
+ normalizeHttpBaseUrl,
+} from "@t3tools/shared/advertisedEndpoint";
import * as Schema from "effect/Schema";
import { isLocalEnvironmentDisabled } from "../../localEnvironment";
@@ -137,6 +142,9 @@ function swapBaseUrlProtocol(
source: "configured",
urlKind,
});
+ if ((url.protocol === "ws:" || url.protocol === "wss:") && !url.pathname.endsWith("/")) {
+ url.pathname = url.pathname.replace(/[^/]+$/, "");
+ }
url.protocol = nextProtocol;
return url.toString();
}
@@ -209,17 +217,19 @@ function resolveConfiguredPrimaryTarget(): PrimaryEnvironmentTarget | null {
(configuredWsBaseUrl?.toLowerCase().startsWith("wss:")
? swapBaseUrlProtocol(configuredWsBaseUrl, "https:", "websocket-base-url")
: swapBaseUrlProtocol(configuredWsBaseUrl!, "http:", "websocket-base-url"));
+ const normalizedHttpBaseUrl = normalizeHttpBaseUrl(
+ normalizeBaseUrl(resolvedHttpBaseUrl, "configured", "http-base-url"),
+ );
const resolvedWsBaseUrl =
- configuredWsBaseUrl ??
- (configuredHttpBaseUrl?.toLowerCase().startsWith("https:")
- ? swapBaseUrlProtocol(configuredHttpBaseUrl, "wss:", "http-base-url")
- : swapBaseUrlProtocol(configuredHttpBaseUrl!, "ws:", "http-base-url"));
+ configuredWsBaseUrl === undefined
+ ? deriveWsBaseUrl(normalizedHttpBaseUrl)
+ : normalizeBaseUrl(configuredWsBaseUrl, "configured", "websocket-base-url");
return {
source: "configured",
target: {
- httpBaseUrl: normalizeBaseUrl(resolvedHttpBaseUrl, "configured", "http-base-url"),
- wsBaseUrl: normalizeBaseUrl(resolvedWsBaseUrl, "configured", "websocket-base-url"),
+ httpBaseUrl: normalizedHttpBaseUrl,
+ wsBaseUrl: resolvedWsBaseUrl,
},
};
}
@@ -296,11 +306,11 @@ export function resolvePrimaryEnvironmentHttpUrl(
source: primaryTarget.source,
urlKind: "http-base-url",
});
- url.pathname = pathname;
+ const endpoint = new URL(environmentEndpointUrl(url.toString(), pathname));
if (searchParams) {
- url.search = new URLSearchParams(searchParams).toString();
+ endpoint.search = new URLSearchParams(searchParams).toString();
}
- return url.toString();
+ return endpoint.toString();
}
// Null only when the desktop app runs with its local environment disabled;
diff --git a/apps/web/src/hooks/useThreadActionMenu.ts b/apps/web/src/hooks/useThreadActionMenu.ts
index 2fda09240b6b27e2d426d98d37a5e8758715ecd4..d0990547209c7bd76da3a6c5fa2494790dda3403 100644
--- a/apps/web/src/hooks/useThreadActionMenu.ts
+++ b/apps/web/src/hooks/useThreadActionMenu.ts
@@ -19,12 +19,13 @@ import { resolveSnoozePresets } from "../components/Sidebar.snooze";
import {
buildThreadActionMenuItems,
threadActionRequiresOperate,
+ threadActionIsOrganization,
type ThreadActionMenuId,
} from "../components/threadActionMenu.logic";
import { stackedThreadToast, toastManager } from "../components/ui/toast";
import { threadEnvironment } from "../state/threads";
import { useOrchestrationCommand } from "../state/use-orchestration-command";
-import { readEnvironmentScope } from "../state/session";
+import { readEnvironmentScope, readThreadOrganizationPermission } from "../state/session";
import {
readEnvironmentSupportsAutoSettleOptOut,
readEnvironmentSupportsPinning,
@@ -148,6 +149,7 @@ export function useThreadActionMenu(input: {
const isRegeneratingTitle = thread.titleRegeneration != null;
const snoozePresets = resolveSnoozePresets(now, timestampFormat);
const items = buildThreadActionMenuItems({
+ canOrganize: readThreadOrganizationPermission(threadRef.environmentId),
canOperate: readEnvironmentScope(threadRef.environmentId, AuthOrchestrationOperateScope),
branch: thread.branch ?? null,
projectFilter: null,
@@ -166,7 +168,9 @@ export function useThreadActionMenu(input: {
const action: ThreadActionMenuId = clicked.value;
if (
threadActionRequiresOperate(action) &&
- !readEnvironmentScope(threadRef.environmentId, AuthOrchestrationOperateScope)
+ !(threadActionIsOrganization(action)
+ ? readThreadOrganizationPermission(threadRef.environmentId)
+ : readEnvironmentScope(threadRef.environmentId, AuthOrchestrationOperateScope))
) {
failureToast(
"Thread action unavailable",
diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx
index 81d1c2140af6b473f6f3f8bccf2fa52d094b6744..e28804233f995fa2ee31db88f893c82d7396d1e8 100644
--- a/apps/web/src/main.tsx
+++ b/apps/web/src/main.tsx
@@ -6,7 +6,6 @@ import "./index.css";
import { prepareProviderAuthDelivery } from "./providerAuthDelivery";
import { isElectron } from "./env";
-import { hasCloudPublicConfig } from "./cloud/publicConfig";
import { getRouter } from "./router";
import {
syncDocumentElectronPlatformClasses,
@@ -27,8 +26,6 @@ if (isElectron) {
syncDocumentWindowControlsOverlayClass();
}
-const clerkPublishableKey = import.meta.env.VITE_CLERK_PUBLISHABLE_KEY as string | undefined;
-
// A failed split-chunk fetch usually means the hashed assets went stale under
// a deploy; one guarded reload picks up the fresh index.html.
let chunkLoadFailed = false;
@@ -43,41 +40,13 @@ window.addEventListener("vite:preloadError", (event) => {
const app = ;
-// Managed auth is cloud-only, and the Electron Clerk provider bundles the full
-// clerk-js runtime. Loading only the selected runtime as a split chunk keeps
-// every Clerk byte out of the startup graph for local-mode users, and keeps
-// the bundled clerk-js out of the browser build entirely.
-const managedAuthShellModule =
- clerkPublishableKey && hasCloudPublicConfig()
- ? isElectron
- ? import("./components/clerk/ElectronManagedAuthShell")
- : import("./components/clerk/BrowserManagedAuthShell")
- : null;
-
-// The index.html boot splash lives inside #root, and React's first commit
-// clears it. Resolve everything that first commit needs, the selected
-// managed-auth runtime and the initial route's split chunks, before
-// rendering, so the splash holds until real UI paints instead of dropping to
-// a blank window while chunks download.
-export const startup = Promise.all([
- managedAuthShellModule?.then((module) => module.default) ?? null,
- router.load(),
-])
- .then(([ManagedAuthShell]) => {
- // A route chunk failure still resolves router.load(): the error is parked in
- // the lazy component and surfaces through the route error boundary. Skip the
- // paint when a reload is on its way, and only re-arm the guard after a boot
- // that fetched every chunk it asked for.
+export const startup = router
+ .load()
+ .then(() => {
if (reloadScheduled) return;
if (!chunkLoadFailed) clearChunkReloadGuard();
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
-
- {ManagedAuthShell && clerkPublishableKey ? (
- {app}
- ) : (
- app
- )}
- ,
+ {app},
);
})
.catch((error: unknown) => {
diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx
index b94d979dfc09f6fbec2c6b0f6e61ebf28a7ae75a..d1a2fc8fe07cd466945058fa26b3ae2f96bc76d2 100644
--- a/apps/web/src/routes/__root.tsx
+++ b/apps/web/src/routes/__root.tsx
@@ -1,3 +1,4 @@
+import { HostedServerAutoConnect } from "../components/settings/SelfHostedServer";
import { usePersonalUiSettingsSync } from "../state/uiSettingsSync";
import { PermissionUpdateNotice } from "../components/PermissionUpdateNotice";
import { type ServerLifecycleWelcomePayload } from "@t3tools/contracts";
@@ -235,6 +236,7 @@ function RootRouteView() {
+
diff --git a/apps/web/src/state/entities.ts b/apps/web/src/state/entities.ts
index 9e1c7f24d3e7b454ad4b53591ac6a778947c8549..38c06fa50ab4ceac1508de1a3d2880a10f31322a 100644
--- a/apps/web/src/state/entities.ts
+++ b/apps/web/src/state/entities.ts
@@ -14,7 +14,7 @@ import type { EnvironmentId, OrchestrationV2ProjectedTurnItem } from "@t3tools/c
import { Atom } from "effect/reactivity";
import { appAtomRegistry } from "../rpc/atomRegistry";
import { environmentProjects } from "./projects";
-import { environmentServerConfigsAtom } from "./server";
+import { environmentCapabilitiesAtom, environmentServerConfigsAtom } from "./server";
import {
allEnvironmentProjectSnapshotsReadyAtom,
allEnvironmentShellsBootstrappedAtom,
@@ -240,8 +240,8 @@ export function readEnvironmentSupportsServerBrowser(environmentId: EnvironmentI
export function readEnvironmentSupportsTitleRegeneration(environmentId: EnvironmentId): boolean {
return (
- appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities
- .threadTitleRegeneration === true
+ appAtomRegistry.get(environmentCapabilitiesAtom).get(environmentId)?.threadTitleRegeneration ===
+ true
);
}
@@ -249,8 +249,7 @@ export function readEnvironmentSupportsTitleRegeneration(environmentId: Environm
Same version-skew contract as settlement. */
export function readEnvironmentSupportsPinning(environmentId: EnvironmentId): boolean {
return (
- appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities
- .threadPinning === true
+ appAtomRegistry.get(environmentCapabilitiesAtom).get(environmentId)?.threadPinning === true
);
}
@@ -258,8 +257,7 @@ export function readEnvironmentSupportsPinning(environmentId: EnvironmentId): bo
orderKey on thread.pin). Same version-skew contract as settlement. */
export function readEnvironmentSupportsPinReorder(environmentId: EnvironmentId): boolean {
return (
- appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities
- .threadPinReorder === true
+ appAtomRegistry.get(environmentCapabilitiesAtom).get(environmentId)?.threadPinReorder === true
);
}
@@ -267,15 +265,15 @@ export function readEnvironmentSupportsPinReorder(environmentId: EnvironmentId):
Same version-skew contract as settlement. */
export function readEnvironmentSupportsAutoSettleOptOut(environmentId: EnvironmentId): boolean {
return (
- appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities
- .threadAutoSettleOptOut === true
+ appAtomRegistry.get(environmentCapabilitiesAtom).get(environmentId)?.threadAutoSettleOptOut ===
+ true
);
}
export function readEnvironmentSupportsActiveReorder(environmentId: EnvironmentId): boolean {
return (
- appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities
- .threadActiveReorder === true
+ appAtomRegistry.get(environmentCapabilitiesAtom).get(environmentId)?.threadActiveReorder ===
+ true
);
}
@@ -284,18 +282,14 @@ export function readEnvironmentSupportsActiveReorder(environmentId: EnvironmentI
so clients under version skew fall back instead of erroring. */
export function readEnvironmentSupportsSettlement(environmentId: EnvironmentId): boolean {
return (
- appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities
- .threadSettlement === true
+ appAtomRegistry.get(environmentCapabilitiesAtom).get(environmentId)?.threadSettlement === true
);
}
/** Whether the environment's server understands thread.snooze/unsnooze.
Same version-skew contract as settlement. */
export function readEnvironmentSupportsSnooze(environmentId: EnvironmentId): boolean {
- return (
- appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities
- .threadSnooze === true
- );
+ return appAtomRegistry.get(environmentCapabilitiesAtom).get(environmentId)?.threadSnooze === true;
}
/** Whether the environment's server understands thread.visit/mark-unread and
@@ -304,8 +298,8 @@ export function readEnvironmentSupportsSnooze(environmentId: EnvironmentId): boo
state instead. */
export function readEnvironmentSupportsVisitedTracking(environmentId: EnvironmentId): boolean {
return (
- appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities
- .threadVisitedTracking === true
+ appAtomRegistry.get(environmentCapabilitiesAtom).get(environmentId)?.threadVisitedTracking ===
+ true
);
}
diff --git a/apps/web/src/state/server.ts b/apps/web/src/state/server.ts
index 14cc31ae5e3b051e1205fd287915df896d1e25e5..f82a8465754783b8219a129d974376ddb073432b 100644
--- a/apps/web/src/state/server.ts
+++ b/apps/web/src/state/server.ts
@@ -1,3 +1,5 @@
+import { createHubEnvironmentCapabilitiesAtom } from "@t3tools/client-runtime/state/hub";
+import { hubConnection } from "../connection/hub";
import {
DEFAULT_SERVER_SETTINGS,
type EditorId,
@@ -39,6 +41,10 @@ export const environmentServerConfigsAtom = createEnvironmentServerConfigsAtom({
catalogValueAtom: environmentCatalog.catalogValueAtom,
serverConfigValueAtom: serverEnvironment.configValueAtom,
});
+export const environmentCapabilitiesAtom = createHubEnvironmentCapabilitiesAtom(
+ environmentServerConfigsAtom,
+ hubConnection,
+);
interface PrimaryServerState {
readonly config: ServerConfig | null;
diff --git a/apps/web/src/state/session.ts b/apps/web/src/state/session.ts
index 883a899ef2c0b328c9b1fc7ec8721b5366fdd8f6..5286fb3a82c336fcda64018b8fa54194da9d1e1c 100644
--- a/apps/web/src/state/session.ts
+++ b/apps/web/src/state/session.ts
@@ -1,6 +1,8 @@
+import { hubConnection } from "../connection/hub";
import { useAtomValue } from "@effect/atom-react";
import { createEnvironmentSessionAtoms } from "@t3tools/client-runtime/state/session";
import {
+ AuthOrchestrationOperateScope,
type AuthEnvironmentScope,
type AuthSessionState,
type EnvironmentId,
@@ -101,3 +103,16 @@ export function useEnvironmentSessionState(environmentId: EnvironmentId) {
isPending: result.waiting,
};
}
+
+export function useThreadOrganizationPermission(environmentId: EnvironmentId | null): boolean {
+ const source = useEnvironmentScope(environmentId, AuthOrchestrationOperateScope);
+ const central = useAtomValue(hubConnection.organizationPermissionAtom(environmentId));
+ return source || central;
+}
+
+export function readThreadOrganizationPermission(environmentId: EnvironmentId): boolean {
+ return (
+ readEnvironmentScope(environmentId, AuthOrchestrationOperateScope) ||
+ appAtomRegistry.get(hubConnection.organizationPermissionAtom(environmentId))
+ );
+}
diff --git a/apps/web/src/state/use-orchestration-command.test.ts b/apps/web/src/state/use-orchestration-command.test.ts
index 44941063a8dc8b526f95973dcadf183fa7e2d9a9..81d06a69845746bf112900ff3c35b59ecb88707d 100644
--- a/apps/web/src/state/use-orchestration-command.test.ts
+++ b/apps/web/src/state/use-orchestration-command.test.ts
@@ -10,12 +10,17 @@ import { assert, beforeEach, expect, it, vi } from "vite-plus/test";
const state = vi.hoisted(() => ({
allowed: new Set(),
+ organization: new Set(),
readScope: vi.fn(),
run: vi.fn(),
}));
vi.mock("react", () => ({ useCallback: (callback: unknown) => callback }));
-vi.mock("./session", () => ({ readEnvironmentScope: state.readScope }));
+vi.mock("./session", () => ({
+ readEnvironmentScope: state.readScope,
+ readThreadOrganizationPermission: (id: string) =>
+ state.organization.has(id) || state.allowed.has(id),
+}));
vi.mock("./use-atom-command", () => ({ useAtomCommand: () => state.run }));
import { useOrchestrationCommand } from "./use-orchestration-command";
@@ -31,6 +36,7 @@ const target = (environmentId: EnvironmentId): Target => ({
beforeEach(() => {
state.allowed.clear();
+ state.organization.clear();
state.run.mockReset().mockResolvedValue(AsyncResult.success("receipt"));
state.readScope
.mockReset()
@@ -83,3 +89,14 @@ it("does not dispatch a later mutation after access changes while awaiting a rec
expect((await mutate(target(second)))._tag).toBe("Failure");
expect(state.run).toHaveBeenCalledTimes(1);
});
+
+it("accepts the central organization grant only for organization commands and rechecks revocation", async () => {
+ state.organization.add(second);
+ const organize = useOrchestrationCommand(command, { organization: true, reportFailure: false });
+ const execute = useOrchestrationCommand(command, { reportFailure: false });
+ expect((await organize(target(second)))._tag).toBe("Success");
+ expect((await execute(target(second)))._tag).toBe("Failure");
+ state.organization.clear();
+ expect((await organize(target(second)))._tag).toBe("Failure");
+ expect(state.run).toHaveBeenCalledTimes(1);
+});
diff --git a/apps/web/src/state/use-orchestration-command.ts b/apps/web/src/state/use-orchestration-command.ts
index 71bee0a35b5d23965a7f99a2dcb1b1621244b23d..41e20d995a51463b38969960404a994d1b2fd623 100644
--- a/apps/web/src/state/use-orchestration-command.ts
+++ b/apps/web/src/state/use-orchestration-command.ts
@@ -12,7 +12,7 @@ import * as Cause from "effect/Cause";
import { AsyncResult } from "effect/reactivity";
import { useCallback } from "react";
-import { readEnvironmentScope } from "./session";
+import { readEnvironmentScope, readThreadOrganizationPermission } from "./session";
import { useAtomCommand } from "./use-atom-command";
export const ORCHESTRATION_OPERATE_DENIED_MESSAGE =
@@ -21,12 +21,16 @@ export const ORCHESTRATION_OPERATE_DENIED_MESSAGE =
/** Recheck the target grant for every step of a thread or project mutation. */
export function useOrchestrationCommand(
command: AtomCommand,
- options?: string | AtomCommandOptions,
+ options?: string | (AtomCommandOptions & { readonly organization?: true }),
): (value: W) => Promise> {
const run = useAtomCommand(command, options);
+ const organization = typeof options === "object" && options.organization === true;
return useCallback(
async (value: W): Promise> => {
- if (!readEnvironmentScope(value.environmentId, AuthOrchestrationOperateScope)) {
+ const permitted = organization
+ ? readThreadOrganizationPermission(value.environmentId)
+ : readEnvironmentScope(value.environmentId, AuthOrchestrationOperateScope);
+ if (!permitted) {
return AsyncResult.failure(
Cause.fail(
new EnvironmentAuthorizationError({
@@ -38,6 +42,6 @@ export function useOrchestrationCommand unexpectedEndpoint("auth.delegatedSession"))
.handle("token", () => unexpectedEndpoint("auth.token"))
.handle("webSocketTicket", () => unexpectedEndpoint("auth.webSocketTicket"))
.handle(
diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts
index cf343e26a4fcf9ffbe1e9d208753d319bdbc9581..44e07a8e1792844183399a9fdbabda88188ac01c 100644
--- a/apps/web/vite.config.ts
+++ b/apps/web/vite.config.ts
@@ -253,7 +253,9 @@ export default defineConfig(() => {
{
target: devProxyTarget,
changeOrigin: !DEV_PROXIED_ORIGIN_PRESERVING_PREFIXES.has(prefix),
- ...(prefix === "/ws" || prefix === "/api" ? { ws: true } : {}),
+ ...(prefix === "/ws" || prefix === "/api" || prefix === "/environments"
+ ? { ws: true }
+ : {}),
},
]),
),
diff --git a/compose.sso.yaml b/compose.sso.yaml
new file mode 100644
index 0000000000000000000000000000000000000000..3f153dc53a9f3acb9feb8a40f669ca814bb46b77
--- /dev/null
+++ b/compose.sso.yaml
@@ -0,0 +1,12 @@
+services:
+ knife:
+ environment:
+ T3CODE_SSO_CONFIG: /run/secrets/knife-oidc.json
+ T3CODE_ARCHIVE_STORE_ID: ${KNIFE_ARCHIVE_STORE_ID:?Set the archive store identity}
+ volumes:
+ - type: bind
+ source: ${KNIFE_SSO_CONFIG:?Set the private OIDC configuration path}
+ target: /run/secrets/knife-oidc.json
+ read_only: true
+ bind:
+ create_host_path: false
diff --git a/docs/operations/development.md b/docs/operations/development.md
index ac427db9428a24a9c8efaf59540f09623ed1af1d..38267e42c617f0df0241843563765dd1b8ede189 100644
--- a/docs/operations/development.md
+++ b/docs/operations/development.md
@@ -369,7 +369,81 @@ four minutes without restarting or replacing the running code. A feed outage use
baseline; a checksum or dependency mismatch stops startup. Rebuild the base image
when the server's locked native dependency closure or container bootstrap changes.
-Enroll execution hosts with the private authority using the existing
-[`t3 shared` commands](../user/shared-server.md). Each machine gets its own
-revocable session credential. The authority owns Codex token refreshes; Claude
-accounts need setup tokens. Connected hosts synchronize accounts automatically.
+Use hosted sign-in below for desktop enrollment and archive uploads. Optional
+[shared provider accounts](../user/shared-server.md#share-agent-accounts) use separate
+grants; signing into the server does not export local provider credentials.
+
+### Hosted sign-in
+
+Register a confidential OpenID Connect application with your identity provider:
+
+| Setting | Value |
+| --------------------- | ------------------------------------------------------------------- |
+| Grant types | `authorization_code`, `refresh_token` |
+| Response type | `code` |
+| Callback | `https://knife.example.com/api/auth/oidc/callback`, matched exactly |
+| PKCE | Required, `S256` |
+| Client authentication | `client_secret_basic` |
+| ID token algorithm | `RS256` |
+| Scopes | `openid offline_access`; allow refresh tokens |
+
+The issuer callback is HTTPS. `t3code://app/self-hosted/callback` is a separate,
+proof-bound return to the desktop; do not register it with the issuer. There is no
+public native client or dynamic client registration.
+
+Set `T3CODE_SSO_CONFIG` to a private JSON file readable by the server process:
+
+```json
+{
+ "publicUrl": "https://knife.example.com",
+ "issuer": "https://identity.example.com",
+ "clientId": "knife",
+ "clientSecret": "PRIVATE_REGISTERED_CLIENT_SECRET",
+ "ownerSub": "IMMUTABLE_OWNER_SUBJECT",
+ "maxInteractiveAgeSeconds": 2592000,
+ "issuerRevalidationSeconds": 3600
+}
+```
+
+Use the exact issuer string from discovery and its immutable subject for the
+intended owner. `publicUrl` is the HTTPS origin without a trailing slash. Email,
+display name, and the first person to visit never establish ownership. Missing
+owner configuration prevents SSO from starting. Without `T3CODE_SSO_CONFIG`,
+existing pairing remains available.
+
+Keep this file outside the checkout, restrict it to its owner (`chmod 600`), and
+mount it read-only into containers. Pass its container path through
+`T3CODE_SSO_CONFIG`. Never include it in an image, public build share, or browser
+configuration. The same deployment needs persistent private server state and an
+HTTPS proxy that forwards WebSocket upgrades.
+
+For Compose, set `KNIFE_SSO_CONFIG` to that host file's absolute path and
+`KNIFE_ARCHIVE_STORE_ID` to the identity stored in the NAS archive directory's
+pre-existing `.knife-archive-store-id` file. The server never creates this marker;
+missing or mismatched storage identity prevents catalog publication and local
+payload eviction. Use the optional overlay:
+
+```sh
+docker compose -f compose.yaml -f compose.sso.yaml up -d --no-build
+```
+
+The overlay mounts the private configuration read-only and refuses to create a
+missing host file. Existing deployments can keep using the base Compose file.
+
+Access credentials last fifteen minutes. Native devices renew with rotating
+credentials kept in the operating system’s protected storage. By default, devices
+must complete browser sign-in again after thirty days. The server revalidates the
+issuer grant during renewal at least once per hour; issuer-side revocation is
+noticed on that check, and previously issued access can last another fifteen
+minutes. Local device revocation closes active connections and disables renewal.
+
+If the issuer is unavailable or a device is lost, run this on the server using its
+existing local state directory:
+
+```sh
+t3 auth devices revoke-all
+```
+
+This local recovery command revokes SSO devices and sessions without contacting
+the issuer. Existing pairing credentials remain valid. It does not change the
+configured owner or revoke identity-provider accounts.
diff --git a/docs/user/shared-server.md b/docs/user/shared-server.md
index e84036522add4253fe83e243029801e84b02bc7d..eb61696d52d373800fc611ac61b5b5b69c31bddf 100644
--- a/docs/user/shared-server.md
+++ b/docs/user/shared-server.md
@@ -1,4 +1,16 @@
-# Shared accounts
+# Self-hosted server
+
+A self-hosted server gives your browser and desktop one address for your machines and archived chats. Each machine keeps its workspaces and running agents. T3 Connect remains optional.
+
+## Connect your machines
+
+On a current Knife desktop, open **Connections**, choose **Self-hosted server**, and enter its HTTPS address. Sign in through the system browser. The desktop enrolls this machine and connects it to the server; private network addresses and transferred token files are unnecessary. Provider credentials stay on the machine unless you explicitly share them.
+
+Open the same address in a web browser to sign in and see your machines. A sleeping machine stays listed. Synced chats can be settled, snoozed, woken, and pinned while it is offline. The machine must reconnect before it can run work; cleanup from settling waits until then. Disconnecting keeps unsent drafts and leaves direct connections and running agents in place.
+
+Device access renews automatically until the server requires sign-in again. Revoking a device closes its connections and stops renewal. Existing manually paired machines keep their identities and saved chats; their pairing and `t3 shared hub` commands remain available for recovery.
+
+## Share agent accounts
A private T3 server can hold managed Codex and Claude accounts for your other hosts. Each host runs agents against its own workspaces. The shared server can run agents too. Provider homes and cached credentials live inside each host’s T3 home; existing CLI homes stay separate. This is opt-in and does not move native CLI history between machines.
@@ -25,14 +37,12 @@ Use `t3 shared status` to list shared accounts, `t3 shared sync` for an immediat
## Run in a container
-Build from this checkout with `docker build -t the-knife .`. The image includes the web client, server, Codex, and Claude. Mount persistent private storage and your workspaces:
+The image includes the web client, server, Codex, and Claude. Follow the [container and hosted sign-in setup](../operations/development.md#private-server-container), including the Compose SSO overlay, private configuration mount, and archive storage identity. The process runs as UID 1000; give it access to the mounted directories. Put an HTTPS reverse proxy in front of port 3773, including WebSocket upgrades, then connect clients using the server’s HTTPS address. Provider account sharing remains optional. Back up `/data` and `/home/node`, including their private credentials, independently of `/archives`.
-```sh
-docker run --name the-knife -p 127.0.0.1:3773:3773 \
- -e KNIFE_UPDATE_URL=https://files.example.com/public-builds/ \
- -v /private/t3:/data -v /private/provider-home:/home/node \
- -v /private/archives:/archives \
- -v /private/workspaces:/workspaces the-knife
-```
+## Offload archived chats
-The process runs as UID 1000; give it access to these directories. Put an HTTPS reverse proxy in front of port 3773, including WebSocket upgrades. Pair clients using the normal T3 pairing flow. Use `docker exec the-knife knife auth session issue …` for server-side account setup. Back up `/data` and `/home/node`, including their private credentials, independently of `/archives`.
+The container’s `T3CODE_ARCHIVE_DIR` points at the private archive mount. Signing a desktop into the self-hosted server also configures its background archive uploads while its local server runs. Keep the archive mount separate from the T3 state mount. Existing hosts using `t3 shared connect` and `T3CODE_ARCHIVE_OFFLOAD=true` keep their saved archive locations.
+
+Archived chats upload automatically in the background. Message and tool bodies are removed locally only after verified upload; titles, projects, timeline indexes, and control metadata stay local. Opening a chat fetches its bodies without retaining them. Unarchiving restores the bodies and attachments before the chat can run again. Local attachment bytes stay while an active chat or fork needs them. Workspace media is copied into the archive; your original files are left to the normal workspace lifecycle.
+
+Uploads retry automatically after failures, with delays increasing from one minute to one hour; the queue survives restarts. `t3 shared archive status` shows pending uploads and failures; `t3 shared archive retry [THREAD_ID]` requests an immediate attempt. A missing mount, failed verification, or concurrent chat change retains the local copy. Synced chats and attachments remain readable from the server while their source machine sleeps. Back up the archive mount: its verified objects become the authoritative copy of offloaded bodies.
diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json
index 360c4bd0bd5f540c1e96f2d6fb78bcccdda1f127..c04217be3aede8254d1ba3a99bad024e7b563a8a 100644
--- a/packages/client-runtime/package.json
+++ b/packages/client-runtime/package.json
@@ -3,6 +3,10 @@
"private": true,
"type": "module",
"exports": {
+ "./state/hub": {
+ "types": "./src/state/hub.ts",
+ "default": "./src/state/hub.ts"
+ },
"./composerThreadItems": {
"types": "./src/composerThreadItems.ts",
"default": "./src/composerThreadItems.ts"
diff --git a/packages/client-runtime/src/authorization/remote.test.ts b/packages/client-runtime/src/authorization/remote.test.ts
index 54290c469d826e46043a24aaba11b8d0503b7881..44dbed210942aa331c49736ffc418615464212df 100644
--- a/packages/client-runtime/src/authorization/remote.test.ts
+++ b/packages/client-runtime/src/authorization/remote.test.ts
@@ -16,6 +16,7 @@ import {
RemoteEnvironmentAuthInvalidJsonError,
RemoteEnvironmentAuthTimeoutError,
resolveRemoteWebSocketConnectionUrl,
+ resolveRemoteDpopWebSocketConnectionUrl,
} from "./remote.ts";
import { fetchRemoteEnvironmentDescriptor } from "../environment/descriptor.ts";
import * as RpcHttp from "../rpc/http.ts";
@@ -90,6 +91,62 @@ const expectFetchCall = (
};
describe("remote environment authorization", () => {
+ it.effect("keeps prefixed descriptor, auth, proof, and socket URLs on one environment", () =>
+ Effect.gen(function* () {
+ const base = "https://hub.example/prefix/environments/one/";
+ const ticket = () =>
+ Response.json({ ticket: "ws-ticket", expiresAt: "2026-05-01T12:05:00.000Z" });
+ const fetch = recordedFetch(
+ Response.json({
+ environmentId: "one",
+ label: "One",
+ platform: { os: "linux", arch: "x64" },
+ serverVersion: "test",
+ capabilities: { repositoryIdentity: true },
+ }),
+ Response.json({
+ access_token: "access",
+ issued_token_type: "urn:ietf:params:oauth:token-type:access_token",
+ token_type: "DPoP",
+ expires_in: 3600,
+ scope: "orchestration:read",
+ }),
+ ticket(),
+ ticket(),
+ );
+ yield* fetchRemoteEnvironmentDescriptor({ httpBaseUrl: base }).pipe(
+ provideRemoteHttp(fetch.fetchFn),
+ );
+ yield* exchangeRemoteDpopAccessToken({
+ httpBaseUrl: base,
+ credential: "bootstrap",
+ dpopProof: "proof",
+ }).pipe(provideRemoteHttp(fetch.fetchFn));
+ const bearerSocket = yield* resolveRemoteWebSocketConnectionUrl({
+ httpBaseUrl: base,
+ wsBaseUrl: base.replace("https:", "wss:"),
+ bearerToken: "bearer",
+ }).pipe(provideRemoteHttp(fetch.fetchFn));
+ const dpopSocket = yield* resolveRemoteDpopWebSocketConnectionUrl({
+ httpBaseUrl: base,
+ wsBaseUrl: base.replace("https:", "wss:"),
+ accessToken: "access",
+ dpopProof: "proof",
+ }).pipe(provideRemoteHttp(fetch.fetchFn));
+ expect(fetch.calls.map(([url]) => String(url))).toEqual([
+ `${base}.well-known/t3/environment`,
+ `${base}oauth/token`,
+ `${base}api/auth/websocket-ticket`,
+ `${base}api/auth/websocket-ticket`,
+ ]);
+ const urls = RpcHttp.makeEnvironmentHttpApiUrlBuilder(base);
+ expect(urls.auth.token()).toBe(`${base}oauth/token`);
+ expect(urls.auth.webSocketTicket()).toBe(`${base}api/auth/websocket-ticket`);
+ expect(bearerSocket).toBe("wss://hub.example/prefix/environments/one/ws?wsTicket=ws-ticket");
+ expect(dpopSocket).toBe(bearerSocket);
+ }),
+ );
+
it.effect("bootstraps bearer auth against a remote backend", () =>
Effect.gen(function* () {
const fetch = recordedFetch(
diff --git a/packages/client-runtime/src/authorization/remote.ts b/packages/client-runtime/src/authorization/remote.ts
index 425d95d3ac170c4d4d73186d2cc1e95ae4e28d39..32a3cde3105fe33a42f0112ef392e61d1cc28368 100644
--- a/packages/client-runtime/src/authorization/remote.ts
+++ b/packages/client-runtime/src/authorization/remote.ts
@@ -8,7 +8,7 @@ import {
} from "@t3tools/contracts";
import { encodeOAuthScope } from "@t3tools/shared/oauthScope";
import * as Effect from "effect/Effect";
-import { environmentEndpointUrl } from "../environment/endpoint.ts";
+import { environmentEndpointUrl, environmentWebSocketUrl } from "../environment/endpoint.ts";
import {
executeEnvironmentHttpRequest,
makeEnvironmentHttpApiGroupClient,
@@ -214,10 +214,7 @@ export const resolveRemoteWebSocketConnectionUrl = Effect.fn(
...(input.timeoutMs ? { timeoutMs: input.timeoutMs } : {}),
});
- const url = new URL(input.wsBaseUrl);
- if (url.pathname === "" || url.pathname === "/") {
- url.pathname = "/ws";
- }
+ const url = new URL(environmentWebSocketUrl(input.wsBaseUrl));
url.searchParams.set("wsTicket", issued.ticket);
appendClientConnectionParams(url, input.clientMetadata, input.connectionMethod);
return url.toString();
@@ -240,10 +237,7 @@ export const resolveRemoteDpopWebSocketConnectionUrl = Effect.fn(
dpopProof: input.dpopProof,
...(input.timeoutMs ? { timeoutMs: input.timeoutMs } : {}),
});
- const url = new URL(input.wsBaseUrl);
- if (url.pathname === "" || url.pathname === "/") {
- url.pathname = "/ws";
- }
+ const url = new URL(environmentWebSocketUrl(input.wsBaseUrl));
url.searchParams.set("wsTicket", issued.ticket);
appendClientConnectionParams(url, input.clientMetadata, input.connectionMethod);
return url.toString();
diff --git a/packages/client-runtime/src/connection/catalog.ts b/packages/client-runtime/src/connection/catalog.ts
index b0b1526a450756ba6b80d8e5e324cfcd182e7717..2bbe45041bdb2fc7d211969c0d941aa29b94a1d8 100644
--- a/packages/client-runtime/src/connection/catalog.ts
+++ b/packages/client-runtime/src/connection/catalog.ts
@@ -32,7 +32,7 @@ export class BearerConnectionProfile extends Schema.TaggedClass()(
+ "HubConnectionCredential",
+ {
+ connectionId: Schema.String,
+ },
+) {}
+
+export class HostedConnectionCredential extends Schema.TaggedClass()(
+ "HostedConnectionCredential",
+ { serverUrl: Schema.String },
+) {}
+
+export const ConnectionCredential = Schema.Union([
+ BearerConnectionCredential,
+ HubConnectionCredential,
+ HostedConnectionCredential,
+]);
export type ConnectionCredential = typeof ConnectionCredential.Type;
export class PrimaryConnectionRegistration extends Schema.TaggedClass()(
@@ -104,7 +120,7 @@ export class BearerConnectionRegistration extends Schema.TaggedClass ({
+ environmentId: EnvironmentId.make(id),
+ label: id,
+ platform: { os: "linux" as const, arch: "x64" as const },
+ serverVersion: "test",
+ orchestrationProtocolVersion: ORCHESTRATION_PROTOCOL_VERSION,
+ capabilities: { repositoryIdentity: true },
+});
+
+function harness() {
+ const profiles = new Map();
+ const credentials = new Map();
+ const requests: Array<{
+ url: string;
+ authorization: string | null;
+ method: string;
+ etag: string | null;
+ }> = [];
+ const writes: Array = [];
+ const commands: Array = [];
+ const cleanups: Array<{ id: EnvironmentId; retainDrafts?: boolean }> = [];
+ const control = {
+ machines: ["laptop", "desktop"],
+ offline: false,
+ badAddress: false,
+ protocolVersion: ORCHESTRATION_PROTOCOL_VERSION,
+ inboxStatus: 200,
+ notModified: false,
+ inboxEtag: '"inbox-v1"',
+ inboxSequence: 5,
+ token: "hub-secret",
+ hostedAuthorization: "Bearer hosted-ephemeral" as string | null,
+ failProfileWrite: false,
+ failCredentialWrite: false,
+ };
+ const dependencies = Layer.mergeAll(
+ Layer.succeed(Capabilities.HostedAuthAccess, {
+ headers: () =>
+ Effect.sync(() =>
+ control.hostedAuthorization === null
+ ? { "x-csrf-token": "cookie-proof" }
+ : { authorization: control.hostedAuthorization },
+ ),
+ }),
+ Http.layerRemoteHttpClient((async (input, init) => {
+ const url = String(input);
+ requests.push({
+ url,
+ authorization: new Headers(init?.headers).get("authorization"),
+ method: init?.method ?? "GET",
+ etag: new Headers(init?.headers).get("if-none-match"),
+ });
+ if (url.endsWith("/.well-known/t3/environment")) return Response.json(descriptor(HUB_ID));
+ if (url.endsWith("/oauth/token"))
+ return Response.json({
+ access_token: control.token,
+ issued_token_type: "urn:ietf:params:oauth:token-type:access_token",
+ token_type: "Bearer",
+ expires_in: 3600,
+ scope: AuthStandardClientScopes.join(" "),
+ });
+ if (url === `${BASE}api/hub/environments`) {
+ if (control.offline) throw new Error("offline");
+ return Response.json({
+ hubEnvironmentId: HUB_ID,
+ environments: control.machines.map((id) => ({
+ ...descriptor(id),
+ descriptor: {
+ ...descriptor(id),
+ orchestrationProtocolVersion: control.protocolVersion,
+ },
+ scopes: AuthStandardClientScopes,
+ httpBaseUrl: `${control.badAddress ? "https://elsewhere.example/" : BASE}environments/${id}/`,
+ })),
+ });
+ }
+ if (url === `${BASE}api/hub/inbox`) {
+ if (control.offline) throw new Error("offline");
+ if (control.inboxStatus !== 200) return new Response(null, { status: control.inboxStatus });
+ if (
+ control.notModified &&
+ init?.method !== "POST" &&
+ new Headers(init?.headers).get("if-none-match") === control.inboxEtag
+ ) {
+ return new Response(null, { status: 304 });
+ }
+ const entry: HubInboxEnvironment = {
+ environmentId: EnvironmentId.make("laptop"),
+ snapshot: {
+ schemaVersion: 2,
+ snapshotSequence: control.inboxSequence,
+ projects: [v2Project],
+ threads: [
+ {
+ ...v2ThreadShell,
+ id: ThreadId.make("chat"),
+ lineage: {
+ rootThreadId: ThreadId.make("chat"),
+ parentThreadId: null,
+ relationshipToParent: null,
+ },
+ snoozedAt: v2ThreadShell.updatedAt,
+ snoozedUntil: DateTime.makeUnsafe("2030-01-01T00:00:00.000Z"),
+ },
+ ],
+ archivedThreads: [],
+ },
+ pendingThreadIds: [],
+ };
+ if (init?.method === "POST") {
+ commands.push(decodeInboxCommand(JSON.parse(await new Response(init.body).text())));
+ }
+ const body =
+ init?.method === "POST"
+ ? encodeInboxEnvironment(entry)
+ : encodeInbox({ environments: [entry] });
+ return Response.json(body, {
+ headers: { etag: control.inboxEtag },
+ });
+ }
+ throw new Error(`Unexpected URL ${url}`);
+ }) satisfies typeof fetch),
+ Layer.succeed(Capabilities.ClientPresentation, {
+ metadata: { label: "Phone", deviceType: "mobile", surface: "mobile" },
+ }),
+ Layer.succeed(Profiles.ConnectionProfileStore, {
+ get: (id) => Effect.succeed(Option.fromUndefinedOr(profiles.get(id))),
+ put: (profile) =>
+ Effect.suspend(() =>
+ control.failProfileWrite
+ ? Effect.fail(
+ new ConnectionBlockedError({
+ reason: "configuration",
+ detail: "Profile storage unavailable",
+ }),
+ )
+ : Effect.sync(() => {
+ profiles.set(profile.connectionId, profile);
+ }),
+ ),
+ remove: (id) =>
+ Effect.sync(() => {
+ profiles.delete(id);
+ }),
+ }),
+ Layer.succeed(Credentials.ConnectionCredentialStore, {
+ get: (id) => Effect.succeed(Option.fromUndefinedOr(credentials.get(id))),
+ put: (id, credential) =>
+ Effect.suspend(() =>
+ control.failCredentialWrite
+ ? Effect.fail(
+ new ConnectionBlockedError({
+ reason: "configuration",
+ detail: "Credential storage unavailable",
+ }),
+ )
+ : Effect.sync(() => {
+ credentials.set(id, credential);
+ }),
+ ),
+ remove: (id) =>
+ Effect.sync(() => {
+ credentials.delete(id);
+ }),
+ }),
+ Layer.mock(Persistence.ConnectionTargetStore)({
+ list: Effect.succeed([]),
+ listDisabled: Effect.succeed([]),
+ }),
+ Layer.mock(Persistence.ConnectionRegistrationStore)({
+ register: (registration) =>
+ Effect.sync(() => {
+ writes.push(registration.target.environmentId);
+ if (registration._tag === "BearerConnectionRegistration") {
+ profiles.set(registration.profile.connectionId, registration.profile);
+ credentials.set(registration.target.connectionId, registration.credential);
+ }
+ }),
+ setRoutes: () => Effect.void,
+ remove: () => Effect.void,
+ setEnabled: () => Effect.void,
+ }),
+ Layer.mock(Persistence.EnvironmentCacheStore)({ clear: () => Effect.void }),
+ Layer.succeed(Persistence.EnvironmentOwnedDataCleanup, {
+ clear: (id, options) =>
+ Effect.sync(() => {
+ cleanups.push({ id, ...options });
+ }),
+ }),
+ Layer.mock(Capabilities.SshEnvironmentGateway)({}),
+ Layer.succeed(Connectivity.Connectivity, {
+ status: Effect.succeed("online" as const),
+ changes: Stream.never,
+ }),
+ Layer.succeed(Wakeups.ConnectionWakeups, { changes: Stream.never }),
+ Layer.mock(Driver.ConnectionDriver)({ connect: () => Effect.never }),
+ );
+ const registry = Registry.layer.pipe(Layer.provideMerge(dependencies));
+ return {
+ profiles,
+ credentials,
+ requests,
+ writes,
+ commands,
+ cleanups,
+ control,
+ layer: Hub.layer.pipe(Layer.provideMerge(registry)),
+ };
+}
+
+describe("self-hosted server connections", () => {
+ it.effect(
+ "stores only a server marker for SSO and renews native headers through the broker",
+ () => {
+ const test = harness();
+ return Effect.gen(function* () {
+ const hub = yield* Hub.HubConnection;
+ yield* hub.connectAuthenticated({ httpBaseUrl: BASE });
+ expect(test.credentials.get(`hub-session:${HUB_ID}`)).toEqual(
+ new HostedConnectionCredential({ serverUrl: BASE }),
+ );
+ expect(test.requests.some((request) => request.url.endsWith("/oauth/token"))).toBe(false);
+ expect(yield* hub.headers).toEqual({ authorization: "Bearer hosted-ephemeral" });
+ test.control.hostedAuthorization = "Bearer renewed-ephemeral";
+ yield* hub.refreshInbox;
+ expect(test.requests.at(-1)?.authorization).toBe("Bearer renewed-ephemeral");
+ test.control.hostedAuthorization = null;
+ expect(yield* hub.headers).toEqual({ "x-csrf-token": "cookie-proof" });
+ yield* hub.refreshInbox;
+ expect(test.requests.at(-1)?.authorization).toBe(null);
+ }).pipe(Effect.provide(test.layer));
+ },
+ );
+
+ it.effect("preserves paired access if SSO connection validation or persistence fails", () => {
+ const test = harness();
+ return Effect.gen(function* () {
+ const hub = yield* Hub.HubConnection;
+ yield* hub.connect({ host: BASE, pairingCode: "code" });
+ const previous = test.credentials.get(`hub-session:${HUB_ID}`);
+ test.control.offline = true;
+ yield* Effect.option(hub.connectAuthenticated({ httpBaseUrl: BASE }));
+ expect(test.credentials.get(`hub-session:${HUB_ID}`)).toEqual(previous);
+ test.control.offline = false;
+ test.control.failProfileWrite = true;
+ yield* Effect.option(hub.connectAuthenticated({ httpBaseUrl: BASE }));
+ expect(test.credentials.get(`hub-session:${HUB_ID}`)).toEqual(previous);
+ }).pipe(Effect.provide(test.layer));
+ });
+
+ it.effect("round-trips timestamped inbox snapshots and organization commands as JSON", () => {
+ const test = harness();
+ return Effect.gen(function* () {
+ const hub = yield* Hub.HubConnection;
+ yield* hub.connect({ host: BASE, pairingCode: "code" });
+ const thread = (yield* SubscriptionRef.get(hub.state)).inbox.environments[0]?.snapshot
+ .threads[0];
+ expect(DateTime.formatIso(thread!.createdAt)).toBe("2026-06-20T00:00:00.000Z");
+ expect(DateTime.formatIso(thread!.snoozedUntil!)).toBe("2030-01-01T00:00:00.000Z");
+ const settledAt = DateTime.makeUnsafe("2026-06-21T12:34:56.000Z");
+ yield* hub.organize({
+ environmentId: EnvironmentId.make("laptop"),
+ command: {
+ type: "thread.settle",
+ commandId: CommandId.make("settle-with-timestamp"),
+ threadId: ThreadId.make("chat"),
+ settledAt,
+ },
+ });
+ expect(test.commands).toEqual([
+ {
+ environmentId: EnvironmentId.make("laptop"),
+ command: {
+ type: "thread.settle",
+ commandId: CommandId.make("settle-with-timestamp"),
+ threadId: ThreadId.make("chat"),
+ settledAt,
+ },
+ },
+ ]);
+ const updated = (yield* SubscriptionRef.get(hub.state)).inbox.environments[0]?.snapshot
+ .threads[0];
+ expect(DateTime.formatIso(updated!.updatedAt)).toBe("2026-06-20T00:00:00.000Z");
+ expect(DateTime.formatIso(updated!.snoozedAt!)).toBe("2026-06-20T00:00:00.000Z");
+ }).pipe(Effect.provide(test.layer), Effect.scoped);
+ });
+ it.effect("accepts organization through the hub while the source has no connection", () => {
+ const test = harness();
+ return Effect.gen(function* () {
+ const hub = yield* Hub.HubConnection;
+ yield* hub.connect({ host: BASE, pairingCode: "code" });
+ const result = yield* hub.organize({
+ environmentId: EnvironmentId.make("laptop"),
+ command: {
+ type: "thread.settle",
+ commandId: CommandId.make("settle-offline"),
+ threadId: ThreadId.make("chat"),
+ },
+ });
+ expect(Option.getOrNull(result)).toEqual({ sequence: 5 });
+ expect(test.requests.at(-1)).toEqual({
+ url: `${BASE}api/hub/inbox`,
+ authorization: "Bearer hub-secret",
+ method: "POST",
+ etag: null,
+ });
+ const direct = yield* hub.organize({
+ environmentId: EnvironmentId.make("direct-only"),
+ command: {
+ type: "thread.unpin",
+ commandId: CommandId.make("unpin-direct"),
+ threadId: ThreadId.make("chat"),
+ },
+ });
+ expect(Option.isNone(direct)).toBe(true);
+ test.control.inboxStatus = 403;
+ expect(
+ (yield* Effect.result(
+ hub.organize({
+ environmentId: EnvironmentId.make("laptop"),
+ command: {
+ type: "thread.pin",
+ commandId: CommandId.make("denied-pin"),
+ threadId: ThreadId.make("chat"),
+ },
+ }),
+ ))._tag,
+ ).toBe("Failure");
+ }).pipe(Effect.provide(test.layer), Effect.scoped);
+ });
+ it.effect("revalidates unchanged inbox metadata without replacing the cached snapshot", () => {
+ const test = harness();
+ return Effect.gen(function* () {
+ const hub = yield* Hub.HubConnection;
+ yield* hub.connect({ host: BASE, pairingCode: "code" });
+ const inbox = (yield* SubscriptionRef.get(hub.state)).inbox;
+ test.control.notModified = true;
+ yield* hub.refreshInbox;
+ expect(test.requests.at(-1)?.etag).toBe('"inbox-v1"');
+ expect((yield* SubscriptionRef.get(hub.state)).inbox).toBe(inbox);
+ }).pipe(Effect.provide(test.layer), Effect.scoped);
+ });
+ it.effect("reuses the validated body after a reconnect fails to persist its profile", () => {
+ const test = harness();
+ return Effect.gen(function* () {
+ const hub = yield* Hub.HubConnection;
+ yield* hub.connect({ host: BASE, pairingCode: "code" });
+ test.control.inboxSequence = 6;
+ test.control.inboxEtag = '"inbox-v2"';
+ test.control.token = "new-account-token";
+ test.control.failProfileWrite = true;
+ yield* hub.connect({ host: BASE, pairingCode: "new-code" }).pipe(Effect.flip);
+ expect(
+ (yield* SubscriptionRef.get(hub.state)).inbox.environments[0]?.snapshot.snapshotSequence,
+ ).toBe(5);
+ test.control.notModified = true;
+ yield* hub.refreshInbox;
+ expect(test.requests.at(-1)?.etag).toBe('"inbox-v2"');
+ expect(
+ (yield* SubscriptionRef.get(hub.state)).inbox.environments[0]?.snapshot.snapshotSequence,
+ ).toBe(6);
+ }).pipe(Effect.provide(test.layer), Effect.scoped);
+ });
+
+ it.effect(
+ "never sends a validator fetched with another credential after failed pairing persistence",
+ () => {
+ const test = harness();
+ return Effect.gen(function* () {
+ const hub = yield* Hub.HubConnection;
+ yield* hub.connect({ host: BASE, pairingCode: "code" });
+ test.control.inboxSequence = 6;
+ test.control.inboxEtag = '"inbox-v2"';
+ test.control.token = "new-account-token";
+ test.control.failCredentialWrite = true;
+ yield* hub.connect({ host: BASE, pairingCode: "new-code" }).pipe(Effect.flip);
+ test.control.notModified = true;
+ yield* hub.refreshInbox;
+ expect(test.requests.at(-1)?.authorization).toBe("Bearer hub-secret");
+ expect(test.requests.at(-1)?.etag).toBeNull();
+ }).pipe(Effect.provide(test.layer), Effect.scoped);
+ },
+ );
+ it.effect(
+ "clears an outdated machine's compatibility rejection after its advertised protocol updates",
+ () => {
+ const test = harness();
+ test.control.protocolVersion = ORCHESTRATION_PROTOCOL_VERSION - 1;
+ return Effect.gen(function* () {
+ const hub = yield* Hub.HubConnection;
+ const registry = yield* Registry.EnvironmentRegistry;
+ yield* hub.connect({ host: BASE, pairingCode: "code" });
+ const id = EnvironmentId.make("laptop");
+ expect(
+ (yield* SubscriptionRef.get(registry.entries)).get(id)?.unsupportedReason,
+ ).toBeDefined();
+ test.control.protocolVersion = ORCHESTRATION_PROTOCOL_VERSION;
+ yield* hub.refresh;
+ expect(
+ (yield* SubscriptionRef.get(registry.entries)).get(id)?.unsupportedReason,
+ ).toBeUndefined();
+ yield* registry.setEnabled(id, true);
+ expect((yield* SubscriptionRef.get(registry.entries)).get(id)?.enabled).toBe(true);
+ }).pipe(Effect.provide(test.layer), Effect.scoped);
+ },
+ );
+ it.effect(
+ "pairs once for two machines, links one secret, and leaves unchanged routes running on refresh",
+ () => {
+ const test = harness();
+ return Effect.gen(function* () {
+ const hub = yield* Hub.HubConnection;
+ const registry = yield* Registry.EnvironmentRegistry;
+ yield* hub.connect({ host: BASE, pairingCode: "one-use-code" });
+ const entries = yield* SubscriptionRef.get(registry.entries);
+ expect([...entries.keys()]).toEqual(["laptop", "desktop"]);
+ expect(
+ test.requests.filter((request) => request.url.endsWith("/oauth/token")),
+ ).toHaveLength(1);
+ expect(
+ [...test.credentials.values()].filter(
+ (value) => value._tag === "BearerConnectionCredential",
+ ),
+ ).toEqual([new BearerConnectionCredential({ token: "hub-secret" })]);
+ expect(test.credentials.get("hub:hub:laptop")).toMatchObject({
+ _tag: "HubConnectionCredential",
+ connectionId: "hub-session:hub",
+ });
+ expect(
+ test.requests.find((request) => request.url.endsWith("/api/hub/environments"))
+ ?.authorization,
+ ).toBe("Bearer hub-secret");
+ yield* hub.refresh;
+ expect(test.writes).toEqual(["laptop", "desktop"]);
+ expect(yield* SubscriptionRef.get(registry.entries)).toBe(entries);
+ const restored = yield* Hub.make;
+ expect((yield* SubscriptionRef.get(restored.state)).connection?.httpBaseUrl).toBe(BASE);
+ }).pipe(Effect.provide(test.layer), Effect.scoped);
+ },
+ );
+
+ it.effect(
+ "keeps saved machines during an outage and discovers later enrollment on refresh",
+ () => {
+ const test = harness();
+ return Effect.gen(function* () {
+ const hub = yield* Hub.HubConnection;
+ const registry = yield* Registry.EnvironmentRegistry;
+ yield* hub.connect({ host: BASE, pairingCode: "one-use-code" });
+ const entries = yield* SubscriptionRef.get(registry.entries);
+ test.control.offline = true;
+ expect((yield* Effect.result(hub.refresh))._tag).toBe("Failure");
+ expect(yield* SubscriptionRef.get(registry.entries)).toBe(entries);
+ expect((yield* SubscriptionRef.get(hub.state)).error).toContain("Couldn't reach");
+ test.control.offline = false;
+ test.control.machines.push("new-machine");
+ yield* hub.refresh;
+ expect([...(yield* SubscriptionRef.get(registry.entries)).keys()]).toEqual([
+ "laptop",
+ "desktop",
+ "new-machine",
+ ]);
+ }).pipe(Effect.provide(test.layer), Effect.scoped);
+ },
+ );
+
+ it.effect(
+ "rejects a directory that sends machine traffic outside the paired origin without saving credentials",
+ () => {
+ const test = harness();
+ test.control.badAddress = true;
+ return Effect.gen(function* () {
+ const hub = yield* Hub.HubConnection;
+ const registry = yield* Registry.EnvironmentRegistry;
+ expect((yield* Effect.result(hub.connect({ host: BASE, pairingCode: "code" })))._tag).toBe(
+ "Failure",
+ );
+ expect(test.credentials.size).toBe(0);
+ expect((yield* SubscriptionRef.get(registry.entries)).size).toBe(0);
+ }).pipe(Effect.provide(test.layer), Effect.scoped);
+ },
+ );
+
+ it.effect(
+ "disconnects only hub-owned routes, preserving directly paired and T3 Connect routes",
+ () => {
+ const test = harness();
+ return Effect.gen(function* () {
+ const hub = yield* Hub.HubConnection;
+ const registry = yield* Registry.EnvironmentRegistry;
+ yield* hub.connect({ host: BASE, pairingCode: "code" });
+ const id = EnvironmentId.make("laptop");
+ const connectionId = "direct-laptop";
+ yield* registry.register(
+ new BearerConnectionRegistration({
+ target: new BearerConnectionTarget({
+ environmentId: id,
+ label: "Laptop",
+ connectionId,
+ }),
+ profile: new BearerConnectionProfile({
+ environmentId: id,
+ label: "Laptop",
+ connectionId,
+ httpBaseUrl: "https://laptop.example/",
+ wsBaseUrl: "wss://laptop.example/",
+ }),
+ credential: new BearerConnectionCredential({ token: "direct-secret" }),
+ }),
+ );
+ yield* registry.register(
+ new RelayConnectionRegistration({
+ target: new RelayConnectionTarget({ environmentId: id, label: "Laptop" }),
+ }),
+ );
+ yield* hub.disconnect;
+ const entries = yield* SubscriptionRef.get(registry.entries);
+ expect([...entries.keys()]).toEqual([id]);
+ expect(
+ connectionRoutes(entries.get(id)!).map((route) => connectionRouteId(route.target)),
+ ).toEqual([connectionId, "relay"]);
+ expect(test.cleanups).toEqual([{ id: "desktop", retainDrafts: true }]);
+ expect(test.credentials.has("hub-session:hub")).toBe(false);
+ expect((yield* SubscriptionRef.get(hub.state)).connection).toBeNull();
+ }).pipe(Effect.provide(test.layer), Effect.scoped);
+ },
+ );
+});
diff --git a/packages/client-runtime/src/connection/hub.ts b/packages/client-runtime/src/connection/hub.ts
new file mode 100644
index 0000000000000000000000000000000000000000..dfc1e1e174010fe15216cd5ded565e6bc3755a04
--- /dev/null
+++ b/packages/client-runtime/src/connection/hub.ts
@@ -0,0 +1,580 @@
+import {
+ EnvironmentId,
+ HubDirectory,
+ HubInbox,
+ HubInboxCommand,
+ HubInboxEnvironment,
+} from "@t3tools/contracts";
+import * as Context from "effect/Context";
+import * as Effect from "effect/Effect";
+import * as Equal from "effect/Equal";
+import * as Layer from "effect/Layer";
+import * as Option from "effect/Option";
+import * as Schema from "effect/Schema";
+import * as Semaphore from "effect/Semaphore";
+import * as SubscriptionRef from "effect/SubscriptionRef";
+import { HttpBody, HttpClient } from "effect/http";
+
+import {
+ BearerConnectionCredential,
+ BearerConnectionProfile,
+ BearerConnectionRegistration,
+ HubConnectionCredential,
+ HostedConnectionCredential,
+} from "./catalog.ts";
+import {
+ BearerConnectionTarget,
+ ConnectionBlockedError,
+ ConnectionTransientError,
+} from "./model.ts";
+import type { ConnectionAttemptError } from "./model.ts";
+import { preparePairingRegistration, type PairingConnectionInput } from "./onboarding.ts";
+import * as CredentialStore from "./credentialStore.ts";
+import * as ProfileStore from "./profileStore.ts";
+import * as Registry from "./registry.ts";
+import { connectionRouteId, connectionRoutes } from "./routes.ts";
+import { orchestrationProtocolCompatibilityError } from "./compatibility.ts";
+import { fetchRemoteEnvironmentDescriptor } from "../environment/descriptor.ts";
+import { deriveWsBaseUrl, environmentEndpointUrl } from "../environment/endpoint.ts";
+import * as ClientCapabilities from "../platform/capabilities.ts";
+
+const HUB_PROFILE_ID = "self-hosted-server";
+const isConnectionAttemptError = Schema.is(
+ Schema.Union([ConnectionBlockedError, ConnectionTransientError]),
+);
+const encodeInboxCommand = Schema.encodeEffect(Schema.toCodecJson(HubInboxCommand));
+const decodeInbox = Schema.decodeUnknownEffect(Schema.toCodecJson(HubInbox));
+const decodeInboxEnvironment = Schema.decodeUnknownEffect(Schema.toCodecJson(HubInboxEnvironment));
+
+export interface HubServerConnection {
+ readonly environmentId: EnvironmentId;
+ readonly label: string;
+ readonly httpBaseUrl: string;
+ readonly authorization?: "t3-connect" | "hosted";
+}
+
+export interface HubConnectionState {
+ readonly connection: HubServerConnection | null;
+ readonly directory: HubDirectory | null;
+ readonly inbox: HubInbox;
+ readonly refreshing: boolean;
+ readonly error: string | null;
+}
+
+export const EMPTY_HUB_CONNECTION_STATE: HubConnectionState = {
+ connection: null,
+ directory: null,
+ inbox: { environments: [] },
+ refreshing: false,
+ error: null,
+};
+
+export class HubConnection extends Context.Service<
+ HubConnection,
+ {
+ readonly state: SubscriptionRef.SubscriptionRef;
+ readonly connect: (
+ input: PairingConnectionInput,
+ ) => Effect.Effect;
+ readonly connectAuthenticated: (input: {
+ readonly httpBaseUrl: string;
+ }) => Effect.Effect;
+ readonly headers: Effect.Effect, ConnectionAttemptError>;
+ readonly refresh: Effect.Effect;
+ readonly refreshInbox: Effect.Effect;
+ readonly organize: (
+ input: HubInboxCommand,
+ ) => Effect.Effect, ConnectionAttemptError>;
+ readonly disconnect: Effect.Effect;
+ }
+>()("@t3tools/client-runtime/connection/hub/HubConnection") {}
+
+const hubCredentialId = (environmentId: EnvironmentId) => `hub-session:${environmentId}`;
+const hubRoutePrefix = (environmentId: EnvironmentId) => `hub:${environmentId}:`;
+
+export const make = Effect.gen(function* () {
+ const profiles = yield* ProfileStore.ConnectionProfileStore;
+ const credentials = yield* CredentialStore.ConnectionCredentialStore;
+ const registry = yield* Registry.EnvironmentRegistry;
+ const http = yield* HttpClient.HttpClient;
+ const presentation = yield* ClientCapabilities.ClientPresentation;
+ const lock = yield* Semaphore.make(1);
+ const saved = Option.getOrNull(yield* profiles.get(HUB_PROFILE_ID));
+ const connection = saved?._tag === "BearerConnectionProfile" ? saved : null;
+ const state = yield* SubscriptionRef.make({
+ ...EMPTY_HUB_CONNECTION_STATE,
+ connection,
+ });
+ let inboxCache:
+ | {
+ readonly url: string;
+ readonly token: string;
+ readonly etag: string | undefined;
+ readonly inbox: HubInbox;
+ }
+ | undefined;
+
+ const currentCredential = Effect.fn("HubConnection.currentCredential")(function* (
+ profile: HubServerConnection,
+ ) {
+ const credential = Option.getOrNull(
+ yield* credentials.get(hubCredentialId(profile.environmentId)),
+ );
+ if (
+ credential?._tag !== "BearerConnectionCredential" &&
+ credential?._tag !== "HostedConnectionCredential"
+ ) {
+ return yield* new ConnectionBlockedError({
+ reason: "authentication",
+ detail: "Sign in to this server to restore access.",
+ });
+ }
+ return credential;
+ });
+
+ const headersFor = (credential: BearerConnectionCredential | HostedConnectionCredential) =>
+ credential._tag === "BearerConnectionCredential"
+ ? Effect.succeed({ authorization: `Bearer ${credential.token}` } as Record)
+ : Effect.gen(function* () {
+ const access = yield* Effect.serviceOption(ClientCapabilities.HostedAuthAccess);
+ if (Option.isNone(access))
+ return yield* new ConnectionBlockedError({
+ reason: "authentication",
+ detail: "Sign in to this server to reconnect.",
+ });
+ return yield* access.value.headers(credential.serverUrl);
+ });
+
+ const requestInbox = Effect.fn("HubConnection.inbox")(
+ function* (
+ profile: HubServerConnection,
+ credential: BearerConnectionCredential | HostedConnectionCredential,
+ ) {
+ const url = environmentEndpointUrl(profile.httpBaseUrl, "/api/hub/inbox");
+ const headers = yield* headersFor(credential);
+ const cacheIdentity = headers.authorization ?? headers["x-csrf-token"] ?? "cookie";
+ const cached =
+ inboxCache?.url === url && inboxCache.token === cacheIdentity ? inboxCache : undefined;
+ const response = yield* http.get(url, {
+ headers: {
+ ...headers,
+ ...(cached?.etag === undefined ? {} : { "if-none-match": cached.etag }),
+ },
+ });
+ if (response.status === 304 && cached?.etag !== undefined) return cached.inbox;
+ if (response.status === 401 || response.status === 403) {
+ return yield* new ConnectionBlockedError({
+ reason: "authentication",
+ detail: "Sign in to this server to restore access.",
+ });
+ }
+ if (response.status !== 200) {
+ return yield* new ConnectionTransientError({
+ reason: "remote-unavailable",
+ detail: "Couldn't load chats from this server. Try again.",
+ });
+ }
+ const inbox = yield* response.json.pipe(Effect.flatMap(decodeInbox));
+ inboxCache = { url, token: cacheIdentity, etag: response.headers.etag, inbox };
+ return inbox;
+ },
+ Effect.timeout("15 seconds"),
+ Effect.mapError((error) =>
+ isConnectionAttemptError(error)
+ ? error
+ : new ConnectionTransientError({
+ reason: "remote-unavailable",
+ detail: "Couldn't load chats from this server. Try again.",
+ }),
+ ),
+ );
+
+ const requestDirectory = Effect.fn("HubConnection.directory")(
+ function* (
+ profile: HubServerConnection,
+ credential: BearerConnectionCredential | HostedConnectionCredential,
+ ) {
+ const response = yield* http
+ .get(environmentEndpointUrl(profile.httpBaseUrl, "/api/hub/environments"), {
+ headers: yield* headersFor(credential),
+ })
+ .pipe(
+ Effect.mapError(
+ () =>
+ new ConnectionTransientError({
+ reason: "remote-unavailable",
+ detail: "Couldn't reach this server. Check the address and try again.",
+ }),
+ ),
+ );
+ if (response.status === 401 || response.status === 403) {
+ return yield* new ConnectionBlockedError({
+ reason: "authentication",
+ detail: "Sign in to this server to restore access.",
+ });
+ }
+ if (response.status === 404) {
+ return yield* new ConnectionBlockedError({
+ reason: "unsupported",
+ detail: "This server can't share other machines. Pair it as a single machine.",
+ });
+ }
+ if (response.status !== 200) {
+ return yield* new ConnectionTransientError({
+ reason: "remote-unavailable",
+ detail: "The server is unavailable. Try again.",
+ });
+ }
+ const directory = yield* response.json.pipe(
+ Effect.flatMap(Schema.decodeUnknownEffect(HubDirectory)),
+ Effect.mapError(
+ () =>
+ new ConnectionBlockedError({
+ reason: "configuration",
+ detail: "The server returned an invalid machine list.",
+ }),
+ ),
+ );
+ if (directory.hubEnvironmentId !== profile.environmentId) {
+ return yield* new ConnectionBlockedError({
+ reason: "configuration",
+ detail: "This address reaches a different server. Check the address and pair again.",
+ });
+ }
+ const seen = new Set();
+ for (const machine of directory.environments) {
+ const expected = environmentEndpointUrl(
+ profile.httpBaseUrl,
+ `/environments/${encodeURIComponent(machine.environmentId)}/`,
+ );
+ if (
+ seen.has(machine.environmentId) ||
+ machine.descriptor.environmentId !== machine.environmentId ||
+ machine.httpBaseUrl !== expected
+ ) {
+ return yield* new ConnectionBlockedError({
+ reason: "configuration",
+ detail: "The server returned an invalid machine address.",
+ });
+ }
+ seen.add(machine.environmentId);
+ }
+ return directory;
+ },
+ Effect.timeoutOrElse({
+ duration: "15 seconds",
+ orElse: () =>
+ Effect.fail(
+ new ConnectionTransientError({
+ reason: "timeout",
+ detail: "The server took too long to respond. Try again.",
+ }),
+ ),
+ }),
+ );
+
+ const removeRoutes = Effect.fn("HubConnection.removeRoutes")(function* (
+ environmentId: EnvironmentId,
+ keep: ReadonlySet = new Set(),
+ ) {
+ const entries = yield* SubscriptionRef.get(registry.entries);
+ for (const [id, entry] of entries) {
+ for (const route of connectionRoutes(entry)) {
+ const routeId = connectionRouteId(route.target);
+ if (routeId.startsWith(hubRoutePrefix(environmentId)) && !keep.has(routeId)) {
+ yield* registry.removeRoute(id, routeId, { retainDrafts: true });
+ }
+ }
+ }
+ });
+
+ const reconcile = Effect.fn("HubConnection.reconcile")(function* (
+ profile: HubServerConnection,
+ directory: HubDirectory,
+ ) {
+ const keep = new Set();
+ for (const machine of directory.environments) {
+ const connectionId = `${hubRoutePrefix(profile.environmentId)}${machine.environmentId}`;
+ keep.add(connectionId);
+ const registration = new BearerConnectionRegistration({
+ target: new BearerConnectionTarget({
+ connectionId,
+ environmentId: machine.environmentId,
+ label: machine.label,
+ }),
+ profile: new BearerConnectionProfile({
+ connectionId,
+ environmentId: machine.environmentId,
+ label: machine.label,
+ httpBaseUrl: machine.httpBaseUrl,
+ wsBaseUrl: deriveWsBaseUrl(machine.httpBaseUrl),
+ }),
+ credential: new HubConnectionCredential({
+ connectionId: hubCredentialId(profile.environmentId),
+ }),
+ });
+ const entry = (yield* SubscriptionRef.get(registry.entries)).get(machine.environmentId);
+ const unchanged =
+ entry !== undefined &&
+ connectionRoutes(entry).some(
+ (route) =>
+ Equal.equals(route.target, registration.target) &&
+ Option.isSome(route.profile) &&
+ Equal.equals(route.profile.value, registration.profile),
+ );
+ if (!unchanged) yield* registry.register(registration);
+ const current = (yield* SubscriptionRef.get(registry.entries)).get(machine.environmentId);
+ if (
+ current !== undefined &&
+ connectionRoutes(current).every((route) => connectionRouteId(route.target) === connectionId)
+ ) {
+ yield* registry.setCompatibility(
+ machine.environmentId,
+ orchestrationProtocolCompatibilityError(machine.descriptor),
+ );
+ }
+ }
+ yield* removeRoutes(profile.environmentId, keep);
+ });
+
+ const reportError = (error: { readonly message: string }) =>
+ SubscriptionRef.update(state, (current) => ({ ...current, error: error.message }));
+ const finish = SubscriptionRef.update(state, (current) => ({ ...current, refreshing: false }));
+ const mapPersistence = (error: { readonly message: string }) =>
+ new ConnectionBlockedError({ reason: "configuration", detail: error.message });
+
+ const refresh = lock.withPermits(1)(
+ Effect.gen(function* () {
+ const current = yield* SubscriptionRef.get(state);
+ if (current.connection === null) return;
+ yield* SubscriptionRef.update(state, (value) => ({
+ ...value,
+ refreshing: true,
+ error: null,
+ }));
+ const credential = yield* currentCredential(current.connection);
+ const directory = yield* requestDirectory(current.connection, credential);
+ yield* reconcile(current.connection, directory).pipe(Effect.mapError(mapPersistence));
+ const inbox = yield* requestInbox(current.connection, credential);
+ yield* SubscriptionRef.update(state, (value) => ({ ...value, directory, inbox }));
+ }).pipe(Effect.tapError(reportError), Effect.ensuring(finish)),
+ );
+
+ const connect = (input: PairingConnectionInput) =>
+ lock.withPermits(1)(
+ Effect.gen(function* () {
+ yield* SubscriptionRef.update(state, (value) => ({
+ ...value,
+ refreshing: true,
+ error: null,
+ }));
+ const registration = yield* preparePairingRegistration(input).pipe(
+ Effect.provideService(ClientCapabilities.ClientPresentation, presentation),
+ Effect.provideService(HttpClient.HttpClient, http),
+ Effect.mapError((error) =>
+ error._tag === "ConnectionBlockedError" && error.reason === "authentication"
+ ? new ConnectionBlockedError({
+ reason: "authentication",
+ detail:
+ "The pairing code is invalid or expired. Create a new code on the server.",
+ })
+ : error,
+ ),
+ );
+ const profile = registration.profile;
+ const credential = registration.credential;
+ if (credential._tag !== "BearerConnectionCredential")
+ return yield* new ConnectionBlockedError({
+ reason: "configuration",
+ detail: "The pairing credential is invalid.",
+ });
+ const directory = yield* requestDirectory(profile, credential);
+ inboxCache = undefined;
+ const inbox = yield* requestInbox(profile, credential);
+ const previous = (yield* SubscriptionRef.get(state)).connection;
+ yield* credentials.put(hubCredentialId(profile.environmentId), credential);
+ yield* reconcile(profile, directory).pipe(Effect.mapError(mapPersistence));
+ yield* profiles.put(
+ new BearerConnectionProfile({ ...profile, connectionId: HUB_PROFILE_ID }),
+ );
+ yield* SubscriptionRef.set(state, {
+ connection: profile,
+ directory,
+ inbox,
+ refreshing: false,
+ error: null,
+ });
+ for (const machine of directory.environments) {
+ yield* registry.retryNow(machine.environmentId);
+ }
+ if (previous !== null && previous.environmentId !== profile.environmentId) {
+ yield* removeRoutes(previous.environmentId).pipe(Effect.mapError(mapPersistence));
+ yield* credentials.remove(hubCredentialId(previous.environmentId));
+ }
+ }).pipe(Effect.tapError(reportError), Effect.ensuring(finish)),
+ );
+
+ const connectAuthenticated = (input: { readonly httpBaseUrl: string }) =>
+ lock.withPermits(1)(
+ Effect.gen(function* () {
+ const descriptor = yield* fetchRemoteEnvironmentDescriptor(input).pipe(
+ Effect.provideService(HttpClient.HttpClient, http),
+ Effect.mapError(mapPersistence),
+ );
+ const credential = new HostedConnectionCredential({ serverUrl: input.httpBaseUrl });
+ const profile = new BearerConnectionProfile({
+ connectionId: HUB_PROFILE_ID,
+ authorization: "hosted",
+ environmentId: descriptor.environmentId,
+ label: descriptor.label,
+ httpBaseUrl: input.httpBaseUrl,
+ wsBaseUrl: deriveWsBaseUrl(input.httpBaseUrl),
+ });
+ const directory = yield* requestDirectory(profile, credential);
+ inboxCache = undefined;
+ const inbox = yield* requestInbox(profile, credential);
+ const previous = (yield* SubscriptionRef.get(state)).connection;
+ const credentialId = hubCredentialId(profile.environmentId);
+ const previousCredential = yield* credentials.get(credentialId);
+ let credentialWritten = false;
+ yield* Effect.gen(function* () {
+ yield* credentials.put(credentialId, credential);
+ credentialWritten = true;
+ yield* reconcile(profile, directory).pipe(Effect.mapError(mapPersistence));
+ yield* profiles.put(profile);
+ }).pipe(
+ Effect.tapError(() =>
+ !credentialWritten
+ ? Effect.void
+ : Option.isSome(previousCredential)
+ ? credentials.put(credentialId, previousCredential.value)
+ : credentials.remove(credentialId),
+ ),
+ );
+
+ yield* SubscriptionRef.set(state, {
+ connection: profile,
+ directory,
+ inbox,
+ refreshing: false,
+ error: null,
+ });
+ if (previous !== null && previous.environmentId !== profile.environmentId) {
+ yield* removeRoutes(previous.environmentId).pipe(Effect.mapError(mapPersistence));
+ yield* credentials.remove(hubCredentialId(previous.environmentId));
+ }
+ }).pipe(Effect.tapError(reportError), Effect.ensuring(finish)),
+ );
+
+ const disconnect = lock.withPermits(1)(
+ Effect.gen(function* () {
+ const current = (yield* SubscriptionRef.get(state)).connection;
+ if (current === null) return;
+ yield* removeRoutes(current.environmentId).pipe(Effect.mapError(mapPersistence));
+ yield* profiles.remove(HUB_PROFILE_ID);
+ yield* credentials.remove(hubCredentialId(current.environmentId));
+ inboxCache = undefined;
+ yield* SubscriptionRef.set(state, EMPTY_HUB_CONNECTION_STATE);
+ }).pipe(Effect.tapError(reportError)),
+ );
+
+ const refreshInbox = lock.withPermit(
+ Effect.gen(function* () {
+ const current = yield* SubscriptionRef.get(state);
+ if (current.connection === null) return;
+ const credential = yield* currentCredential(current.connection);
+ const inbox = yield* requestInbox(current.connection, credential);
+ yield* SubscriptionRef.update(state, (value) => ({ ...value, inbox, error: null }));
+ }).pipe(Effect.tapError(reportError)),
+ );
+
+ const organize = (input: HubInboxCommand) =>
+ lock.withPermit(
+ Effect.gen(function* () {
+ const current = yield* SubscriptionRef.get(state);
+ if (
+ current.connection === null ||
+ !current.directory?.environments.some(
+ (entry) => entry.environmentId === input.environmentId,
+ )
+ )
+ return Option.none();
+ const credential = yield* currentCredential(current.connection);
+ const encoded = yield* encodeInboxCommand(input);
+ const response = yield* http.post(
+ environmentEndpointUrl(current.connection.httpBaseUrl, "/api/hub/inbox"),
+ {
+ headers: yield* headersFor(credential),
+ body: HttpBody.jsonUnsafe(encoded),
+ },
+ );
+ if (response.status === 401 || response.status === 403) {
+ return yield* new ConnectionBlockedError({
+ reason: "authentication",
+ detail:
+ "This server connection cannot change chats. Pair again with access to change threads.",
+ });
+ }
+ if (response.status !== 200) {
+ return yield* new ConnectionTransientError({
+ reason: "remote-unavailable",
+ detail: "Couldn't save this chat change on the server. Try again.",
+ });
+ }
+ const entry = yield* response.json.pipe(Effect.flatMap(decodeInboxEnvironment));
+ if (entry.environmentId !== input.environmentId) {
+ return yield* new ConnectionBlockedError({
+ reason: "configuration",
+ detail: "The server returned a different machine. Refresh and try again.",
+ });
+ }
+ inboxCache = undefined;
+ yield* SubscriptionRef.update(state, (value) => ({
+ ...value,
+ inbox: {
+ environments: [
+ ...value.inbox.environments.filter(
+ (item) => item.environmentId !== entry.environmentId,
+ ),
+ entry,
+ ],
+ },
+ error: null,
+ }));
+ return Option.some({ sequence: entry.snapshot.snapshotSequence });
+ }).pipe(
+ Effect.timeout("15 seconds"),
+ Effect.mapError((error) =>
+ isConnectionAttemptError(error)
+ ? error
+ : new ConnectionTransientError({
+ reason: "remote-unavailable",
+ detail: "Couldn't save this chat change on the server. Try again.",
+ }),
+ ),
+ ),
+ );
+
+ const headers = Effect.gen(function* () {
+ const connection = (yield* SubscriptionRef.get(state)).connection;
+ if (connection === null)
+ return yield* new ConnectionBlockedError({
+ reason: "configuration",
+ detail: "Connect your server to open archived chats.",
+ });
+ return yield* headersFor(yield* currentCredential(connection));
+ });
+
+ return HubConnection.of({
+ state,
+ connect,
+ connectAuthenticated,
+ headers,
+ refresh,
+ refreshInbox,
+ organize,
+ disconnect,
+ });
+});
+
+export const layer = Layer.effect(HubConnection, make);
diff --git a/packages/client-runtime/src/connection/index.ts b/packages/client-runtime/src/connection/index.ts
index 90881817c6420eebc0643bbdbfc4f1e373abe201..73ad1d242c6e73968a39271e44fddc932112f47d 100644
--- a/packages/client-runtime/src/connection/index.ts
+++ b/packages/client-runtime/src/connection/index.ts
@@ -1,4 +1,5 @@
export * from "./catalog.ts";
+export { HubConnection } from "./hub.ts";
export * as Connectivity from "./connectivity.ts";
export * as CredentialStore from "./credentialStore.ts";
export { type ConnectionDriverProgress, type EnvironmentConnectionLease } from "./driver.ts";
diff --git a/packages/client-runtime/src/connection/layer.ts b/packages/client-runtime/src/connection/layer.ts
index 7c6d1d6e9a498d68bf604c23cd406c27bedd42b8..50fd51240be99e6e92b649d169951dbfe56615a8 100644
--- a/packages/client-runtime/src/connection/layer.ts
+++ b/packages/client-runtime/src/connection/layer.ts
@@ -15,6 +15,8 @@ import * as PlatformConnectionSource from "../platform/source.ts";
import * as RelayEnvironmentDiscovery from "../relay/discovery.ts";
import * as RemoteEnvironmentAuthorization from "../authorization/service.ts";
import * as RpcSession from "../rpc/session.ts";
+import * as Hub from "./hub.ts";
+import * as Wakeups from "./wakeups.ts";
export const watchDiscoveredCompatibility = Effect.fn("connection.watchDiscoveredCompatibility")(
function* () {
@@ -78,10 +80,12 @@ export function layerWithOptions(options: RpcSession.RpcSessionOptions) {
);
const layerRegistry = EnvironmentRegistry.layer.pipe(Layer.provide(layerDriver));
const layerOnboarding = ConnectionOnboarding.layer.pipe(Layer.provide(layerRegistry));
+ const layerHub = Hub.layer.pipe(Layer.provide(layerRegistry));
const layerConnectionServices = Layer.mergeAll(
layerRegistry,
RelayEnvironmentDiscovery.layer,
layerOnboarding,
+ layerHub,
// Exposed for updating hosts too old to connect through the driver.
ConnectionResolver.layer,
);
@@ -89,8 +93,19 @@ export function layerWithOptions(options: RpcSession.RpcSessionOptions) {
Effect.gen(function* () {
const registry = yield* EnvironmentRegistry.EnvironmentRegistry;
const platformSource = yield* PlatformConnectionSource.PlatformConnectionSource;
+ const hub = yield* Hub.HubConnection;
+ const wakeups = yield* Wakeups.ConnectionWakeups;
yield* watchDiscoveredCompatibility().pipe(Effect.forkScoped);
yield* registry.start;
+ yield* Stream.merge(Stream.tick("1 minute"), wakeups.changes).pipe(
+ Stream.runForEach(() => hub.refresh.pipe(Effect.ignore)),
+ Effect.forkScoped,
+ );
+ yield* hub.refresh.pipe(Effect.ignore, Effect.forkScoped);
+ yield* Stream.tick("15 seconds").pipe(
+ Stream.runForEach(() => hub.refreshInbox.pipe(Effect.ignore)),
+ Effect.forkScoped,
+ );
yield* platformSource.registrations.pipe(
Stream.runForEach(registry.reconcilePlatform),
Effect.forkScoped,
diff --git a/packages/client-runtime/src/connection/onboarding.test.ts b/packages/client-runtime/src/connection/onboarding.test.ts
index e53420659c0535e639fbf1a94e872f6e35c327d9..8babb9e61ed6bbaac779558bab9e916e985e9c6b 100644
--- a/packages/client-runtime/src/connection/onboarding.test.ts
+++ b/packages/client-runtime/src/connection/onboarding.test.ts
@@ -271,6 +271,35 @@ describe("connection onboarding", () => {
}),
);
+ it.effect("keeps routes for different prefixes on the same hub distinct", () =>
+ Effect.gen(function* () {
+ const calls: Array<{ readonly url: string; readonly init: RequestInit }> = [];
+ const registrations = [];
+ for (const name of ["one", "two"]) {
+ registrations.push(
+ yield* preparePairingRegistration({
+ host: `https://hub.example/prefix/environments/${name}/`,
+ pairingCode: "pairing-token",
+ }).pipe(Effect.provide(Layer.mergeAll(layerClientPresentation, layerPairingHttp(calls)))),
+ );
+ }
+ expect(registrations.map((registration) => registration.target.connectionId)).toEqual([
+ "bearer:environment-paired:https://hub.example/prefix/environments/one",
+ "bearer:environment-paired:https://hub.example/prefix/environments/two",
+ ]);
+ expect(registrations[0]?.profile).toMatchObject({
+ httpBaseUrl: "https://hub.example/prefix/environments/one/",
+ wsBaseUrl: "wss://hub.example/prefix/environments/one/",
+ });
+ expect(calls.map((call) => call.url)).toEqual([
+ "https://hub.example/prefix/environments/one/.well-known/t3/environment",
+ "https://hub.example/prefix/environments/one/oauth/token",
+ "https://hub.example/prefix/environments/two/.well-known/t3/environment",
+ "https://hub.example/prefix/environments/two/oauth/token",
+ ]);
+ }),
+ );
+
it.effect("turns a switched-off environment back on when it is paired again", () =>
Effect.gen(function* () {
const registry = yield* EnvironmentRegistry.EnvironmentRegistry;
@@ -434,6 +463,9 @@ describe("connection onboarding", () => {
pairingCode: "pairing-token",
}).pipe(Effect.provide(Layer.mergeAll(layerClientPresentation, httpLayer)));
+ if (registration.credential._tag !== "BearerConnectionCredential") {
+ throw new Error("Pairing must issue a bearer credential.");
+ }
const session = yield* fetchRemoteSessionState({
httpBaseUrl: registration.profile.httpBaseUrl,
bearerToken: registration.credential.token,
@@ -589,8 +621,8 @@ describe("connection onboarding", () => {
profile: {
environmentId,
label: "Renamed environment",
- httpBaseUrl: "http://100.65.180.100:3773/",
- wsBaseUrl: "ws://100.65.180.100:3773/",
+ httpBaseUrl: "http://100.65.180.100:3773/path/",
+ wsBaseUrl: "ws://100.65.180.100:3773/path/",
},
credential: { token: "bearer-token" },
});
diff --git a/packages/client-runtime/src/connection/onboarding.ts b/packages/client-runtime/src/connection/onboarding.ts
index a6ac9aa556e3ca03523db64ccd0c2c057d994556..dd22e191e466bd80f092de54c629783244312d55 100644
--- a/packages/client-runtime/src/connection/onboarding.ts
+++ b/packages/client-runtime/src/connection/onboarding.ts
@@ -98,7 +98,8 @@ const resolvePairingTarget = Effect.fn("clientRuntime.connection.onboarding.reso
* reuses the id and replaces that route.
*/
function bearerConnectionId(environmentId: EnvironmentId, httpBaseUrl: string): string {
- return `bearer:${environmentId}:${new URL(httpBaseUrl).origin}`;
+ const url = new URL(normalizeHttpBaseUrl(httpBaseUrl));
+ return `bearer:${environmentId}:${url.origin}${url.pathname.replace(/\/+$/, "")}`;
}
function differentMachineError(label: string) {
diff --git a/packages/client-runtime/src/connection/presentation.test.ts b/packages/client-runtime/src/connection/presentation.test.ts
index 61d6278a5305158a80cc8ff97aadbe69ff991f38..107a65b55ffb9f918376a8084a35d19cdea7d37e 100644
--- a/packages/client-runtime/src/connection/presentation.test.ts
+++ b/packages/client-runtime/src/connection/presentation.test.ts
@@ -135,6 +135,22 @@ describe("connection presentation", () => {
).toBe("https://tunnel.example.test/mcp");
});
+ it("keeps the selected environment prefix in MCP addresses", () => {
+ const entry = {
+ ...ENTRY,
+ profile: Option.some(
+ new BearerConnectionProfile({
+ connectionId: TARGET.connectionId,
+ environmentId: TARGET.environmentId,
+ label: TARGET.label,
+ httpBaseUrl: "https://hub.example/prefix/environments/one/",
+ wsBaseUrl: "wss://hub.example/prefix/environments/one/",
+ }),
+ ),
+ };
+ expect(environmentMcpUrl({ entry })).toBe("https://hub.example/prefix/environments/one/mcp");
+ });
+
it("distinguishes initial connection, reconnect, and retry errors", () => {
expect(presentConnectionState(supervisorState({ phase: "connecting", attempt: 1 }))).toEqual({
phase: "connecting",
diff --git a/packages/client-runtime/src/connection/presentation.ts b/packages/client-runtime/src/connection/presentation.ts
index 9015a755133e5bc4d9740596d5ce81e76e165556..6dff002b887f56e3a6366b0d87b48f91e09c1198 100644
--- a/packages/client-runtime/src/connection/presentation.ts
+++ b/packages/client-runtime/src/connection/presentation.ts
@@ -1,6 +1,7 @@
import type { ServerConfig } from "@t3tools/contracts";
import * as Option from "effect/Option";
+import { environmentEndpointUrl } from "../environment/endpoint.ts";
import type { ConnectionCatalogEntry } from "./catalog.ts";
import type { ConnectionTarget, SupervisorConnectionState } from "./model.ts";
import { connectionRouteId, connectionRoutes, routeHttpBaseUrl } from "./routes.ts";
@@ -122,16 +123,11 @@ export function environmentMcpUrl(input: {
}
function mcpUrlFromBase(httpBaseUrl: string): string | null {
- let url: URL;
try {
- url = new URL(httpBaseUrl);
+ return environmentEndpointUrl(httpBaseUrl, "/mcp");
} catch {
return null;
}
- url.pathname = "/mcp";
- url.search = "";
- url.hash = "";
- return url.toString();
}
export function connectionCatalogDisplayUrl(entry: ConnectionCatalogEntry): string | null {
diff --git a/packages/client-runtime/src/connection/registry.test.ts b/packages/client-runtime/src/connection/registry.test.ts
index 18d1f062f04394668f61a0dc9ba719ac08aba154..c790197eba0465b1ff381ef1bcd223ff6db574b3 100644
--- a/packages/client-runtime/src/connection/registry.test.ts
+++ b/packages/client-runtime/src/connection/registry.test.ts
@@ -393,9 +393,10 @@ const makeHarness = Effect.fn("TestEnvironmentRegistry.makeHarness")(function* (
...PREPARED,
environmentId: target.environmentId,
label: target.label,
- httpAuthorization: Option.isSome(credential)
- ? { _tag: "Bearer", token: credential.value.token }
- : null,
+ httpAuthorization:
+ Option.isSome(credential) && credential.value._tag === "BearerConnectionCredential"
+ ? { _tag: "Bearer", token: credential.value.token }
+ : null,
target,
};
if (options?.prepareError) return yield* options.prepareError;
@@ -1375,11 +1376,13 @@ describe("EnvironmentRegistry", () => {
target._tag !== "BearerConnectionTarget" || storedCredentials === undefined
? Effect.succeed(undefined)
: Ref.get(storedCredentials).pipe(
- Effect.map((current) =>
- current.get(target.connectionId)?.token === "old-bearer"
+ Effect.map((current) => {
+ const credential = current.get(target.connectionId);
+ return credential?._tag === "BearerConnectionCredential" &&
+ credential.token === "old-bearer"
? new ConnectionBlockedError({ reason: "authentication", detail: "revoked" })
- : undefined,
- ),
+ : undefined;
+ }),
),
});
storedCredentials = harness.storedCredentials;
diff --git a/packages/client-runtime/src/connection/registry.ts b/packages/client-runtime/src/connection/registry.ts
index 501a19bb0b9e0be7533bcdfc0746a5f41b1b6ef9..a30e53075b3e633b777fff4534b75c3c87659583 100644
--- a/packages/client-runtime/src/connection/registry.ts
+++ b/packages/client-runtime/src/connection/registry.ts
@@ -126,6 +126,7 @@ export class EnvironmentRegistry extends Context.Service<
readonly removeRoute: (
environmentId: EnvironmentId,
routeId: string,
+ options?: { readonly retainDrafts?: boolean },
) => Effect.Effect<
void,
| Persistence.ConnectionPersistenceError
@@ -728,7 +729,15 @@ export const make = Effect.gen(function* () {
state.lastFailure.reason === "authentication")
) {
yield* scope.supervisor.retryNow;
- } else if (state.phase === "connected") {
+ } else if (
+ state.phase === "connected" &&
+ registration.credential._tag === "HubConnectionCredential"
+ ) {
+ yield* scope.supervisor.retryNow;
+ } else if (
+ state.phase === "connected" &&
+ registration.credential._tag === "BearerConnectionCredential"
+ ) {
const token = registration.credential.token;
yield* SubscriptionRef.updateSome(scope.supervisor.prepared, (current) =>
Option.isSome(current) &&
@@ -844,6 +853,7 @@ export const make = Effect.gen(function* () {
/** Forgets a user-saved environment. Callers hold its lease lock. */
const removeLocked = Effect.fn("EnvironmentRegistry.removeLocked")(function* (
environmentId: EnvironmentId,
+ options?: { readonly retainDrafts?: boolean },
) {
if ((yield* Ref.get(platformEnvironmentIds)).has(environmentId)) {
return yield* new PlatformEnvironmentRemovalError({
@@ -875,7 +885,7 @@ export const make = Effect.gen(function* () {
}),
),
),
- ownedDataCleanup.clear(environmentId),
+ ownedDataCleanup.clear(environmentId, options),
],
{ concurrency: "unbounded", discard: true },
);
@@ -918,6 +928,10 @@ export const make = Effect.gen(function* () {
readonly activeRoute: ConnectionRoute;
readonly reported: ReadonlyArray;
}) {
+ if (connectionRouteId(input.activeRoute.target).startsWith("hub:")) {
+ return Option.none();
+ }
+
return yield* withLeaseLock(
input.environmentId,
Effect.gen(function* () {
@@ -980,6 +994,7 @@ export const make = Effect.gen(function* () {
const removeRoute = Effect.fn("EnvironmentRegistry.removeRoute")(function* (
environmentId: EnvironmentId,
routeId: string,
+ options?: { readonly retainDrafts?: boolean },
) {
// One lock for the whole decision: a route registered between "this was
// the last route" and the removal must not be deleted with it.
@@ -991,7 +1006,7 @@ export const make = Effect.gen(function* () {
const route = routes.find((candidate) => connectionRouteId(candidate.target) === routeId);
if (route === undefined) return;
const remaining = routesAfterRemoving(routes, routeId);
- if (remaining.length === 0) return yield* removeLocked(environmentId);
+ if (remaining.length === 0) return yield* removeLocked(environmentId, options);
yield* replaceRoutesLocked(entry, remaining);
const profile = Option.getOrNull(route.profile);
if (profile !== null && isSshConnectionProfile(profile)) {
diff --git a/packages/client-runtime/src/connection/resolver.test.ts b/packages/client-runtime/src/connection/resolver.test.ts
index 0d40db307c1f0a5bae88b0e91d91ceb4da70468e..5b73043bcaa02438d7ef095f9d4b3167bf38d88f 100644
--- a/packages/client-runtime/src/connection/resolver.test.ts
+++ b/packages/client-runtime/src/connection/resolver.test.ts
@@ -17,6 +17,7 @@ import * as RemoteEnvironmentAuthorization from "../authorization/service.ts";
import {
BearerConnectionCredential,
BearerConnectionProfile,
+ HubConnectionCredential,
type ConnectionCatalogEntry,
SshConnectionProfile,
type ConnectionCredential,
@@ -186,6 +187,35 @@ const makeDependencies = Effect.fn("TestConnectionResolver.makeDependencies")((o
});
describe("ConnectionResolver", () => {
+ it.effect("resolves hub routes through one privately stored credential", () =>
+ Effect.gen(function* () {
+ const connectionId = "hub:hub:environment-1";
+ const profile = new BearerConnectionProfile({
+ connectionId,
+ environmentId: ENVIRONMENT_ID,
+ label: "Machine",
+ httpBaseUrl: "https://hub.example/environments/environment-1/",
+ wsBaseUrl: "wss://hub.example/environments/environment-1/",
+ });
+ const dependencies = yield* makeDependencies({
+ credentials: [
+ [connectionId, new HubConnectionCredential({ connectionId: "hub-session:hub" })],
+ ["hub-session:hub", new BearerConnectionCredential({ token: "single-hub-secret" })],
+ ],
+ });
+ const resolver = yield* ConnectionResolver.ConnectionResolver.pipe(
+ Effect.provide(dependencies),
+ );
+ const target = new BearerConnectionTarget({
+ connectionId,
+ environmentId: ENVIRONMENT_ID,
+ label: "Machine",
+ });
+ const prepared = yield* resolver.prepare(catalogEntry(target, Option.some(profile)));
+ expect(prepared.httpAuthorization).toEqual({ _tag: "Bearer", token: "single-hub-secret" });
+ expect(prepared.httpBaseUrl).toBe(profile.httpBaseUrl);
+ }),
+ );
it.effect("blocks an old host during discovery before opening orchestration RPC", () =>
Effect.gen(function* () {
const layerBroker = yield* makeDependencies({ descriptorProtocolVersion: null });
diff --git a/packages/client-runtime/src/connection/resolver.ts b/packages/client-runtime/src/connection/resolver.ts
index a70a7531a79955500a6f289c4bbb06fd4ce9b4a4..02b4042b4a075331df783aa238b331e5b10ea283 100644
--- a/packages/client-runtime/src/connection/resolver.ts
+++ b/packages/client-runtime/src/connection/resolver.ts
@@ -45,6 +45,7 @@ import {
orchestrationProtocolCompatibilityError,
} from "./compatibility.ts";
import { credentialConnectionId } from "./routes.ts";
+import { deriveWsBaseUrl, environmentWebSocketUrl } from "../environment/endpoint.ts";
import { fetchRemoteEnvironmentDescriptor } from "../environment/descriptor.ts";
export class ConnectionResolver extends Context.Service<
@@ -72,13 +73,10 @@ const isSshProfile = Schema.is(SshConnectionProfile);
const isBearerCredential = Schema.is(BearerConnectionCredential);
function primarySocketUrl(
- target: PrimaryConnectionTarget,
+ target: { readonly wsBaseUrl: string },
clientMetadata: AuthClientPresentationMetadata | undefined,
): string {
- const url = new URL(target.wsBaseUrl);
- if (url.pathname === "" || url.pathname === "/") {
- url.pathname = "/ws";
- }
+ const url = new URL(environmentWebSocketUrl(target.wsBaseUrl));
appendClientConnectionParams(url, clientMetadata, "direct");
return url.toString();
}
@@ -120,6 +118,7 @@ const makePrimaryBroker = Effect.fn("clientRuntime.connection.broker.makePrimary
const makeBearerBroker = Effect.fn("clientRuntime.connection.broker.makeBearer")(function* () {
const credentials = yield* ConnectionCredentialStore.ConnectionCredentialStore;
const remote = yield* RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization;
+ const presentation = yield* ClientCapabilities.ClientPresentation;
return Effect.fn("clientRuntime.connection.broker.bearer")(function* (
entry: ConnectionCatalogEntry & { readonly target: BearerConnectionTarget },
@@ -148,15 +147,60 @@ const makeBearerBroker = Effect.fn("clientRuntime.connection.broker.makeBearer")
return { ...authorized, target } satisfies PreparedConnection;
}
// A learned route borrows the credential of the route it was learned from.
- const credential = yield* credentials.get(credentialConnectionId(target.connectionId)).pipe(
- Effect.flatMap(
- Option.match({
- onNone: () => Effect.fail(credentialMissingError(target.connectionId)),
- onSome: Effect.succeed,
- }),
- ),
- );
- if (!isBearerCredential(credential)) {
+ const savedCredential = yield* credentials
+ .get(credentialConnectionId(target.connectionId))
+ .pipe(
+ Effect.flatMap(
+ Option.match({
+ onNone: () => Effect.fail(credentialMissingError(target.connectionId)),
+ onSome: Effect.succeed,
+ }),
+ ),
+ );
+ const credential =
+ savedCredential._tag === "HubConnectionCredential"
+ ? Option.getOrNull(yield* credentials.get(savedCredential.connectionId))
+ : savedCredential;
+ if (credential?._tag === "HostedConnectionCredential") {
+ const service = yield* Effect.serviceOption(ClientCapabilities.HostedAuthAccess);
+ if (Option.isNone(service)) return yield* credentialMissingError(target.connectionId);
+ const destination = new URL(profile.httpBaseUrl);
+ const server = new URL(credential.serverUrl);
+ const prefix = server.pathname.replace(/\/+$/, "");
+ if (
+ destination.origin !== server.origin ||
+ (destination.pathname !== prefix && !destination.pathname.startsWith(`${prefix}/`)) ||
+ profile.wsBaseUrl !== deriveWsBaseUrl(profile.httpBaseUrl)
+ ) {
+ return yield* new ConnectionBlockedError({
+ reason: "configuration",
+ detail: "This connection belongs to a different server. Sign in again.",
+ });
+ }
+ const headers = yield* service.value.headers(credential.serverUrl);
+ const bearer = headers.authorization?.replace(/^Bearer /, "");
+ if (bearer) {
+ return {
+ ...(yield* remote.authorizeBearer({
+ expectedEnvironmentId: target.environmentId,
+ httpBaseUrl: profile.httpBaseUrl,
+ wsBaseUrl: profile.wsBaseUrl,
+ bearerToken: bearer,
+ connectionMethod: "direct",
+ })),
+ target,
+ } satisfies PreparedConnection;
+ }
+ return {
+ environmentId: target.environmentId,
+ label: profile.label,
+ httpBaseUrl: profile.httpBaseUrl,
+ socketUrl: primarySocketUrl(profile, presentation.metadata),
+ httpAuthorization: null,
+ target,
+ } satisfies PreparedConnection;
+ }
+ if (credential === null || !isBearerCredential(credential)) {
return yield* credentialMissingError(target.connectionId);
}
const authorized = yield* remote.authorizeBearer({
diff --git a/packages/client-runtime/src/connection/routes.test.ts b/packages/client-runtime/src/connection/routes.test.ts
index 3079294305c74b2c637ba704620070381d944a29..f9897a015e0c142db1d8894665f65a920a1e1bb8 100644
--- a/packages/client-runtime/src/connection/routes.test.ts
+++ b/packages/client-runtime/src/connection/routes.test.ts
@@ -104,6 +104,19 @@ describe("learned routes", () => {
});
});
+ it("retains distinct learned environment bases on the same origin", () => {
+ const reported = [
+ { httpBaseUrl: "https://hub.example/prefix/environments/one/" },
+ { httpBaseUrl: "https://hub.example/prefix/environments/two/" },
+ ];
+ const routes = mergeLearnedRoutes({ entry: relayOnly, activeRoute: RELAY, reported, allowInsecure: false })!;
+ expect(routes.slice(0, 2).map((route) => profileOf(route))).toMatchObject([
+ { httpBaseUrl: reported[0]!.httpBaseUrl, wsBaseUrl: "wss://hub.example/prefix/environments/one/" },
+ { httpBaseUrl: reported[1]!.httpBaseUrl, wsBaseUrl: "wss://hub.example/prefix/environments/two/" },
+ ]);
+ expect(mergeLearnedRoutes({ entry: entryWithRoutes(relayOnly, routes), activeRoute: RELAY, reported, allowInsecure: false })).toBeNull();
+ });
+
it("replaces a learned LAN address when the server reports a new one", () => {
const first = mergeLearnedRoutes({
entry: relayOnly,
diff --git a/packages/client-runtime/src/connection/routes.ts b/packages/client-runtime/src/connection/routes.ts
index 4691aa9389d8cb40c775a51de5e0c5821993ea6a..3a25da2bc41fecfa2c0407bddb83f59ddafab827 100644
--- a/packages/client-runtime/src/connection/routes.ts
+++ b/packages/client-runtime/src/connection/routes.ts
@@ -4,6 +4,7 @@ import {
isSharedAddressSpaceHost,
isTailnetHost,
} from "@t3tools/shared/hostClassification";
+import { deriveWsBaseUrl, normalizeHttpBaseUrl } from "../environment/endpoint.ts";
import type { DesktopSshEnvironmentTarget } from "@t3tools/contracts";
import * as Option from "effect/Option";
@@ -261,13 +262,13 @@ export function mergeLearnedRoutes(input: {
? credentialConnectionId(active.connectionId)
: undefined;
- // Usable reported addresses, by origin.
+ // Usable reported environment bases.
const reported = new Map();
const tailscaleOrigins = new Set();
for (const endpoint of input.reported) {
let url: URL;
try {
- url = new URL(endpoint.httpBaseUrl);
+ url = new URL(normalizeHttpBaseUrl(endpoint.httpBaseUrl));
} catch {
continue;
}
@@ -275,8 +276,9 @@ export function mergeLearnedRoutes(input: {
if (url.protocol === "http:" && !input.allowInsecure) continue;
// A loopback address names whichever device opens it, never the server.
if (isLocalLoopbackHost(url.hostname)) continue;
- reported.set(url.origin, url);
- if (endpoint.kind === "tailnet") tailscaleOrigins.add(url.origin);
+ const address = url.href.replace(/\/+$/, "");
+ reported.set(address, url);
+ if (endpoint.kind === "tailnet") tailscaleOrigins.add(address);
}
const normalized = (url: string) => url.replace(/\/+$/, "");
const known = new Set(
@@ -300,11 +302,12 @@ export function mergeLearnedRoutes(input: {
});
let next: ReadonlyArray = kept;
for (const url of reported.values()) {
- if (known.has(url.origin)) continue;
- const httpBaseUrl = `${url.origin}/`;
+ const httpBaseUrl = url.href;
+ const address = httpBaseUrl.replace(/\/+$/, "");
+ if (known.has(address)) continue;
const connectionId = learnedConnectionId(
entry.target.environmentId,
- url.origin,
+ address,
sharedCredential,
);
next = insertRoute(next, {
@@ -319,10 +322,10 @@ export function mergeLearnedRoutes(input: {
environmentId: entry.target.environmentId,
label: entry.target.label,
httpBaseUrl,
- wsBaseUrl: `${url.protocol === "https:" ? "wss:" : "ws:"}//${url.host}/`,
+ wsBaseUrl: deriveWsBaseUrl(httpBaseUrl),
learned: true,
...(authorization === undefined ? {} : { authorization }),
- ...(tailscaleOrigins.has(url.origin) ? { network: "tailscale" as const } : {}),
+ ...(tailscaleOrigins.has(address) ? { network: "tailscale" as const } : {}),
}),
),
});
diff --git a/packages/client-runtime/src/environment/endpoint.test.ts b/packages/client-runtime/src/environment/endpoint.test.ts
index d26201dc4f72c688b9eeb8e28dc6d1d1836da943..3d26b7cdee01fbc94100549476f1b14bc52e8167 100644
--- a/packages/client-runtime/src/environment/endpoint.test.ts
+++ b/packages/client-runtime/src/environment/endpoint.test.ts
@@ -4,6 +4,8 @@ import {
classifyHostedHttpsCompatibility,
createAdvertisedEndpoint,
deriveWsBaseUrl,
+ environmentEndpointUrl,
+ environmentWebSocketUrl,
normalizeHttpBaseUrl,
} from "./endpoint.ts";
@@ -16,12 +18,33 @@ const coreProvider = {
describe("advertised endpoint helpers", () => {
it("normalizes HTTP and WebSocket base URLs", () => {
- expect(normalizeHttpBaseUrl("https://example.com/path?x=1#hash")).toBe("https://example.com/");
- expect(normalizeHttpBaseUrl("wss://example.com/socket")).toBe("https://example.com/");
- expect(deriveWsBaseUrl("https://example.com/api")).toBe("wss://example.com/");
+ expect(normalizeHttpBaseUrl("https://example.com/path?x=1#hash")).toBe(
+ "https://example.com/path/",
+ );
+ expect(normalizeHttpBaseUrl("wss://example.com/socket")).toBe("https://example.com/socket/");
+ expect(deriveWsBaseUrl("https://example.com/api")).toBe("wss://example.com/api/");
expect(deriveWsBaseUrl("http://127.0.0.1:3773")).toBe("ws://127.0.0.1:3773/");
});
+ it.each(["https", "http", "wss", "ws"])("preserves the base path for %s routes", (scheme) => {
+ const base = `${scheme}://hub.example/prefix/environments/one///?old=1#fragment`;
+ expect(environmentEndpointUrl(base, "/api/assets/a%2Fb?signature=abc#preview")).toBe(
+ `${scheme}://hub.example/prefix/environments/one/api/assets/a%2Fb?signature=abc#preview`,
+ );
+ expect(environmentEndpointUrl(base, ".well-known/t3/environment")).toBe(
+ `${scheme}://hub.example/prefix/environments/one/.well-known/t3/environment`,
+ );
+ });
+
+ it("distinguishes a canonical WebSocket base from an existing socket endpoint", () => {
+ expect(environmentWebSocketUrl("wss://hub.example/prefix/environments/one/")).toBe(
+ "wss://hub.example/prefix/environments/one/ws",
+ );
+ expect(environmentWebSocketUrl("wss://remote.example/ws?transport=1")).toBe(
+ "wss://remote.example/ws?transport=1",
+ );
+ });
+
it("marks HTTP endpoints as blocked from hosted HTTPS apps", () => {
expect(classifyHostedHttpsCompatibility("http://192.168.1.44:3773")).toBe(
"mixed-content-blocked",
diff --git a/packages/client-runtime/src/environment/endpoint.ts b/packages/client-runtime/src/environment/endpoint.ts
index 4178259361e79940e729750415db285a18dee6d1..da7d766fa8066e9a621c5cfe2f5ff54ca0e1e2ad 100644
--- a/packages/client-runtime/src/environment/endpoint.ts
+++ b/packages/client-runtime/src/environment/endpoint.ts
@@ -1,9 +1 @@
export * from "@t3tools/shared/advertisedEndpoint";
-
-export const environmentEndpointUrl = (httpBaseUrl: string, pathname: string): string => {
- const url = new URL(httpBaseUrl);
- url.pathname = pathname;
- url.search = "";
- url.hash = "";
- return url.toString();
-};
diff --git a/packages/client-runtime/src/operations/commands.ts b/packages/client-runtime/src/operations/commands.ts
index 83ca6b4f663a823374e6d7f84cfc9a359e7d0b58..bce6b98c421c4af2efc68e7ecbd632427e21b85f 100644
--- a/packages/client-runtime/src/operations/commands.ts
+++ b/packages/client-runtime/src/operations/commands.ts
@@ -4,6 +4,7 @@ import {
CommandId,
CheckpointId,
CheckpointScopeId,
+ HubOrganizationCommand,
ORCHESTRATION_V2_WS_METHODS,
OrchestrationV2CheckpointUnavailableError,
WS_METHODS,
@@ -32,9 +33,15 @@ import { derivePendingBackgroundWork } from "@t3tools/shared/orchestrationV2Pend
import { visibleThreadPullRequests } from "@t3tools/shared/threadPullRequests";
import * as Crypto from "effect/Crypto";
import * as Effect from "effect/Effect";
+import * as Option from "effect/Option";
+import * as Schema from "effect/Schema";
+import * as Hub from "../connection/hub.ts";
+import * as EnvironmentSupervisor from "../connection/supervisor.ts";
import { getInitialServerConfig, request } from "../rpc/client.ts";
+const isHubOrganizationCommand = Schema.is(HubOrganizationCommand);
+
interface CommandMetadata {
readonly commandId?: CommandId;
readonly createdAt?: string;
@@ -270,8 +277,22 @@ const allocateCommandId = Effect.fn("EnvironmentCommands.allocateCommandId")(fun
return CommandId.make(yield* crypto.randomUUIDv4().pipe(Effect.orDie));
});
-const dispatch = (command: OrchestrationV2Command) =>
- request(ORCHESTRATION_V2_WS_METHODS.dispatchCommand, command);
+const dispatch = Effect.fn("EnvironmentCommands.dispatch")(function* (
+ command: OrchestrationV2Command,
+) {
+ if (isHubOrganizationCommand(command)) {
+ const hub = yield* Effect.serviceOption(Hub.HubConnection);
+ if (Option.isSome(hub)) {
+ const supervisor = yield* EnvironmentSupervisor.EnvironmentSupervisor;
+ const result = yield* hub.value.organize({
+ environmentId: supervisor.target.environmentId,
+ command,
+ });
+ if (Option.isSome(result)) return result.value;
+ }
+ }
+ return yield* request(ORCHESTRATION_V2_WS_METHODS.dispatchCommand, command);
+});
const getProjection = (threadId: ThreadId) =>
request(ORCHESTRATION_V2_WS_METHODS.getThreadProjection, { threadId });
diff --git a/packages/client-runtime/src/platform/capabilities.ts b/packages/client-runtime/src/platform/capabilities.ts
index 2de4522a592ec72eaf32ec46a9c472ce53b7270c..6f4d20341a87297dd9315f3723511f7f9f5b522e 100644
--- a/packages/client-runtime/src/platform/capabilities.ts
+++ b/packages/client-runtime/src/platform/capabilities.ts
@@ -76,3 +76,12 @@ export class SshEnvironmentGateway extends Context.Service<
) => Effect.Effect;
}
>()("@t3tools/client-runtime/platform/capabilities/SshEnvironmentGateway") {}
+
+export class HostedAuthAccess extends Context.Service<
+ HostedAuthAccess,
+ {
+ readonly headers: (
+ serverUrl: string,
+ ) => Effect.Effect, ConnectionAttemptError>;
+ }
+>()("@t3tools/client-runtime/platform/capabilities/HostedAuthAccess") {}
diff --git a/packages/client-runtime/src/platform/persistence.ts b/packages/client-runtime/src/platform/persistence.ts
index 29463ce6da585fb9219058fbfcf0634de7bd44d3..a929a29118dbeafab0afe84bf2ee57f6893c2bf0 100644
--- a/packages/client-runtime/src/platform/persistence.ts
+++ b/packages/client-runtime/src/platform/persistence.ts
@@ -146,7 +146,10 @@ export class EnvironmentCacheStore extends Context.Service<
>()("@t3tools/client-runtime/platform/persistence/EnvironmentCacheStore") {}
export class EnvironmentOwnedDataCleanup extends Context.Reference<{
- readonly clear: (environmentId: EnvironmentId) => Effect.Effect;
+ readonly clear: (
+ environmentId: EnvironmentId,
+ options?: { readonly retainDrafts?: boolean },
+ ) => Effect.Effect;
}>("@t3tools/client-runtime/platform/persistence/EnvironmentOwnedDataCleanup", {
defaultValue: () => ({
clear: () => Effect.void,
diff --git a/packages/client-runtime/src/rpc/http.ts b/packages/client-runtime/src/rpc/http.ts
index bccd0ef516060e5b16ebaacb3ed9d8faf681c359..64423edcb4665d4da6a69a32f37a044a81738fad 100644
--- a/packages/client-runtime/src/rpc/http.ts
+++ b/packages/client-runtime/src/rpc/http.ts
@@ -8,6 +8,7 @@ import {
type EnvironmentResourceNotFoundError,
type EnvironmentScopeRequiredError,
} from "@t3tools/contracts";
+import { normalizeHttpBaseUrl } from "@t3tools/shared/advertisedEndpoint";
import * as HttpObservability from "@t3tools/shared/httpObservability";
import * as Data from "effect/Data";
import * as Duration from "effect/Duration";
@@ -86,17 +87,9 @@ export const layerRemoteHttpClient = (
HttpObservability.layer,
);
-const remoteApiBaseUrl = (httpBaseUrl: string): string => {
- const url = new URL(httpBaseUrl);
- url.pathname = "/";
- url.search = "";
- url.hash = "";
- return url.toString();
-};
-
export const makeEnvironmentHttpApiClient = (httpBaseUrl: string) =>
HttpApiClient.make(EnvironmentHttpApi, {
- baseUrl: remoteApiBaseUrl(httpBaseUrl),
+ baseUrl: normalizeHttpBaseUrl(httpBaseUrl),
});
export const makeEnvironmentHttpApiGroupClient = <
@@ -109,14 +102,14 @@ export const makeEnvironmentHttpApiGroupClient = <
HttpApiClient.group(EnvironmentHttpApi, {
httpClient,
group,
- baseUrl: remoteApiBaseUrl(httpBaseUrl),
+ baseUrl: normalizeHttpBaseUrl(httpBaseUrl),
}),
);
/** Contract-derived request URLs for authentication proofs, tracing, and structured errors. */
export const makeEnvironmentHttpApiUrlBuilder = (httpBaseUrl: string) =>
HttpApiClient.urlBuilder(EnvironmentHttpApi, {
- baseUrl: remoteApiBaseUrl(httpBaseUrl),
+ baseUrl: normalizeHttpBaseUrl(httpBaseUrl),
});
const failRemoteRequest = (
diff --git a/packages/client-runtime/src/state/archiveAssetHttp.ts b/packages/client-runtime/src/state/archiveAssetHttp.ts
new file mode 100644
index 0000000000000000000000000000000000000000..bba590a5711eadc9b6d0c9ce1dff15188062a65f
--- /dev/null
+++ b/packages/client-runtime/src/state/archiveAssetHttp.ts
@@ -0,0 +1,41 @@
+import {
+ ArchiveAssetCreateUrlInput,
+ ArchiveError,
+ AssetCreateUrlResult,
+ type AssetResource,
+ type EnvironmentId,
+} from "@t3tools/contracts";
+import * as Effect from "effect/Effect";
+import * as Schema from "effect/Schema";
+import * as SubscriptionRef from "effect/SubscriptionRef";
+import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http";
+import * as HubConnection from "../connection/hub.ts";
+import { environmentEndpointUrl } from "../environment/endpoint.ts";
+
+const encodeInput = Schema.encodeEffect(Schema.toCodecJson(ArchiveAssetCreateUrlInput));
+export const fetchArchivedAssetUrl = Effect.fn("clientRuntime.state.fetchArchivedAssetUrl")(
+ function* (environmentId: EnvironmentId, resource: AssetResource) {
+ const hub = yield* HubConnection.HubConnection;
+ const connection = (yield* SubscriptionRef.get(hub.state)).connection;
+ if (!connection) return yield* new ArchiveError({ reason: "unavailable" });
+ const http = yield* HttpClient.HttpClient;
+ const result = yield* HttpClientRequest.post(
+ `${connection.httpBaseUrl}/api/archive/assets`,
+ ).pipe(
+ HttpClientRequest.setHeaders(yield* hub.headers),
+ HttpClientRequest.bodyJson(yield* encodeInput({ environmentId, resource })),
+ Effect.flatMap(http.execute),
+ Effect.provideService(FetchHttpClient.RequestInit, {
+ credentials: "include",
+ redirect: "error",
+ }),
+ Effect.flatMap(HttpClientResponse.filterStatusOk),
+ Effect.flatMap(HttpClientResponse.schemaBodyJson(AssetCreateUrlResult)),
+ Effect.timeout("10 seconds"),
+ );
+ return {
+ ...result,
+ relativeUrl: environmentEndpointUrl(connection.httpBaseUrl, result.relativeUrl),
+ };
+ },
+);
diff --git a/packages/client-runtime/src/state/archiveThreadHttp.ts b/packages/client-runtime/src/state/archiveThreadHttp.ts
new file mode 100644
index 0000000000000000000000000000000000000000..bcb66475234c50968cc119b785ec0bb3b3f98ec5
--- /dev/null
+++ b/packages/client-runtime/src/state/archiveThreadHttp.ts
@@ -0,0 +1,38 @@
+import {
+ ArchiveError,
+ OrchestrationV2ThreadDetailSnapshot,
+ type EnvironmentId,
+ type ThreadId,
+} from "@t3tools/contracts";
+import * as Effect from "effect/Effect";
+import * as Schema from "effect/Schema";
+import * as SubscriptionRef from "effect/SubscriptionRef";
+import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http";
+import * as HubConnection from "../connection/hub.ts";
+
+export const fetchArchivedThreadSnapshot = Effect.fn(
+ "clientRuntime.state.fetchArchivedThreadSnapshot",
+)(function* (environmentId: EnvironmentId, threadId: ThreadId) {
+ const hub = yield* HubConnection.HubConnection;
+ const connection = (yield* SubscriptionRef.get(hub.state)).connection;
+ if (!connection) return yield* new ArchiveError({ reason: "unavailable" });
+ const http = yield* HttpClient.HttpClient;
+ return yield* HttpClientRequest.get(
+ `${connection.httpBaseUrl}/api/archive/catalog/${encodeURIComponent(environmentId)}/${encodeURIComponent(threadId)}`,
+ ).pipe(
+ HttpClientRequest.setHeaders(yield* hub.headers),
+ http.execute,
+ Effect.provideService(FetchHttpClient.RequestInit, { redirect: "error" }),
+ Effect.flatMap(HttpClientResponse.filterStatusOk),
+ Effect.flatMap(
+ HttpClientResponse.schemaBodyJson(Schema.toCodecJson(OrchestrationV2ThreadDetailSnapshot)),
+ ),
+ Effect.filterOrFail(
+ (snapshot) =>
+ snapshot.projection.thread.id === threadId &&
+ snapshot.projection.thread.archivedAt !== null,
+ () => new ArchiveError({ reason: "integrity" }),
+ ),
+ Effect.timeout("10 seconds"),
+ );
+});
diff --git a/packages/client-runtime/src/state/assets.test.ts b/packages/client-runtime/src/state/assets.test.ts
index c572f51370e8d677955f6b9702d1f00451d0b751..61de79f7936c9905e3171e714c0c20102e559abc 100644
--- a/packages/client-runtime/src/state/assets.test.ts
+++ b/packages/client-runtime/src/state/assets.test.ts
@@ -11,6 +11,8 @@ import {
ThreadId,
WS_METHODS,
} from "@t3tools/contracts";
+import { HttpClient, HttpClientResponse } from "effect/http";
+import * as HubConnection from "../connection/hub.ts";
import * as Cause from "effect/Cause";
import * as Effect from "effect/Effect";
import * as Stream from "effect/Stream";
@@ -29,14 +31,29 @@ import {
import * as EnvironmentSupervisor from "../connection/supervisor.ts";
import type { RpcSession } from "../rpc/session.ts";
import type { WsRpcProtocolClient } from "../rpc/protocol.ts";
+import { createOrchestrationEnvironmentAtoms } from "./orchestration.ts";
+import { v2Project, v2ThreadShell, v2Now } from "./orchestrationV2TestFixtures.ts";
import { createProjectFaviconCache } from "../projectFaviconCache.ts";
import {
createAssetEnvironmentAtoms,
createProjectFaviconUrlAtomFamily,
InvalidAssetCollectionKeyError,
parseAssetCollectionKey,
+ resolveAssetUrl,
} from "./assets.ts";
+describe("asset addresses", () => {
+ it("resolves signed environment paths through the proxy while retaining external assets", () => {
+ const base = "https://hub.example/prefix/environments/one/";
+ expect(resolveAssetUrl(base, "/api/assets/a%2Fb?signature=signed")).toBe(
+ `${base}api/assets/a%2Fb?signature=signed`,
+ );
+ expect(resolveAssetUrl(base, "https://other.example/api/assets/signed")).toBe(
+ "https://other.example/api/assets/signed",
+ );
+ });
+});
+
describe("asset collection keys", () => {
it("preserves malformed JSON and its native cause", () => {
const key = "not-json";
@@ -60,6 +77,118 @@ describe("asset collection keys", () => {
});
describe("createAssetEnvironmentAtoms", () => {
+ it.effect("issues NAS attachment URLs without waiting for a sleeping source session", () =>
+ Effect.gen(function* () {
+ const environmentId = EnvironmentId.make("sleeping-laptop");
+ const resource = {
+ _tag: "attachment" as const,
+ attachmentId: "stored-image",
+ fileName: "picture.png",
+ };
+ const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({
+ target: new PrimaryConnectionTarget({
+ environmentId,
+ label: "Laptop",
+ httpBaseUrl: "https://source.example.test",
+ wsBaseUrl: "wss://source.example.test",
+ }),
+ state: yield* SubscriptionRef.make({
+ ...AVAILABLE_CONNECTION_STATE,
+ phase: "offline",
+ }),
+ session: yield* SubscriptionRef.make(Option.none()),
+ prepared: yield* SubscriptionRef.make(Option.none()),
+ connect: Effect.void,
+ disconnect: Effect.void,
+ retryNow: Effect.void,
+ });
+ const environments = EnvironmentRegistry.EnvironmentRegistry.of({
+ run: (_id, effect) =>
+ Effect.provideService(effect, EnvironmentSupervisor.EnvironmentSupervisor, supervisor),
+ followStream: (_id, stream) =>
+ Stream.provideService(stream, EnvironmentSupervisor.EnvironmentSupervisor, supervisor),
+ } as EnvironmentRegistry.EnvironmentRegistry["Service"]);
+ const hubState = yield* SubscriptionRef.make({
+ ...HubConnection.EMPTY_HUB_CONNECTION_STATE,
+ inbox: {
+ environments: [
+ {
+ environmentId,
+ pendingThreadIds: [],
+ snapshot: {
+ schemaVersion: 2,
+ snapshotSequence: 17,
+ projects: [v2Project],
+ threads: [],
+ archivedThreads: [{ ...v2ThreadShell, archivedAt: v2Now }],
+ },
+ },
+ ],
+ },
+ connection: {
+ environmentId: EnvironmentId.make("nas"),
+ label: "Archive",
+ httpBaseUrl: "https://archive.example.test",
+ },
+ });
+ const hub = HubConnection.HubConnection.of({
+ state: hubState,
+ headers: Effect.succeed({ authorization: "Bearer ephemeral" }),
+ connect: () => Effect.void,
+ connectAuthenticated: () => Effect.void,
+ refresh: Effect.void,
+ refreshInbox: Effect.void,
+ organize: () => Effect.succeedNone,
+ disconnect: Effect.void,
+ });
+ const client = HttpClient.make((request) =>
+ Effect.sync(() => {
+ expect(request.method).toBe("POST");
+ expect(request.url).toBe("https://archive.example.test/api/archive/assets");
+ expect(request.headers.authorization).toBe("Bearer ephemeral");
+ if (request.body._tag !== "Uint8Array") throw new Error("Expected JSON body");
+ expect(JSON.parse(new TextDecoder().decode(request.body.body))).toEqual({
+ environmentId,
+ resource,
+ });
+ return HttpClientResponse.fromWeb(
+ request,
+ Response.json({
+ relativeUrl: "/api/archive/assets/signed/picture.png",
+ expiresAt: 4_000_000_000_000,
+ }),
+ );
+ }),
+ );
+ const runtime = Atom.runtime(
+ Layer.mergeAll(
+ Layer.succeed(EnvironmentRegistry.EnvironmentRegistry, environments),
+ Layer.succeed(HubConnection.HubConnection, hub),
+ Layer.succeed(HttpClient.HttpClient, client),
+ ),
+ );
+ const registry = AtomRegistry.make();
+ yield* Effect.addFinalizer(() => Effect.sync(() => registry.dispose()));
+ const query = createAssetEnvironmentAtoms(runtime).createUrl({
+ environmentId,
+ input: { resource },
+ });
+ const result = yield* AtomRegistry.getResult(registry, query, { suspendOnWaiting: true });
+ expect(result.relativeUrl).toBe(
+ "https://archive.example.test/api/archive/assets/signed/picture.png",
+ );
+ const archiveQuery = createOrchestrationEnvironmentAtoms(runtime).archivedShellSnapshot({
+ environmentId,
+ input: {},
+ });
+ const discovered = yield* AtomRegistry.getResult(registry, archiveQuery, {
+ suspendOnWaiting: true,
+ });
+ expect(discovered.threads.map((thread) => thread.id)).toEqual([v2ThreadShell.id]);
+ expect(discovered.projects[0]?.id).toBe(v2Project.id);
+ }).pipe(Effect.scoped),
+ );
+
it.effect.each([
{ name: "missing video", path: "/tmp/clip.mp4", fallback: true },
{ name: "literal filename characters", path: "/tmp/frame#one?two.png", fallback: true },
diff --git a/packages/client-runtime/src/state/assets.ts b/packages/client-runtime/src/state/assets.ts
index 8543dd957731e5ac13ce1da518642e2d3d7d035e..42a9a6ef00ce8db8906b548cf35e63c16a991ec4 100644
--- a/packages/client-runtime/src/state/assets.ts
+++ b/packages/client-runtime/src/state/assets.ts
@@ -1,4 +1,5 @@
import {
+ ArchiveError,
type AssetCreateUrlInput,
type AssetCreateUrlResult,
type AssetImageDimensions,
@@ -13,6 +14,9 @@ import {
getProjectFaviconResourceKey,
isProjectFaviconFallbackUrl,
} from "@t3tools/shared/projectFavicon";
+import { HttpClient } from "effect/http";
+import * as HubConnection from "../connection/hub.ts";
+import { fetchArchivedAssetUrl } from "./archiveAssetHttp.ts";
import * as Effect from "effect/Effect";
import * as Option from "effect/Option";
import * as Result from "effect/Result";
@@ -21,9 +25,11 @@ import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity";
import * as EnvironmentRegistry from "../connection/registry.ts";
import * as EnvironmentSupervisor from "../connection/supervisor.ts";
-import { request } from "../rpc/client.ts";
+import { environmentEndpointUrl } from "../environment/endpoint.ts";
+import { request, EnvironmentRpcUnavailableError } from "../rpc/client.ts";
import type { ProjectFaviconCache, ProjectFaviconTarget } from "../projectFaviconCache.ts";
import { createEnvironmentQueryAtomFamily } from "./runtime.ts";
+import { authorizeHubSignedUrl } from "./hubSignedUrl.ts";
const ASSET_URL_REFRESH_INTERVAL_MS = 30 * 60_000;
const ASSET_URL_STALE_TIME_MS = 5 * 60_000;
@@ -44,6 +50,7 @@ export class InvalidAssetCollectionKeyError extends Schema.TaggedError(
readonly httpBaseUrl: string;
} | null>,
) {
+ const fromArchive = Effect.fn("assets.fromArchive")(function* (input: AssetCreateUrlInput) {
+ const supervisor = yield* EnvironmentSupervisor.EnvironmentSupervisor;
+ const hub = yield* Effect.serviceOption(HubConnection.HubConnection);
+ const http = yield* Effect.serviceOption(HttpClient.HttpClient);
+ if (Option.isNone(hub) || Option.isNone(http)) return Option.none();
+ return yield* fetchArchivedAssetUrl(supervisor.target.environmentId, input.resource).pipe(
+ Effect.provideService(HubConnection.HubConnection, hub.value),
+ Effect.provideService(HttpClient.HttpClient, http.value),
+ Effect.option,
+ );
+ });
const execute = Effect.fn("assets.createUrl")(function* (input: AssetCreateUrlInput) {
+ const supervisor = yield* EnvironmentSupervisor.EnvironmentSupervisor;
const result = yield* request(WS_METHODS.assetsCreateUrl, input).pipe(Effect.result);
- if (Result.isSuccess(result)) return result.success;
+ if (Result.isSuccess(result)) return yield* authorizeHubSignedUrl(result.success);
+ const archived = yield* fromArchive(input);
+ if (Option.isSome(archived)) return archived.value;
const error = result.failure;
const resource = input.resource;
const local = localMediaEnvironment
? (yield* AtomRegistry.AtomRegistry).get(localMediaEnvironment)
: null;
- const supervisor = yield* EnvironmentSupervisor.EnvironmentSupervisor;
if (
!local ||
local.environmentId === supervisor.target.environmentId ||
@@ -170,20 +190,79 @@ export function createAssetEnvironmentAtoms(
const registry = yield* EnvironmentRegistry.EnvironmentRegistry;
const asset = yield* registry.run(
local.environmentId,
- request(WS_METHODS.assetsCreateUrl, input),
+ request(WS_METHODS.assetsCreateUrl, input).pipe(Effect.flatMap(authorizeHubSignedUrl)),
);
// Callers resolve against the thread's server, so preserve the local server's origin.
- return { ...asset, relativeUrl: new URL(asset.relativeUrl, local.httpBaseUrl).href };
+ return { ...asset, relativeUrl: environmentEndpointUrl(local.httpBaseUrl, asset.relativeUrl) };
});
- const createUrl = createEnvironmentQueryAtomFamily(runtime, {
+ const createUrlQuery = createEnvironmentQueryAtomFamily(runtime, {
label: "environment-data:assets:create-url",
execute,
+ executeDisconnected: (input) =>
+ fromArchive(input).pipe(
+ Effect.flatMap(
+ Option.match({
+ onSome: Effect.succeed,
+ onNone: () =>
+ EnvironmentSupervisor.EnvironmentSupervisor.pipe(
+ Effect.flatMap((supervisor) =>
+ Effect.fail(
+ new EnvironmentRpcUnavailableError({
+ environmentId: supervisor.target.environmentId,
+ message: new ArchiveError({ reason: "unavailable" }).message,
+ }),
+ ),
+ ),
+ ),
+ }),
+ ),
+ ),
staleTimeMs: ASSET_URL_STALE_TIME_MS,
idleTtlMs: ASSET_URL_IDLE_TTL_MS,
refreshIntervalMs: ASSET_URL_REFRESH_INTERVAL_MS,
refreshTrigger: ({ input }) =>
input.resource._tag === "media-file" ? localMediaEnvironment : undefined,
});
+ const signedUrlFamily = Atom.family((key: string) => {
+ const [environmentId, resource] = decodeAssetUrlKey(JSON.parse(key));
+ const query = createUrlQuery({ environmentId, input: { resource } });
+ return Atom.transform(
+ query,
+ (get) => {
+ const result = get(query);
+ if (
+ AsyncResult.isSuccess(result) &&
+ !result.waiting &&
+ (new URL(result.value.relativeUrl, "http://relative.invalid").searchParams.has(
+ "wsTicket",
+ ) ||
+ new URL(result.value.relativeUrl, "http://relative.invalid").pathname.includes(
+ "/api/archive/assets/",
+ ))
+ ) {
+ // @effect-diagnostics-next-line globalDate:off - Atom expiry schedules outside an Effect runtime.
+ const now = Date.now();
+ const remaining = Math.min(
+ ASSET_URL_REFRESH_INTERVAL_MS,
+ Math.max(0, result.value.expiresAt - now - 60_000),
+ );
+ if (remaining === 0) {
+ get.refresh(query);
+ return get.once(query);
+ }
+ // @effect-diagnostics-next-line globalTimers:off - Atom lifetime owns the refresh timer.
+ const handle = setTimeout(() => get.refresh(query), remaining);
+ get.addFinalizer(() => clearTimeout(handle));
+ }
+ return result;
+ },
+ { initialValueTarget: query },
+ ).pipe(Atom.setIdleTTL(ASSET_URL_IDLE_TTL_MS));
+ });
+ const createUrl = (target: {
+ readonly environmentId: EnvironmentId;
+ readonly input: AssetCreateUrlInput;
+ }) => signedUrlFamily(JSON.stringify([target.environmentId, target.input.resource]));
const createUrlsFamily = Atom.family((key: string) => {
const [environmentId, resources] = parseAssetCollectionKey(key);
return Atom.make((get) =>
diff --git a/packages/client-runtime/src/state/attachments.ts b/packages/client-runtime/src/state/attachments.ts
index b58a8e34e5a95ab8927330bf385bd5b02617c7b3..d2fd65ec78a9b833979ee09dce9c7e66967efdf5 100644
--- a/packages/client-runtime/src/state/attachments.ts
+++ b/packages/client-runtime/src/state/attachments.ts
@@ -7,8 +7,11 @@ import {
type EnvironmentId,
} from "@t3tools/contracts";
import type { AsyncResult, Atom, AtomRegistry } from "effect/reactivity";
+import * as Effect from "effect/Effect";
import type { EnvironmentRegistry } from "../connection/registry.ts";
+import { requestGuarded } from "../rpc/client.ts";
+import { authorizeHubSignedUrl } from "./hubSignedUrl.ts";
import {
createEnvironmentRpcCommand,
executeAtomQuery,
@@ -29,6 +32,10 @@ export function createAttachmentEnvironmentAtoms(
createUploadUrl: createEnvironmentRpcCommand(runtime, {
label: "environment-command:attachments:create-upload-url",
tag: WS_METHODS.attachmentsCreateUploadUrl,
+ execute: (input) =>
+ requestGuarded(WS_METHODS.attachmentsCreateUploadUrl, input).pipe(
+ Effect.flatMap(authorizeHubSignedUrl),
+ ),
}),
remove: createEnvironmentRpcCommand(runtime, {
label: "environment-command:attachments:delete",
diff --git a/packages/client-runtime/src/state/environmentHttpAuth.test.ts b/packages/client-runtime/src/state/environmentHttpAuth.test.ts
index 587d707b8a07bb916e2af62b7814361bd00b9ac9..2e1f6d22aa2bddc3d65453a1786109e8124974df 100644
--- a/packages/client-runtime/src/state/environmentHttpAuth.test.ts
+++ b/packages/client-runtime/src/state/environmentHttpAuth.test.ts
@@ -23,6 +23,7 @@ import * as Tracer from "effect/Tracer";
import { TestClock } from "effect/testing";
import type { HttpClient } from "effect/http";
+import { HostedAuthAccess } from "../platform/capabilities.ts";
import * as RemoteEnvironmentAuthorization from "../authorization/service.ts";
import {
ConnectionTransientError,
@@ -629,6 +630,31 @@ describe("authenticated environment HTTP requests", () => {
}),
);
+ it.effect("adds current hosted CSRF to cookie-authenticated environment requests", () =>
+ Effect.gen(function* () {
+ const harness = makeHarness(() => Response.json(SESSION));
+ const servers: string[] = [];
+ yield* fetchEnvironmentSessionState({
+ prepared: { ...PREPARED, httpAuthorization: null },
+ signer: Option.none(),
+ }).pipe(
+ Effect.provide(harness.httpLayer),
+ Effect.provideService(HostedAuthAccess, {
+ headers: (server) =>
+ Effect.sync(() => {
+ servers.push(server);
+ return { "x-csrf-token": "current-cookie-proof" };
+ }),
+ }),
+ );
+ expect(servers).toEqual([PREPARED.httpBaseUrl]);
+ expect(new Headers(harness.calls[0]!.init.headers).get("x-csrf-token")).toBe(
+ "current-cookie-proof",
+ );
+ expect(harness.calls[0]!.init.credentials).toBe("include");
+ }),
+ );
+
it.effect("keeps the caller's timeout while waiting for renewal", () =>
Effect.gen(function* () {
const harness = makeHarness(() => Response.json(SESSION));
diff --git a/packages/client-runtime/src/state/environmentHttpAuth.ts b/packages/client-runtime/src/state/environmentHttpAuth.ts
index 9693c7215b3c611995d0741b320e5ef169cd18a8..6e0b389156f37082de1ceca48ee37b1d82942c04 100644
--- a/packages/client-runtime/src/state/environmentHttpAuth.ts
+++ b/packages/client-runtime/src/state/environmentHttpAuth.ts
@@ -8,6 +8,7 @@ import * as Result from "effect/Result";
import { withRelayClientTracing } from "@t3tools/shared/relayTracing";
import { FetchHttpClient, type HttpMethod } from "effect/http";
+import { HostedAuthAccess } from "../platform/capabilities.ts";
import type { RemoteEnvironmentAuthorization } from "../authorization/service.ts";
import type { PreparedConnection, PreparedHttpAuthorization } from "../connection/model.ts";
import type { ManagedRelayDpopSigner } from "../relay/managedRelay.ts";
@@ -21,6 +22,7 @@ import {
} from "../rpc/http.ts";
export interface EnvironmentHttpAuthHeaders {
+ readonly "x-csrf-token"?: string;
readonly authorization?: string;
readonly dpop?: string;
}
@@ -71,7 +73,11 @@ const buildEnvironmentAuthHeaders = (
): Effect.Effect =>
Effect.gen(function* () {
if (authorization === null) {
- return {};
+ const hosted = yield* Effect.serviceOption(HostedAuthAccess);
+ if (Option.isNone(hosted)) return {};
+ return yield* hosted.value
+ .headers(new URL(url).origin)
+ .pipe(Effect.orElseSucceed(() => ({})));
}
if (authorization._tag === "Bearer") {
return { authorization: `Bearer ${authorization.token}` };
diff --git a/packages/client-runtime/src/state/hub-capabilities.test.ts b/packages/client-runtime/src/state/hub-capabilities.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..e4976fecf55d8c2c08581f41e933a142bad58df1
--- /dev/null
+++ b/packages/client-runtime/src/state/hub-capabilities.test.ts
@@ -0,0 +1,125 @@
+import { describe, expect, it } from "@effect/vitest";
+import {
+ AuthOrchestrationOperateScope,
+ EnvironmentId,
+ ExecutionEnvironmentDescriptor,
+ type ServerConfig,
+} from "@t3tools/contracts";
+import * as Effect from "effect/Effect";
+import * as Fiber from "effect/Fiber";
+import * as Layer from "effect/Layer";
+import * as Schema from "effect/Schema";
+import * as SubscriptionRef from "effect/SubscriptionRef";
+import { Atom, AtomRegistry } from "effect/reactivity";
+
+import { HubConnection, type HubConnectionState } from "../connection/hub.ts";
+import { createHubConnectionAtoms, createHubEnvironmentCapabilitiesAtom } from "./hub.ts";
+
+const hubId = EnvironmentId.make("hub");
+const id = EnvironmentId.make("offline");
+const descriptor = Schema.decodeUnknownSync(ExecutionEnvironmentDescriptor)({
+ environmentId: id,
+ label: "Offline machine",
+ platform: { os: "darwin", arch: "arm64" },
+ serverVersion: "test",
+ capabilities: {},
+});
+const initial = {
+ connection: { environmentId: hubId, label: "Hub", httpBaseUrl: "https://hub.example/" },
+ directory: {
+ hubEnvironmentId: hubId,
+ environments: [
+ {
+ environmentId: id,
+ label: "Offline machine",
+ httpBaseUrl: "https://hub.example/environments/offline/",
+ scopes: [AuthOrchestrationOperateScope],
+ descriptor,
+ },
+ ],
+ },
+ inbox: {
+ environments: [
+ {
+ environmentId: id,
+ pendingThreadIds: [],
+ snapshot: {
+ schemaVersion: 1,
+ snapshotSequence: 0,
+ projects: [],
+ threads: [],
+ archivedThreads: [],
+ },
+ },
+ ],
+ },
+ refreshing: false,
+ error: null,
+} satisfies HubConnectionState;
+
+describe("central organization availability", () => {
+ it.effect(
+ "permits offline organization from the directory grant without a source configuration",
+ () =>
+ Effect.gen(function* () {
+ const state = yield* SubscriptionRef.make(initial);
+ const runtime = Atom.runtime(
+ Layer.succeed(HubConnection, {
+ state,
+ connect: () => Effect.void,
+ connectAuthenticated: () => Effect.void,
+ headers: Effect.succeed({}),
+ refresh: Effect.void,
+ refreshInbox: Effect.void,
+ disconnect: Effect.void,
+ organize: () => Effect.succeedNone,
+ }),
+ );
+ const hub = createHubConnectionAtoms(runtime);
+ const configs = Atom.make>(new Map());
+ const capabilities = createHubEnvironmentCapabilitiesAtom(configs, hub);
+ const registry = AtomRegistry.make();
+ yield* Effect.addFinalizer(() => Effect.sync(() => registry.dispose()));
+ registry.mount(hub.organizationPermissionAtom(id));
+ yield* AtomRegistry.getResult(registry, hub.stateAtom);
+
+ expect(registry.get(hub.organizationPermissionAtom(id))).toBe(true);
+ expect(registry.get(hub.organizationPermissionAtom(EnvironmentId.make("other")))).toBe(
+ false,
+ );
+ expect(registry.get(configs).size).toBe(0);
+ expect(registry.get(capabilities).get(id)).toMatchObject({
+ threadSettlement: true,
+ threadSnooze: true,
+ threadPinning: true,
+ threadPinReorder: true,
+ threadActiveReorder: true,
+ threadAutoSettleOptOut: true,
+ });
+
+ expect(registry.get(capabilities).get(id)?.threadTitleRegeneration).not.toBe(true);
+ const revoked = yield* Effect.forkChild(
+ Effect.callback((resume) => {
+ const unsubscribe = registry.subscribe(
+ hub.organizationPermissionAtom(id),
+ () => {
+ if (!registry.get(hub.organizationPermissionAtom(id))) resume(Effect.void);
+ },
+ { immediate: true },
+ );
+ return Effect.sync(unsubscribe);
+ }),
+ );
+ yield* SubscriptionRef.set(state, {
+ ...initial,
+ directory: {
+ ...initial.directory,
+ environments: initial.directory.environments.map((entry) => ({ ...entry, scopes: [] })),
+ },
+ });
+ yield* Fiber.join(revoked);
+ expect(registry.get(hub.organizationPermissionAtom(id))).toBe(false);
+ expect(registry.get(capabilities).get(id)?.threadSettlement).not.toBe(true);
+ }).pipe(Effect.scoped),
+ );
+});
diff --git a/packages/client-runtime/src/state/hub.ts b/packages/client-runtime/src/state/hub.ts
new file mode 100644
index 0000000000000000000000000000000000000000..c7b91a006bf2566cf0f807d9106af09100e8a30d
--- /dev/null
+++ b/packages/client-runtime/src/state/hub.ts
@@ -0,0 +1,173 @@
+import * as Effect from "effect/Effect";
+import * as Option from "effect/Option";
+import * as Stream from "effect/Stream";
+import * as SubscriptionRef from "effect/SubscriptionRef";
+import { AsyncResult, Atom } from "effect/reactivity";
+import {
+ AuthOrchestrationOperateScope,
+ HUB_THREAD_ORGANIZATION_FIELDS,
+ type EnvironmentId,
+ type ExecutionEnvironmentCapabilities,
+ type HubInboxEnvironment,
+ type ServerConfig,
+} from "@t3tools/contracts";
+import * as Equal from "effect/Equal";
+import * as Struct from "effect/Struct";
+
+import { HubConnection, EMPTY_HUB_CONNECTION_STATE } from "../connection/hub.ts";
+import type { PairingConnectionInput } from "../connection/onboarding.ts";
+import { createAtomCommandScheduler, createRuntimeCommand } from "./runtime.ts";
+import type { EnvironmentShellState } from "./shell.ts";
+
+export type { HubConnectionState, HubServerConnection } from "../connection/hub.ts";
+export type { PairingConnectionInput } from "../connection/onboarding.ts";
+
+export function applyHubInboxEnvironment(
+ state: EnvironmentShellState,
+ entry: HubInboxEnvironment | undefined,
+): EnvironmentShellState {
+ if (entry === undefined) return state;
+ const local = Option.getOrNull(state.snapshot);
+ const snapshot =
+ local !== null &&
+ (state.status === "live" || local.snapshotSequence > entry.snapshot.snapshotSequence)
+ ? local
+ : entry.snapshot;
+ const pending = new Set(entry.pendingThreadIds);
+ const organization = new Map(entry.snapshot.threads.map((thread) => [thread.id, thread]));
+ let changed = false;
+ const threads = snapshot.threads.map((thread) => {
+ const central = organization.get(thread.id);
+ if (!pending.has(thread.id) || central === undefined) return thread;
+ const fields = Struct.pick(central, HUB_THREAD_ORGANIZATION_FIELDS);
+ if (Equal.equals(fields, Struct.pick(thread, HUB_THREAD_ORGANIZATION_FIELDS))) return thread;
+ changed = true;
+ return { ...thread, ...fields };
+ });
+ const next = changed ? { ...snapshot, threads } : snapshot;
+ if (local === next) return state;
+ return {
+ ...state,
+ snapshot: Option.some(next),
+ status: state.status === "live" ? "live" : "cached",
+ };
+}
+
+export function createHubConnectionAtoms(runtime: Atom.AtomRuntime) {
+ const stateAtom = runtime.atom(
+ Stream.unwrap(HubConnection.pipe(Effect.map((hub) => SubscriptionRef.changes(hub.state)))),
+ { initialValue: EMPTY_HUB_CONNECTION_STATE },
+ );
+ const stateValueAtom = Atom.make((get) =>
+ Option.getOrElse(AsyncResult.value(get(stateAtom)), () => EMPTY_HUB_CONNECTION_STATE),
+ );
+ const inboxValueAtom = Atom.make((get) => get(stateValueAtom).inbox);
+ const organizationEnvironmentIdsAtom = Atom.make((get) => {
+ const state = get(stateValueAtom);
+ if (state.connection === null) return new Set();
+ const permitted = new Set(
+ state.directory?.environments.flatMap((entry) =>
+ entry.scopes.includes(AuthOrchestrationOperateScope) ? [entry.environmentId] : [],
+ ),
+ );
+ return new Set(
+ state.inbox.environments.flatMap((entry) =>
+ permitted.has(entry.environmentId) ? [entry.environmentId] : [],
+ ),
+ );
+ }).pipe(
+ Atom.withEquality>(
+ (left, right) => left.size === right.size && [...left].every((id) => right.has(id)),
+ ),
+ );
+ const organizationPermissionAtom = Atom.family((environmentId: EnvironmentId | null) =>
+ Atom.make((get) =>
+ environmentId === null ? false : get(organizationEnvironmentIdsAtom).has(environmentId),
+ ),
+ );
+ const scheduler = createAtomCommandScheduler();
+ const concurrency = { mode: "serial" as const, key: () => "hub" };
+ const connect = createRuntimeCommand(runtime, {
+ label: "hub:connect",
+ scheduler,
+ concurrency,
+ execute: (input: PairingConnectionInput) =>
+ HubConnection.pipe(Effect.flatMap((hub) => hub.connect(input))),
+ });
+ const connectAuthenticated = createRuntimeCommand(runtime, {
+ label: "hub:sign-in",
+ scheduler,
+ concurrency,
+ execute: (input: { readonly httpBaseUrl: string }) =>
+ HubConnection.pipe(Effect.flatMap((hub) => hub.connectAuthenticated(input))),
+ });
+ const refresh = createRuntimeCommand(runtime, {
+ label: "hub:refresh",
+ scheduler,
+ concurrency,
+ execute: (_input: void) => HubConnection.pipe(Effect.flatMap((hub) => hub.refresh)),
+ });
+ const disconnect = createRuntimeCommand(runtime, {
+ label: "hub:disconnect",
+ scheduler,
+ concurrency,
+ execute: (_input: void) => HubConnection.pipe(Effect.flatMap((hub) => hub.disconnect)),
+ });
+ return {
+ stateAtom,
+ stateValueAtom,
+ inboxValueAtom,
+ organizationEnvironmentIdsAtom,
+ organizationPermissionAtom,
+ connect,
+ connectAuthenticated,
+ refresh,
+ disconnect,
+ };
+}
+
+export function createHubEnvironmentCapabilitiesAtom(
+ configsAtom: Atom.Atom>,
+ hub: Pick<
+ ReturnType,
+ "stateValueAtom" | "organizationEnvironmentIdsAtom"
+ >,
+) {
+ return Atom.make((get): ReadonlyMap => {
+ const capabilities = new Map(
+ get(hub.stateValueAtom).directory?.environments.map((entry) => [
+ entry.environmentId,
+ entry.descriptor.capabilities,
+ ]),
+ );
+ for (const [id, config] of get(configsAtom))
+ capabilities.set(id, config.environment.capabilities);
+ for (const id of get(hub.organizationEnvironmentIdsAtom)) {
+ const current = capabilities.get(id);
+ if (current === undefined) continue;
+ capabilities.set(id, {
+ ...current,
+ threadSettlement: true,
+ threadSnooze: true,
+ threadPinning: true,
+ threadPinReorder: true,
+ threadActiveReorder: true,
+ threadAutoSettleOptOut: true,
+ });
+ }
+ return capabilities;
+ }).pipe(
+ Atom.withEquality>(
+ (left, right) =>
+ left.size === right.size &&
+ [...left].every(([id, capabilities]) => {
+ const other = right.get(id);
+ return (
+ other !== undefined &&
+ Object.keys(capabilities).length === Object.keys(other).length &&
+ Object.entries(capabilities).every(([key, value]) => Reflect.get(other, key) === value)
+ );
+ }),
+ ),
+ );
+}
diff --git a/packages/client-runtime/src/state/hubSignedUrl.test.ts b/packages/client-runtime/src/state/hubSignedUrl.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..2057533dad08b98b1168a49e505e7d4555555eb4
--- /dev/null
+++ b/packages/client-runtime/src/state/hubSignedUrl.test.ts
@@ -0,0 +1,234 @@
+import { describe, expect, it } from "@effect/vitest";
+import {
+ AuthStandardClientScopes,
+ EnvironmentId,
+ EnvironmentAuthorizationError,
+ WS_METHODS,
+} from "@t3tools/contracts";
+import * as Effect from "effect/Effect";
+import * as DateTime from "effect/DateTime";
+import * as Layer from "effect/Layer";
+import * as Option from "effect/Option";
+import * as Stream from "effect/Stream";
+import * as SubscriptionRef from "effect/SubscriptionRef";
+import { Atom, AtomRegistry } from "effect/reactivity";
+import { vi } from "vite-plus/test";
+
+import * as EnvironmentRegistry from "../connection/registry.ts";
+import {
+ AVAILABLE_CONNECTION_STATE,
+ BearerConnectionTarget,
+ type PreparedConnection,
+ type SupervisorConnectionState,
+} from "../connection/model.ts";
+import * as EnvironmentSupervisor from "../connection/supervisor.ts";
+import type { RpcSession } from "../rpc/session.ts";
+import type { WsRpcProtocolClient } from "../rpc/protocol.ts";
+import { layerRemoteHttpClient } from "../rpc/http.ts";
+import { createAssetEnvironmentAtoms, resolveAssetUrl } from "./assets.ts";
+import { createAttachmentEnvironmentAtoms, runAttachmentUploadCycle } from "./attachments.ts";
+import { runAtomCommand } from "./runtime.ts";
+
+const environmentId = EnvironmentId.make("laptop");
+const base = "https://hub.example/knife/environments/laptop/";
+
+const harness = (hub: boolean, allowed = true) =>
+ Effect.gen(function* () {
+ const tickets = new Map();
+ const calls = { assets: 0, uploads: 0, tickets: 0 };
+ const target = new BearerConnectionTarget({
+ environmentId,
+ label: "Laptop",
+ connectionId: hub ? "hub:home:laptop" : "direct:laptop",
+ });
+ const prepared: PreparedConnection = {
+ environmentId,
+ label: "Laptop",
+ httpBaseUrl: base,
+ socketUrl: `${base.replace(/^http/, "ws")}ws`,
+ httpAuthorization: { _tag: "Bearer", token: "root-session" },
+ target,
+ };
+ const fetcher: typeof fetch = async (input, init) => {
+ const url = new URL(String(input));
+ if (url.pathname.endsWith("/api/auth/session"))
+ return Response.json({
+ authenticated: true,
+ auth: {
+ policy: "remote-reachable",
+ bootstrapMethods: [],
+ sessionMethods: [],
+ sessionCookieName: "test",
+ },
+ scopes: allowed ? AuthStandardClientScopes : [],
+ permissions: allowed ? AuthStandardClientScopes : [],
+ });
+ if (url.pathname.endsWith("/api/auth/websocket-ticket")) {
+ expect(new Headers(init?.headers).get("authorization")).toBe("Bearer root-session");
+ const ticket = `media-${++calls.tickets}`;
+ const expiresAt = DateTime.toEpochMillis(DateTime.nowUnsafe()) + 5 * 60_000;
+ tickets.set(ticket, expiresAt);
+ return Response.json({
+ ticket,
+ expiresAt: DateTime.formatIso(DateTime.makeUnsafe(expiresAt)),
+ });
+ }
+ if (
+ url.pathname.includes("/api/assets/") ||
+ url.pathname.includes("/api/attachments/upload/")
+ ) {
+ const authorized =
+ !hub ||
+ (tickets.get(url.searchParams.get("wsTicket") ?? "") ?? 0) >
+ DateTime.toEpochMillis(DateTime.nowUnsafe());
+ return new Response(authorized ? "file bytes" : "unauthorized", {
+ status: authorized ? 200 : 401,
+ });
+ }
+ throw new Error(`Unexpected URL ${url}`);
+ };
+ const client = {
+ [WS_METHODS.assetsCreateUrl]: () =>
+ Effect.sync(() => {
+ calls.assets++;
+ return {
+ relativeUrl: "/api/assets/leaf-signed/photo.png",
+ expiresAt: DateTime.toEpochMillis(DateTime.nowUnsafe()) + 3_600_000,
+ };
+ }),
+ [WS_METHODS.attachmentsCreateUploadUrl]: () =>
+ !allowed
+ ? Effect.fail(
+ new EnvironmentAuthorizationError({
+ requiredScope: "orchestration:operate",
+ message: "Not allowed",
+ }),
+ )
+ : Effect.sync(() => {
+ calls.uploads++;
+ return {
+ attachmentId: "pending",
+ relativeUrl: "/api/attachments/upload/leaf-signed",
+ expiresAt: DateTime.toEpochMillis(DateTime.nowUnsafe()) + 600_000,
+ };
+ }),
+ } as unknown as WsRpcProtocolClient;
+ const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({
+ target,
+ state: yield* SubscriptionRef.make({
+ ...AVAILABLE_CONNECTION_STATE,
+ phase: "connected" as const,
+ }),
+ prepared: yield* SubscriptionRef.make(Option.some(prepared)),
+ session: yield* SubscriptionRef.make(Option.some({ client } as RpcSession)),
+ connect: Effect.void,
+ disconnect: Effect.void,
+ retryNow: Effect.void,
+ });
+ const environments = EnvironmentRegistry.EnvironmentRegistry.of({
+ run: (_id, effect) =>
+ Effect.provideService(effect, EnvironmentSupervisor.EnvironmentSupervisor, supervisor),
+ followStream: (_id, stream) =>
+ Stream.provideService(stream, EnvironmentSupervisor.EnvironmentSupervisor, supervisor),
+ } as EnvironmentRegistry.EnvironmentRegistry["Service"]);
+ const runtime = Atom.runtime(
+ Layer.merge(
+ layerRemoteHttpClient(fetcher),
+ Layer.succeed(EnvironmentRegistry.EnvironmentRegistry, environments),
+ ),
+ );
+ const registry = AtomRegistry.make();
+ yield* Effect.addFinalizer(() => Effect.sync(() => registry.dispose()));
+ const assets = createAssetEnvironmentAtoms(runtime);
+ const attachments = createAttachmentEnvironmentAtoms(runtime);
+ const query = assets.createUrl({
+ environmentId,
+ input: { resource: { _tag: "attachment", attachmentId: "photo" } },
+ });
+ return { registry, assets, attachments, query, fetcher, calls };
+ });
+
+describe("hub signed media capabilities", () => {
+ it.effect("authorizes image reads and guarded byte uploads through the public hub prefix", () =>
+ Effect.scoped(
+ Effect.gen(function* () {
+ const test = yield* harness(true);
+ const asset = yield* AtomRegistry.getResult(test.registry, test.query, {
+ suspendOnWaiting: true,
+ });
+ const url = resolveAssetUrl(base, asset.relativeUrl)!;
+ expect(url).toContain(`${base}api/assets/leaf-signed/photo.png?wsTicket=`);
+ expect(asset.expiresAt).toBeLessThanOrEqual(
+ DateTime.toEpochMillis(DateTime.nowUnsafe()) + 5 * 60_000,
+ );
+ expect((yield* Effect.promise(() => test.fetcher(url))).status).toBe(200);
+ expect((yield* Effect.promise(() => test.fetcher(url.split("?")[0]!))).status).toBe(401);
+ const upload = yield* Effect.promise(() =>
+ runAttachmentUploadCycle({
+ registry: test.registry,
+ createUploadUrl: test.attachments.createUploadUrl,
+ remove: test.attachments.remove,
+ environmentId,
+ upload: { type: "file", name: "notes.txt", mimeType: "text/plain", sizeBytes: 3 },
+ resolveUploadUrl: (relativeUrl) => resolveAssetUrl(base, relativeUrl),
+ transport: (uploadUrl) => ({
+ done: test.fetcher(uploadUrl, { method: "PUT", body: "abc" }).then((response) => {
+ expect(response.status).toBe(200);
+ }),
+ abort: () => {},
+ }),
+ }),
+ );
+ expect(upload).toEqual({ status: "uploaded", attachmentId: "pending" });
+ expect(test.calls).toEqual({ assets: 1, uploads: 1, tickets: 2 });
+ }),
+ ),
+ );
+
+ it.effect("does not mint a hub ticket when the server refuses the upload", () =>
+ Effect.scoped(
+ Effect.gen(function* () {
+ const test = yield* harness(true, false);
+ const result = yield* Effect.promise(() =>
+ runAtomCommand(
+ test.registry,
+ test.attachments.createUploadUrl,
+ {
+ environmentId,
+ input: { type: "file", name: "notes.txt", mimeType: "text/plain", sizeBytes: 3 },
+ },
+ { reportFailure: false },
+ ),
+ );
+ expect(result._tag).toBe("Failure");
+ expect(test.calls).toEqual({ assets: 0, uploads: 0, tickets: 0 });
+ }),
+ ),
+ );
+
+ it.effect.each([true, false])(
+ "refreshes ticket-bound images before expiry without shortening direct asset caching (hub=%s)",
+ (hub) =>
+ Effect.scoped(
+ Effect.gen(function* () {
+ vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "Date"] });
+ yield* Effect.addFinalizer(() => Effect.sync(() => vi.useRealTimers()));
+ const test = yield* harness(hub);
+ const unmount = test.registry.mount(test.query);
+ yield* Effect.addFinalizer(() => Effect.sync(unmount));
+ yield* AtomRegistry.getResult(test.registry, test.query, { suspendOnWaiting: true });
+ vi.advanceTimersByTime(4 * 60_000);
+ yield* Effect.yieldNow;
+ const asset = yield* AtomRegistry.getResult(test.registry, test.query, {
+ suspendOnWaiting: true,
+ });
+ expect(test.calls.assets).toBe(hub ? 2 : 1);
+ expect(test.calls.tickets).toBe(hub ? 2 : 0);
+ expect(
+ (yield* Effect.promise(() => test.fetcher(resolveAssetUrl(base, asset.relativeUrl)!)))
+ .status,
+ ).toBe(200);
+ }),
+ ),
+ );
+});
diff --git a/packages/client-runtime/src/state/hubSignedUrl.ts b/packages/client-runtime/src/state/hubSignedUrl.ts
new file mode 100644
index 0000000000000000000000000000000000000000..772747fab7fa0d2d93196f6471eaabe5c43d0b08
--- /dev/null
+++ b/packages/client-runtime/src/state/hubSignedUrl.ts
@@ -0,0 +1,50 @@
+import * as DateTime from "effect/DateTime";
+import * as Effect from "effect/Effect";
+import * as Option from "effect/Option";
+import * as SubscriptionRef from "effect/SubscriptionRef";
+import { HttpClient } from "effect/http";
+
+import { issueRemoteWebSocketTicket } from "../authorization/remote.ts";
+import * as EnvironmentSupervisor from "../connection/supervisor.ts";
+import { environmentEndpointUrl } from "../environment/endpoint.ts";
+import { EnvironmentRpcUnavailableError } from "../rpc/client.ts";
+
+export const authorizeHubSignedUrl = Effect.fn("clientRuntime.state.authorizeHubSignedUrl")(
+ function* (result: A) {
+ const supervisor = yield* EnvironmentSupervisor.EnvironmentSupervisor;
+ const prepared = Option.getOrNull(yield* SubscriptionRef.get(supervisor.prepared));
+ const target = prepared?.target ?? supervisor.target;
+ if (target._tag !== "BearerConnectionTarget" || !target.connectionId.startsWith("hub:")) {
+ return result;
+ }
+
+ const unavailable = () =>
+ new EnvironmentRpcUnavailableError({
+ environmentId: target.environmentId,
+ message: "Reconnect to this server to access its files.",
+ });
+ if (prepared?.httpAuthorization?._tag !== "Bearer") return yield* unavailable();
+
+ const client = yield* Effect.serviceOption(HttpClient.HttpClient);
+ if (Option.isNone(client)) return yield* unavailable();
+
+ const ticket = yield* issueRemoteWebSocketTicket({
+ httpBaseUrl: prepared.httpBaseUrl,
+ bearerToken: prepared.httpAuthorization.token,
+ }).pipe(
+ Effect.provideService(HttpClient.HttpClient, client.value),
+ Effect.mapError(unavailable),
+ );
+ const base = new URL(prepared.httpBaseUrl);
+ const url = new URL(environmentEndpointUrl(prepared.httpBaseUrl, result.relativeUrl));
+ if (url.origin !== base.origin || !url.pathname.startsWith(base.pathname))
+ return yield* unavailable();
+ url.searchParams.set("wsTicket", ticket.ticket);
+
+ return {
+ ...result,
+ relativeUrl: url.href,
+ expiresAt: Math.min(result.expiresAt, DateTime.toEpochMillis(ticket.expiresAt)),
+ };
+ },
+);
diff --git a/packages/client-runtime/src/state/orchestration.ts b/packages/client-runtime/src/state/orchestration.ts
index 68495a181c9732d905567198b9403d64ba53406f..2f74ccef87e83303097389be3394edd3739ad37a 100644
--- a/packages/client-runtime/src/state/orchestration.ts
+++ b/packages/client-runtime/src/state/orchestration.ts
@@ -1,4 +1,4 @@
-import { ORCHESTRATION_V2_WS_METHODS, type ThreadId } from "@t3tools/contracts";
+import { ArchiveError, ORCHESTRATION_V2_WS_METHODS, type ThreadId } from "@t3tools/contracts";
import * as Effect from "effect/Effect";
import * as Option from "effect/Option";
import * as SubscriptionRef from "effect/SubscriptionRef";
@@ -7,6 +7,7 @@ import { Atom } from "effect/reactivity";
import {
createEnvironmentRpcCommand,
+ createEnvironmentQueryAtomFamily,
createEnvironmentRpcQueryAtomFamily,
createEnvironmentRpcSubscriptionAtomFamily,
createEnvironmentCommand,
@@ -15,7 +16,12 @@ import type { EnvironmentRegistry } from "../connection/registry.ts";
import * as EnvironmentSupervisor from "../connection/supervisor.ts";
import * as RemoteEnvironmentAuthorization from "../authorization/service.ts";
import * as ManagedRelayDpopSigner from "../relay/managedRelay.ts";
-import { EnvironmentRpcUnavailableError } from "../rpc/client.ts";
+import * as HubConnection from "../connection/hub.ts";
+import {
+ request,
+ EnvironmentRpcUnavailableError,
+ type EnvironmentRpcInput,
+} from "../rpc/client.ts";
import { fetchEnvironmentThreadTranscript } from "./threadTranscriptHttp.ts";
export function createOrchestrationEnvironmentAtoms(
@@ -109,9 +115,33 @@ export function createOrchestrationEnvironmentAtoms(
staleTimeMs: 30_000,
idleTtlMs: 60_000,
}),
- archivedShellSnapshot: createEnvironmentRpcQueryAtomFamily(runtime, {
+ archivedShellSnapshot: createEnvironmentQueryAtomFamily(runtime, {
label: "environment-data:orchestration:archived-shell-snapshot",
- tag: ORCHESTRATION_V2_WS_METHODS.getArchivedShellSnapshot,
+ execute: (
+ input: EnvironmentRpcInput,
+ ) => request(ORCHESTRATION_V2_WS_METHODS.getArchivedShellSnapshot, input),
+ executeDisconnected: () =>
+ Effect.gen(function* () {
+ const supervisor = yield* EnvironmentSupervisor.EnvironmentSupervisor;
+ const unavailable = () =>
+ new EnvironmentRpcUnavailableError({
+ environmentId: supervisor.target.environmentId,
+ message: new ArchiveError({ reason: "unavailable" }).message,
+ });
+ const hub = yield* Effect.serviceOption(HubConnection.HubConnection);
+ if (Option.isNone(hub)) return yield* unavailable();
+ yield* hub.value.refreshInbox.pipe(Effect.ignore);
+ const entry = (yield* SubscriptionRef.get(hub.value.state)).inbox.environments.find(
+ (entry) => entry.environmentId === supervisor.target.environmentId,
+ );
+ if (!entry) return yield* unavailable();
+ return {
+ schemaVersion: entry.snapshot.schemaVersion,
+ snapshotSequence: entry.snapshot.snapshotSequence,
+ projects: entry.snapshot.projects,
+ threads: entry.snapshot.archivedThreads,
+ };
+ }),
}),
};
}
diff --git a/packages/client-runtime/src/state/runtime.ts b/packages/client-runtime/src/state/runtime.ts
index 95aabd6d843da0e3c3b8507a45adad09d5b7b7a9..148a16d6edac3ecc33123404b64e9ca33104fcae 100644
--- a/packages/client-runtime/src/state/runtime.ts
+++ b/packages/client-runtime/src/state/runtime.ts
@@ -66,6 +66,10 @@ interface EnvironmentQueryAtomOptions extends Omit<
input: Input,
emit: (value: A) => Effect.Effect,
) => Effect.Effect;
+ readonly executeDisconnected?: (
+ input: Input,
+ emit: (value: A) => Effect.Effect,
+ ) => Effect.Effect;
readonly persistence?: QueryPersistence;
readonly staleTimeMs?: number;
readonly idleTtlMs?: number;
@@ -592,6 +596,14 @@ export function createEnvironmentQueryAtomFamily(
> => {
if (connection === null) return Effect.never;
const [connectionState, session] = connection;
+ if (
+ (connectionState.phase !== "connected" || Option.isNone(session)) &&
+ options.executeDisconnected
+ )
+ return runInEnvironment(
+ target.environmentId,
+ options.executeDisconnected(target.input, emit),
+ );
switch (connectionState.phase) {
case "connected":
return Option.isSome(session)
diff --git a/packages/client-runtime/src/state/shell.ts b/packages/client-runtime/src/state/shell.ts
index dbc4405a9faa0311cb7d3099a4fab2dd9fe62d01..bca1b087ede1dfeb97579f7f03f35359efa563b4 100644
--- a/packages/client-runtime/src/state/shell.ts
+++ b/packages/client-runtime/src/state/shell.ts
@@ -23,6 +23,8 @@ import * as EnvironmentRegistry from "../connection/registry.ts";
import { connectionProjectionPhase } from "../connection/model.ts";
import * as EnvironmentSupervisor from "../connection/supervisor.ts";
import * as ConnectionWakeups from "../connection/wakeups.ts";
+import * as Hub from "../connection/hub.ts";
+import { applyHubInboxEnvironment } from "./hub.ts";
import { safeErrorLogAttributes } from "../errors/safeLog.ts";
import * as Persistence from "../platform/persistence.ts";
import { runCachePersistence } from "./cachePersistence.ts";
@@ -428,9 +430,23 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make")
});
function shellStateChanges(environmentId: EnvironmentId) {
- return followStreamInEnvironment(
- environmentId,
- Stream.unwrap(makeEnvironmentShellState().pipe(Effect.map(SubscriptionRef.changes))),
+ return Stream.unwrap(
+ Effect.gen(function* () {
+ const source = followStreamInEnvironment(
+ environmentId,
+ Stream.unwrap(makeEnvironmentShellState().pipe(Effect.map(SubscriptionRef.changes))),
+ );
+ const hub = yield* Effect.serviceOption(Hub.HubConnection);
+ if (Option.isNone(hub)) return source;
+ return Stream.zipLatest(source, SubscriptionRef.changes(hub.value.state)).pipe(
+ Stream.map(([state, central]) =>
+ applyHubInboxEnvironment(
+ state,
+ central.inbox.environments.find((entry) => entry.environmentId === environmentId),
+ ),
+ ),
+ );
+ }),
);
}
diff --git a/packages/client-runtime/src/state/threadCommands.test.ts b/packages/client-runtime/src/state/threadCommands.test.ts
index cf36760baecb774b1ed8fe161d20178b5345d2b8..2fb860d339bffef5cb35d633f03421668e72a6a0 100644
--- a/packages/client-runtime/src/state/threadCommands.test.ts
+++ b/packages/client-runtime/src/state/threadCommands.test.ts
@@ -24,6 +24,7 @@ import * as EnvironmentRegistry from "../connection/registry.ts";
import * as EnvironmentSupervisor from "../connection/supervisor.ts";
import type { RpcSession } from "../rpc/session.ts";
import { createThreadEnvironmentAtoms } from "./threadCommands.ts";
+import { applyHubInboxEnvironment } from "./hub.ts";
const ENVIRONMENT_ID = EnvironmentId.make("remote");
const THREAD_ID = ThreadId.make("thread");
@@ -80,6 +81,47 @@ const SNAPSHOT: OrchestrationV2ShellSnapshot = {
],
};
+describe("central inbox shell state", () => {
+ const entry = {
+ environmentId: ENVIRONMENT_ID,
+ pendingThreadIds: [THREAD_ID],
+ snapshot: {
+ ...SNAPSHOT,
+ threads: SNAPSHOT.threads.map((thread) => ({
+ ...thread,
+ settledOverride: "settled" as const,
+ settledAt: NOW,
+ })),
+ },
+ };
+
+ it("shows cached chat metadata on a new client with no source snapshot", () => {
+ const state = applyHubInboxEnvironment(
+ {
+ snapshot: Option.none(),
+ status: "empty",
+ error: Option.none(),
+ },
+ entry,
+ );
+ expect(state.status).toBe("cached");
+ expect(Option.getOrNull(state.snapshot)?.threads[0]?.settledOverride).toBe("settled");
+ });
+
+ it("applies pending organization without hiding live execution", () => {
+ const live = {
+ ...SNAPSHOT,
+ threads: SNAPSHOT.threads.map((thread) => ({ ...thread, status: "running" as const })),
+ };
+ const state = { snapshot: Option.some(live), status: "live" as const, error: Option.none() };
+ const merged = Option.getOrThrow(applyHubInboxEnvironment(state, entry).snapshot);
+ expect(merged.threads[0]?.settledOverride).toBe("settled");
+ expect(merged.threads[0]?.status).toBe("running");
+ expect(merged.snapshotSequence).toBe(live.snapshotSequence);
+ expect(applyHubInboxEnvironment(state, { ...entry, pendingThreadIds: [] })).toBe(state);
+ });
+});
+
const makeHarness = Effect.fn("TestThreadCommands.makeHarness")(function* () {
const requests = yield* Queue.unbounded<{
command: OrchestrationV2Command;
diff --git a/packages/client-runtime/src/state/threads-sync.test.ts b/packages/client-runtime/src/state/threads-sync.test.ts
index b78377b2b1ecafc3bb45500fd2b102ced510d7c7..6b5589577aa8f4244f484cd7a8cd7b02cd80c666 100644
--- a/packages/client-runtime/src/state/threads-sync.test.ts
+++ b/packages/client-runtime/src/state/threads-sync.test.ts
@@ -1,11 +1,11 @@
import {
+ OrchestrationV2ThreadDetailSnapshot,
EnvironmentId,
EventId,
MessageId,
ORCHESTRATION_V2_WS_METHODS,
ThreadId,
TurnItemId,
- type OrchestrationV2ThreadDetailSnapshot,
type OrchestrationV2ThreadProjection,
type OrchestrationV2ThreadStreamItem,
type OrchestrationV2TurnItem,
@@ -19,6 +19,8 @@ import * as Fiber from "effect/Fiber";
import * as Option from "effect/Option";
import * as Queue from "effect/Queue";
import * as Ref from "effect/Ref";
+import * as Schema from "effect/Schema";
+import * as HubConnection from "../connection/hub.ts";
import * as Scope from "effect/Scope";
import * as Stream from "effect/Stream";
import * as SubscriptionRef from "effect/SubscriptionRef";
@@ -46,6 +48,9 @@ import {
} from "./threads.ts";
import * as ThreadSnapshotLoader from "./threadSnapshotHttp.ts";
+const encodeArchiveSnapshot = Schema.encodeEffect(
+ Schema.toCodecJson(OrchestrationV2ThreadDetailSnapshot),
+);
const TARGET = new PrimaryConnectionTarget({
environmentId: EnvironmentId.make("environment-1"),
label: "Test environment",
@@ -99,6 +104,8 @@ function awaitThreadState(
}
const makeHarness = Effect.fn("TestEnvironmentThreads.makeHarness")(function* (options?: {
+ readonly hub?: HubConnection.HubConnection["Service"];
+ readonly offline?: boolean;
readonly cached?: OrchestrationV2ThreadProjection;
readonly cachedHistory?: {
readonly historyCursor: string | null;
@@ -155,7 +162,7 @@ const makeHarness = Effect.fn("TestEnvironmentThreads.makeHarness")(function* (o
),
} as unknown as WsRpcProtocolClient;
const supervisorSession = yield* SubscriptionRef.make>(
- Option.some(testSession(client, options)),
+ options?.offline ? Option.none() : Option.some(testSession(client, options)),
);
const prepared = yield* SubscriptionRef.make>(
Option.some(PREPARED),
@@ -244,6 +251,10 @@ const makeHarness = Effect.fn("TestEnvironmentThreads.makeHarness")(function* (o
Effect.provideService(ThreadHistoryController.ThreadHistoryController, historyController),
);
}
+ if (options?.hub)
+ makeThreadState = makeThreadState.pipe(
+ Effect.provideService(HubConnection.HubConnection, options.hub),
+ );
const threadState = yield* makeThreadState;
yield* SubscriptionRef.changes(threadState).pipe(
Stream.runForEach((state) =>
@@ -320,6 +331,64 @@ const deleted = (sequence = 3): OrchestrationV2ThreadStreamItem => {
};
describe("EnvironmentThreads", () => {
+ it.effect(
+ "opens a NAS archived transcript without a source session and resumes its source sequence after reconnect",
+ () =>
+ Effect.gen(function* () {
+ const archived = {
+ ...BASE_PROJECTION,
+ thread: {
+ ...BASE_PROJECTION.thread,
+ archivedAt: DateTime.makeUnsafe("2026-06-20T02:00:00Z"),
+ },
+ };
+ const archiveSnapshot = { snapshotSequence: 81, projection: archived };
+ const hubState = yield* SubscriptionRef.make({
+ ...HubConnection.EMPTY_HUB_CONNECTION_STATE,
+ connection: {
+ environmentId: EnvironmentId.make("archive-hub"),
+ label: "Archive",
+ httpBaseUrl: "https://archive.example.test",
+ },
+ });
+ const hub = HubConnection.HubConnection.of({
+ state: hubState,
+ headers: Effect.succeed({ authorization: "Bearer ephemeral-archive-access" }),
+ connect: () => Effect.void,
+ connectAuthenticated: () => Effect.void,
+ refresh: Effect.void,
+ refreshInbox: Effect.void,
+ organize: () => Effect.succeedNone,
+ disconnect: Effect.void,
+ });
+ const json = yield* encodeArchiveSnapshot(archiveSnapshot);
+ const http = HttpClient.make((request) =>
+ Effect.sync(() => {
+ expect(request.url).toBe(
+ `https://archive.example.test/api/archive/catalog/${TARGET.environmentId}/${THREAD_ID}`,
+ );
+ expect(request.headers.authorization).toBe("Bearer ephemeral-archive-access");
+ return HttpClientResponse.fromWeb(request, Response.json(json));
+ }),
+ );
+ const h = yield* makeHarness({ hub, offline: true, historyHttpClient: http });
+ const state = yield* awaitThreadState(h.observed, (value) => Option.isSome(value.data));
+ expect(state.status).toBe("cached");
+ expect(Option.getOrThrow(state.data)).toEqual(archived);
+ expect(yield* Ref.get(h.subscriptionCount)).toBe(0);
+ expect(yield* Ref.get(h.loaderCalls)).toBe(0);
+ yield* h.replaceSession;
+ yield* Queue.offer(h.inputs, titleUpdated("Source reconnected", 82));
+ const live = yield* awaitThreadState(
+ h.observed,
+ (value) =>
+ Option.isSome(value.data) && value.data.value.thread.title === "Source reconnected",
+ );
+ expect(live.status).toBe("live");
+ expect(yield* Ref.get(h.lastSubscribeAfterSequence)).toBe(81);
+ }),
+ );
+
it.effect("loads the server thread when its local cache read fails", () =>
Effect.gen(function* () {
const h = yield* makeHarness({
diff --git a/packages/client-runtime/src/state/threads.ts b/packages/client-runtime/src/state/threads.ts
index a6e31cf40c4a8f1e32cf69f73fde8ba7f722bd14..150b1c5a668f4af98989556ae0a4491e262d21c9 100644
--- a/packages/client-runtime/src/state/threads.ts
+++ b/packages/client-runtime/src/state/threads.ts
@@ -44,6 +44,8 @@ import {
mergeOlderHistoryIntoProjection,
type ThreadHistoryMeta,
} from "./threadHistoryMerge.ts";
+import { fetchArchivedThreadSnapshot } from "./archiveThreadHttp.ts";
+import * as HubConnection from "../connection/hub.ts";
import * as ThreadSnapshotLoader from "./threadSnapshotHttp.ts";
import {
EMPTY_ENVIRONMENT_THREAD_STATE,
@@ -178,6 +180,7 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make
ThreadHistoryController.ThreadHistoryController,
);
const httpClient = yield* Effect.serviceOption(HttpClient.HttpClient);
+ const archiveHub = yield* Effect.serviceOption(HubConnection.HubConnection);
const dpopSigner = yield* Effect.serviceOption(ManagedRelay.ManagedRelayDpopSigner);
const remoteAuthorization = yield* Effect.serviceOption(
RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization,
@@ -359,6 +362,7 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make
options?: {
/** Socket/full snapshots: drop progressive meta with the new timeline. */
readonly resetHistory?: boolean;
+ readonly status?: "cached";
/**
* Explicit progressive meta installed atomically with the projection
* (bounded HTTP). Wins over resetHistory when both are supplied.
@@ -381,11 +385,13 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make
...previous,
data: Option.some(thread),
// Buffered values from a failed attempt can arrive after its error.
- status: Option.isSome(previous.error)
- ? ("cached" as const)
- : waiting
- ? ("synchronizing" as const)
- : ("live" as const),
+ status:
+ options?.status ??
+ (Option.isSome(previous.error)
+ ? ("cached" as const)
+ : waiting
+ ? ("synchronizing" as const)
+ : ("live" as const)),
error: previous.error,
history,
};
@@ -771,6 +777,43 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make
Effect.forkScoped,
);
+ if (Option.isSome(archiveHub) && Option.isSome(httpClient)) {
+ yield* Stream.merge(
+ SubscriptionRef.changes(supervisor.state).pipe(Stream.map(() => undefined)),
+ SubscriptionRef.changes(archiveHub.value.state).pipe(Stream.map(() => undefined)),
+ ).pipe(
+ Stream.mapEffect(() =>
+ Effect.gen(function* () {
+ const current = yield* SubscriptionRef.get(state);
+ if (current.status === "live" || current.status === "deleted") return;
+ if (Option.isSome(current.data) && !current.history.hasMoreHistory) return;
+ const snapshot = yield* fetchArchivedThreadSnapshot(environmentId, threadId).pipe(
+ Effect.provideService(HubConnection.HubConnection, archiveHub.value),
+ Effect.provideService(HttpClient.HttpClient, httpClient.value),
+ Effect.option,
+ );
+ if (Option.isNone(snapshot)) return;
+ yield* applyLock.withPermits(1)(
+ Effect.gen(function* () {
+ const current = yield* SubscriptionRef.get(state);
+ if (
+ current.status === "live" ||
+ current.status === "deleted" ||
+ snapshot.value.snapshotSequence < (yield* SubscriptionRef.get(lastSequence))
+ )
+ return;
+ yield* SubscriptionRef.set(lastSequence, snapshot.value.snapshotSequence);
+ yield* setThread(snapshot.value.projection, { resetHistory: true, status: "cached" });
+ yield* remember;
+ }),
+ );
+ }),
+ ),
+ Stream.runDrain,
+ Effect.forkScoped,
+ );
+ }
+
const foregroundResubscriptions = Option.match(wakeups, {
onNone: () => Stream.never,
onSome: (service) =>
diff --git a/packages/client-runtime/src/webhookAddress.test.ts b/packages/client-runtime/src/webhookAddress.test.ts
index 2284ee8fb150d06fab3a72f6c39dcbd40dedb3a2..389245ae464a8795f3964c073a0bf219613603e2 100644
--- a/packages/client-runtime/src/webhookAddress.test.ts
+++ b/packages/client-runtime/src/webhookAddress.test.ts
@@ -21,6 +21,12 @@ describe("webhookAddress", () => {
expect(result.note).toContain("Tailscale");
});
+ it("keeps the environment prefix in direct webhook addresses", () => {
+ expect(
+ webhookAddress(endpoint(null), "https://hub.example/prefix/environments/one/").address,
+ ).toBe(`https://hub.example/prefix/environments/one${path}`);
+ });
+
it("says only this computer can call a loopback address", () => {
const result = webhookAddress(endpoint(null), "http://127.0.0.1:3773/");
expect(result.copyable).toBe(true);
diff --git a/packages/client-runtime/src/webhookAddress.ts b/packages/client-runtime/src/webhookAddress.ts
index cd1424fd6f867075a5e42748ad023050e250ae0b..eb229210841143448b10fdccffbc8cb61b9ee269 100644
--- a/packages/client-runtime/src/webhookAddress.ts
+++ b/packages/client-runtime/src/webhookAddress.ts
@@ -1,3 +1,4 @@
+import { environmentEndpointUrl } from "./environment/endpoint.ts";
import type { ScheduledTaskWebhookEndpoint } from "@t3tools/contracts";
import { isLocalLoopbackHost } from "@t3tools/shared/hostClassification";
@@ -29,7 +30,7 @@ export function webhookAddress(
note: "Link this environment to T3 Connect for a public URL.",
};
}
- const url = new URL(endpoint.path, httpBaseUrl);
+ const url = new URL(environmentEndpointUrl(httpBaseUrl, endpoint.path));
return {
address: url.href,
copyable: true,
diff --git a/packages/contracts/src/auth.ts b/packages/contracts/src/auth.ts
index e206163869c48918dc634801975386873ac88264..ca7581d6b865804c5de32569e0a0132ad2a6c3f4 100644
--- a/packages/contracts/src/auth.ts
+++ b/packages/contracts/src/auth.ts
@@ -81,6 +81,9 @@ export type ServerAuthSessionMethod = typeof ServerAuthSessionMethod.Type;
export const AuthOrchestrationReadScope = "orchestration:read" as const;
export const AuthOrchestrationOperateScope = "orchestration:operate" as const;
+export const AuthEnvironmentConnectScope = "environment:connect" as const;
+export const AuthArchiveReadScope = "archive:read" as const;
+export const AuthArchiveWriteScope = "archive:write" as const;
export const AuthSettingsWriteScope = "settings:write" as const;
export const AuthProvidersManageScope = "providers:manage" as const;
export const AuthEnvironmentMaintainScope = "environment:maintain" as const;
@@ -98,8 +101,11 @@ export const AuthAccessWriteScope = "access:write" as const;
export const AuthRelayReadScope = "relay:read" as const;
export const AuthRelayWriteScope = "relay:write" as const;
export const AuthEnvironmentScope = Schema.Literals([
+ AuthEnvironmentConnectScope,
AuthOrchestrationReadScope,
AuthOrchestrationOperateScope,
+ AuthArchiveReadScope,
+ AuthArchiveWriteScope,
AuthSettingsWriteScope,
AuthProvidersManageScope,
AuthEnvironmentMaintainScope,
@@ -152,6 +158,8 @@ const authScopeResponseFields = {
// Only clients talking to an old server use these parent checks. Servers never
// expand stored grants, and an explicitly empty permissions array grants nothing.
const legacyParents: Partial> = {
+ [AuthArchiveReadScope]: AuthOrchestrationReadScope,
+ [AuthArchiveWriteScope]: AuthOrchestrationOperateScope,
[AuthFilesystemReadScope]: AuthOrchestrationReadScope,
[AuthDiagnosticsReadScope]: AuthOrchestrationReadScope,
[AuthSettingsWriteScope]: AuthOrchestrationOperateScope,
@@ -205,6 +213,8 @@ export function sessionHasLegacyPermissions(session: SessionGrantInput): boolean
export const AuthStandardClientScopes = [
AuthOrchestrationReadScope,
AuthOrchestrationOperateScope,
+ AuthArchiveReadScope,
+ AuthArchiveWriteScope,
AuthSettingsWriteScope,
AuthProvidersManageScope,
AuthEnvironmentMaintainScope,
@@ -218,6 +228,7 @@ export const AuthStandardClientScopes = [
AuthRelayReadScope,
] as const;
export const AuthAdministrativeScopes = [
+ AuthEnvironmentConnectScope,
...AuthStandardClientScopes,
AuthAccessReadScope,
AuthAccessWriteScope,
@@ -458,6 +469,12 @@ export const AuthCreatePairingCredentialInput = Schema.Struct({
});
export type AuthCreatePairingCredentialInput = typeof AuthCreatePairingCredentialInput.Type;
+export const AuthCreateDelegatedSessionInput = Schema.Struct({
+ label: Schema.optionalKey(TrimmedNonEmptyString),
+ scopes: AuthGrantScopes,
+});
+export type AuthCreateDelegatedSessionInput = typeof AuthCreateDelegatedSessionInput.Type;
+
export const AuthSessionState = Schema.Struct({
authenticated: Schema.Boolean,
auth: ServerAuthDescriptor,
diff --git a/packages/contracts/src/desktopHostedAuth.ts b/packages/contracts/src/desktopHostedAuth.ts
new file mode 100644
index 0000000000000000000000000000000000000000..6a99dcf13d16d92e80bf25c9e37673076ecb35e3
--- /dev/null
+++ b/packages/contracts/src/desktopHostedAuth.ts
@@ -0,0 +1,15 @@
+import * as Schema from "effect/Schema";
+
+export const DesktopHostedAuthState = Schema.Struct({
+ serverUrl: Schema.NullOr(Schema.String),
+ phase: Schema.Literals(["idle", "waiting", "connected", "error"]),
+ error: Schema.NullOr(Schema.String),
+});
+export type DesktopHostedAuthState = typeof DesktopHostedAuthState.Type;
+
+export const SelfHostedConnectionStatus = Schema.Struct({
+ configured: Schema.Boolean,
+ connected: Schema.Boolean,
+ expiresAt: Schema.NullOr(Schema.String),
+});
+export type SelfHostedConnectionStatus = typeof SelfHostedConnectionStatus.Type;
diff --git a/packages/contracts/src/environmentHttp.ts b/packages/contracts/src/environmentHttp.ts
index fc07bb48505fafed1d6532a1b79e5c8cd08a576a..1a411f857d8cefc4698b34e1445dcda390afc6e1 100644
--- a/packages/contracts/src/environmentHttp.ts
+++ b/packages/contracts/src/environmentHttp.ts
@@ -15,6 +15,7 @@ import {
AuthBrowserSessionResult,
AuthClientSession,
AuthCreatePairingCredentialInput,
+ AuthCreateDelegatedSessionInput,
AuthMcpApprovalDecisionRequest,
AuthMcpApprovalDetails,
AuthMcpApprovalError,
@@ -74,17 +75,20 @@ import {
} from "./relay.ts";
const OptionalBearerHeaders = Schema.Struct({
+ "x-csrf-token": Schema.optionalKey(Schema.String),
authorization: Schema.optionalKey(Schema.String),
dpop: Schema.optionalKey(Schema.String),
});
const OrchestrationProtocolHeaders = Schema.Struct({
+ "x-csrf-token": Schema.optionalKey(Schema.String),
authorization: Schema.optionalKey(Schema.String),
dpop: Schema.optionalKey(Schema.String),
[ORCHESTRATION_PROTOCOL_HEADER]: Schema.Literal(ORCHESTRATION_PROTOCOL_VERSION_TEXT),
});
const OptionalDpopProofHeaders = Schema.Struct({
+ "x-csrf-token": Schema.optionalKey(Schema.String),
dpop: Schema.optionalKey(Schema.String),
});
@@ -374,6 +378,7 @@ const EnvironmentProjectMutationErrors = [
] as const;
export interface EnvironmentSessionPrincipalShape {
+ readonly parentSessionId?: AuthSessionId;
readonly sessionId: AuthSessionId;
readonly subject: string;
readonly method: ServerAuthSessionMethod;
@@ -496,6 +501,14 @@ class EnvironmentAuthHttpApi extends HttpApiGroup.make("auth")
error: EnvironmentPairingCredentialErrors,
}).middleware(EnvironmentAuthenticatedAuth),
)
+ .add(
+ HttpApiEndpoint.post("delegatedSession", "/api/auth/delegated-session", {
+ headers: OptionalBearerHeaders,
+ payload: AuthCreateDelegatedSessionInput,
+ success: AuthAccessTokenResult,
+ error: EnvironmentPairingCredentialErrors,
+ }).middleware(EnvironmentAuthenticatedAuth),
+ )
.add(
HttpApiEndpoint.get("pairingLinks", "/api/auth/pairing-links", {
headers: OptionalBearerHeaders,
diff --git a/packages/contracts/src/hostedAuth.ts b/packages/contracts/src/hostedAuth.ts
new file mode 100644
index 0000000000000000000000000000000000000000..ca9c9949e92071a325b67bfaf3259fce023ba4df
--- /dev/null
+++ b/packages/contracts/src/hostedAuth.ts
@@ -0,0 +1,70 @@
+import * as Schema from "effect/Schema";
+import { AuthClientPresentationMetadata, AuthGrantScopes } from "./auth.ts";
+import { EnvironmentId } from "./baseSchemas.ts";
+
+const ProofChallenge = Schema.String.check(Schema.isPattern(/^[A-Za-z0-9_-]{43}$/u));
+const ProofVerifier = Schema.String.check(Schema.isPattern(/^[A-Za-z0-9._~-]{43,128}$/u));
+
+export const HostedAuthDeviceStart = Schema.Struct({
+ codeChallenge: ProofChallenge,
+ state: ProofChallenge,
+ client: AuthClientPresentationMetadata,
+});
+export type HostedAuthDeviceStart = typeof HostedAuthDeviceStart.Type;
+
+export const HostedAuthDeviceStartResult = Schema.Struct({ authorizationUrl: Schema.String });
+export type HostedAuthDeviceStartResult = typeof HostedAuthDeviceStartResult.Type;
+
+export const HostedAuthDeviceExchange = Schema.Struct({
+ code: ProofChallenge,
+ codeVerifier: ProofVerifier,
+});
+export type HostedAuthDeviceExchange = typeof HostedAuthDeviceExchange.Type;
+
+export const HostedAuthDeviceRefresh = Schema.Struct({ refreshToken: Schema.NonEmptyString });
+export type HostedAuthDeviceRefresh = typeof HostedAuthDeviceRefresh.Type;
+
+export const HostedAuthGrant = Schema.Struct({
+ deviceId: Schema.String,
+ accessToken: Schema.String,
+ refreshToken: Schema.String,
+ expiresAt: Schema.DateTimeUtc,
+});
+export type HostedAuthGrant = typeof HostedAuthGrant.Type;
+
+export const HostedAuthDevice = Schema.Struct({
+ deviceId: Schema.String,
+ client: AuthClientPresentationMetadata,
+ createdAt: Schema.DateTimeUtc,
+ expiresAt: Schema.DateTimeUtc,
+ revokedAt: Schema.NullOr(Schema.DateTimeUtc),
+ current: Schema.Boolean,
+ environmentId: Schema.optionalKey(EnvironmentId),
+});
+export type HostedAuthDevice = typeof HostedAuthDevice.Type;
+
+export const HostedAuthAccount = Schema.Struct({
+ accountId: Schema.String,
+ deviceId: Schema.String,
+ csrfToken: Schema.String,
+ expiresAt: Schema.DateTimeUtc,
+});
+export type HostedAuthAccount = typeof HostedAuthAccount.Type;
+
+export const HostedAuthTokenRequest = Schema.Struct({ scopes: AuthGrantScopes });
+export type HostedAuthTokenRequest = typeof HostedAuthTokenRequest.Type;
+
+export class HostedAuthError extends Schema.TaggedError()("HostedAuthError", {
+ reason: Schema.Literals([
+ "disabled",
+ "configuration",
+ "identity",
+ "transaction",
+ "credential",
+ "forbidden",
+ "reauthentication",
+ "upstream",
+ "storage",
+ ]),
+ cause: Schema.optionalKey(Schema.Redacted(Schema.Defect())),
+}) {}
diff --git a/packages/contracts/src/hub.ts b/packages/contracts/src/hub.ts
new file mode 100644
index 0000000000000000000000000000000000000000..d84b335a5df77f88dce491112832a30518711656
--- /dev/null
+++ b/packages/contracts/src/hub.ts
@@ -0,0 +1,116 @@
+import * as Schema from "effect/Schema";
+import { AuthEnvironmentScopes } from "./auth.ts";
+import { EnvironmentId, ThreadId } from "./baseSchemas.ts";
+import { ExecutionEnvironmentDescriptor } from "./environment.ts";
+import { OrchestrationV2Command, OrchestrationV2ShellSnapshot } from "./orchestrationV2.ts";
+
+export const HUB_THREAD_ORGANIZATION_FIELDS = [
+ "settledOverride",
+ "settledAt",
+ "unsettledAt",
+ "snoozedUntil",
+ "snoozedAt",
+ "pinnedAt",
+ "pinOrderKey",
+ "activeOrderKey",
+ "autoSettleDisabledAt",
+] as const;
+
+const organizationCommandTypes = [
+ "thread.settle",
+ "thread.unsettle",
+ "thread.snooze",
+ "thread.unsnooze",
+ "thread.pin",
+ "thread.unpin",
+ "thread.pin.reorder",
+ "thread.active.reorder",
+ "thread.auto-settle.set",
+] as const;
+
+export const HubOrganizationCommand = OrchestrationV2Command.pipe(
+ Schema.refine(
+ (
+ command,
+ ): command is Extract<
+ OrchestrationV2Command,
+ { readonly type: (typeof organizationCommandTypes)[number] }
+ > => organizationCommandTypes.some((type) => type === command.type),
+ ),
+);
+export type HubOrganizationCommand = typeof HubOrganizationCommand.Type;
+
+export const HubInboxCommand = Schema.Struct({
+ environmentId: EnvironmentId,
+ command: HubOrganizationCommand,
+});
+export type HubInboxCommand = typeof HubInboxCommand.Type;
+
+export const HubInboxEnvironment = Schema.Struct({
+ environmentId: EnvironmentId,
+ snapshot: OrchestrationV2ShellSnapshot,
+ pendingThreadIds: Schema.Array(ThreadId),
+});
+export type HubInboxEnvironment = typeof HubInboxEnvironment.Type;
+
+export const HubInbox = Schema.Struct({ environments: Schema.Array(HubInboxEnvironment) });
+export type HubInbox = typeof HubInbox.Type;
+
+export const HubDirectory = Schema.Struct({
+ hubEnvironmentId: EnvironmentId,
+ environments: Schema.Array(
+ Schema.Struct({
+ environmentId: EnvironmentId,
+ label: Schema.NonEmptyString,
+ httpBaseUrl: Schema.NonEmptyString,
+ scopes: AuthEnvironmentScopes,
+ descriptor: ExecutionEnvironmentDescriptor,
+ }),
+ ),
+});
+export type HubDirectory = typeof HubDirectory.Type;
+
+export const HubManagementRequest = Schema.Union([
+ Schema.Struct({ type: Schema.Literal("hub.configure"), publicUrl: Schema.NonEmptyString }),
+ Schema.Struct({ type: Schema.Literal("hub.status") }),
+ Schema.Struct({
+ type: Schema.Literal("hub.enroll"),
+ url: Schema.NonEmptyString,
+ environmentId: EnvironmentId,
+ tokenFile: Schema.NonEmptyString,
+ }),
+ Schema.Struct({ type: Schema.Literal("hub.remove"), environmentId: EnvironmentId }),
+]);
+export type HubManagementRequest = typeof HubManagementRequest.Type;
+
+export class HubError extends Schema.TaggedError()("HubError", {
+ reason: Schema.Literals([
+ "configuration",
+ "address",
+ "identity",
+ "credential",
+ "not-found",
+ "upstream",
+ "storage",
+ ]),
+ cause: Schema.optionalKey(Schema.Redacted(Schema.Defect())),
+}) {
+ override get message(): string {
+ switch (this.reason) {
+ case "configuration":
+ return "Configure the server's public URL before enrolling machines.";
+ case "address":
+ return "Use an HTTP or HTTPS address without a password, query, or fragment.";
+ case "identity":
+ return "The machine identity changed. Check its address before enrolling it.";
+ case "credential":
+ return "The machine access grant is unavailable. Enroll it with a new grant.";
+ case "not-found":
+ return "This machine was removed. Refresh the server's machine list.";
+ case "upstream":
+ return "The machine is unreachable. Check its private network connection.";
+ case "storage":
+ return "The server could not save its machines. Check its storage permissions.";
+ }
+ }
+}
diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts
index 8e385387b0ec78716d0cf9d1d6e790fc63addc8e..58f832664ae17e71ae297acd7bdfc8d21fd0d3ad 100644
--- a/packages/contracts/src/index.ts
+++ b/packages/contracts/src/index.ts
@@ -70,3 +70,9 @@ export * from "./scriptProgress.ts";
export * from "./uiSettings.ts";
export * from "./sharedAccounts.ts";
+
+export * from "./transcriptArchive.ts";
+export * from "./hub.ts";
+export * from "./hostedAuth.ts";
+export * from "./desktopHostedAuth.ts";
+export * from "./outbound.ts";
diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts
index f05f11c1d1a8f04b9429c2d7a05701149bbb4ac3..b150dbb62ecc64c9dbd211d17a0f88c9bdda6d96 100644
--- a/packages/contracts/src/ipc.ts
+++ b/packages/contracts/src/ipc.ts
@@ -1,3 +1,4 @@
+import type { DesktopHostedAuthState } from "./desktopHostedAuth.ts";
import * as Schema from "effect/Schema";
import { SnapShotSource } from "./chatAttachment.ts";
@@ -1127,6 +1128,11 @@ export const SystemSettingsPaneSchema = Schema.Literals(["full-disk-access"]);
export type SystemSettingsPane = typeof SystemSettingsPaneSchema.Type;
export interface DesktopBridge {
+ beginHostedAuth?: (server: string) => Promise;
+ getHostedAuthState?: () => Promise;
+ getHostedAccessToken?: (server: string) => Promise;
+ cancelHostedAuth?: () => Promise;
+ disconnectHostedAuth?: () => Promise;
supportsLocalTerminal?: (input: {
readonly httpBaseUrl: string;
readonly environmentId: string;
diff --git a/packages/contracts/src/outbound.ts b/packages/contracts/src/outbound.ts
new file mode 100644
index 0000000000000000000000000000000000000000..05bb39db2fe7a26d3f62c77a5cccc91a9cb5425f
--- /dev/null
+++ b/packages/contracts/src/outbound.ts
@@ -0,0 +1,32 @@
+import * as Schema from "effect/Schema";
+import { AuthEnvironmentScopes } from "./auth.ts";
+import { ExecutionEnvironmentDescriptor } from "./environment.ts";
+
+export const OutboundSourceMessage = Schema.Union([
+ Schema.Struct({ type: Schema.Literal("register"), descriptor: ExecutionEnvironmentDescriptor }),
+ Schema.Struct({
+ type: Schema.Literal("grant"),
+ id: Schema.String.check(Schema.isUUID()),
+ token: Schema.NonEmptyString.check(Schema.isMaxLength(16_384)),
+ }),
+ Schema.Struct({ type: Schema.Literal("pong") }),
+]);
+export type OutboundSourceMessage = typeof OutboundSourceMessage.Type;
+
+export const OutboundHubMessage = Schema.Union([
+ Schema.Struct({ type: Schema.Literal("connect"), id: Schema.String.check(Schema.isUUID()) }),
+ Schema.Struct({
+ type: Schema.Literal("grant"),
+ id: Schema.String.check(Schema.isUUID()),
+ scopes: AuthEnvironmentScopes,
+ }),
+ Schema.Struct({ type: Schema.Literal("ping") }),
+]);
+export type OutboundHubMessage = typeof OutboundHubMessage.Type;
+
+export const HostedSourceConnection = Schema.Struct({
+ serverUrl: Schema.NonEmptyString.check(Schema.isMaxLength(2_048)),
+ authorization: Schema.NonEmptyString.check(Schema.isMaxLength(16_384)),
+ expiresAt: Schema.DateTimeUtc,
+});
+export type HostedSourceConnection = typeof HostedSourceConnection.Type;
diff --git a/packages/contracts/src/transcriptArchive.ts b/packages/contracts/src/transcriptArchive.ts
new file mode 100644
index 0000000000000000000000000000000000000000..f5fb6a224690fa0412d1e153bc15495ad10391ab
--- /dev/null
+++ b/packages/contracts/src/transcriptArchive.ts
@@ -0,0 +1,76 @@
+import * as Schema from "effect/Schema";
+import { OrchestrationV2ThreadShell } from "./orchestrationV2.ts";
+import { AssetResource } from "./assets.ts";
+import { OrchestrationProjectShell } from "./orchestrationProject.ts";
+import { EnvironmentId, ThreadId } from "./baseSchemas.ts";
+
+export const ArchiveObjectHash = Schema.String.check(Schema.isPattern(/^[a-f0-9]{64}$/u));
+export const ArchiveObjectReceipt = Schema.Struct({
+ hash: ArchiveObjectHash,
+ size: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
+});
+export const ArchiveManagementRequest = Schema.Union([
+ Schema.Struct({ type: Schema.Literal("archive.status") }),
+ Schema.Struct({ type: Schema.Literal("archive.retry"), threadId: Schema.optionalKey(ThreadId) }),
+]);
+export type ArchiveManagementRequest = typeof ArchiveManagementRequest.Type;
+export const ArchiveStatus = Schema.Struct({
+ enabled: Schema.Boolean,
+ offloaded: Schema.Int,
+ pending: Schema.Int,
+ queue: Schema.Array(Schema.Struct({ threadId: ThreadId, nextAttemptAt: Schema.Number })),
+ storage: Schema.Struct({
+ allocatedDatabaseBytes: Schema.Number,
+ attachmentsBytes: Schema.Number,
+ nativeProviderLogBytes: Schema.Number,
+ }),
+ lastReclamation: Schema.NullOr(
+ Schema.Struct({
+ beforeAllocatedBytes: Schema.Number,
+ afterAllocatedBytes: Schema.Number,
+ }),
+ ),
+ failures: Schema.Array(
+ Schema.Struct({ threadId: ThreadId, attempts: Schema.Int, reason: Schema.String }),
+ ),
+});
+export type ArchiveStatus = typeof ArchiveStatus.Type;
+
+export const ArchiveAssetCreateUrlInput = Schema.Struct({
+ environmentId: EnvironmentId,
+ resource: AssetResource,
+});
+
+export const ArchivePublication = Schema.Struct({
+ environmentId: EnvironmentId,
+ manifestHash: ArchiveObjectHash,
+ project: OrchestrationProjectShell,
+});
+export type ArchivePublication = typeof ArchivePublication.Type;
+export const ArchiveCatalogEntry = Schema.Struct({
+ environmentId: EnvironmentId,
+ manifestHash: ArchiveObjectHash,
+ throughSequence: Schema.Number,
+ thread: OrchestrationV2ThreadShell,
+ project: OrchestrationProjectShell,
+});
+export type ArchiveCatalogEntry = typeof ArchiveCatalogEntry.Type;
+export const ArchiveCatalogResult = Schema.Struct({ entries: Schema.Array(ArchiveCatalogEntry) });
+
+export class ArchiveError extends Schema.TaggedError()("ArchiveError", {
+ reason: Schema.Literals(["unavailable", "integrity", "changed", "storage"]),
+ cause: Schema.optionalKey(Schema.Redacted(Schema.Defect())),
+}) {
+ override get message() {
+ switch (this.reason) {
+ case "unavailable":
+ return "The transcript archive is unavailable. Reconnect to its server and retry.";
+ case "integrity":
+ return "The archived transcript failed verification. Retry or check the archive storage.";
+ case "changed":
+ return "The chat changed while it was being archived. Its local copy was retained.";
+ case "storage":
+ return "The transcript archive could not be saved. Check its mounted storage and retry.";
+ }
+ }
+}
diff --git a/packages/shared/package.json b/packages/shared/package.json
index b453dad3f3a998b64e8fe4252cebaf146d6800ab..f3a07e22b032c4f704d1d380d658648a7d0bf46b 100644
--- a/packages/shared/package.json
+++ b/packages/shared/package.json
@@ -3,6 +3,10 @@
"private": true,
"type": "module",
"exports": {
+ "./hostedServerUrl": {
+ "types": "./src/hostedServerUrl.ts",
+ "import": "./src/hostedServerUrl.ts"
+ },
"./forkUpdateDirectory": {
"types": "./src/forkUpdateDirectory.ts",
"import": "./src/forkUpdateDirectory.ts"
diff --git a/packages/shared/src/advertisedEndpoint.ts b/packages/shared/src/advertisedEndpoint.ts
index 314d8272c81652a35e593b7a1df7f4f7d63aa088..007c3bb7d0d3f3eb8f77866dc2f616965213a292 100644
--- a/packages/shared/src/advertisedEndpoint.ts
+++ b/packages/shared/src/advertisedEndpoint.ts
@@ -33,12 +33,26 @@ export function normalizeHttpBaseUrl(rawValue: string): string {
throw new Error(`Endpoint must use HTTP or HTTPS. Received ${url.protocol}`);
}
- url.pathname = "/";
+ url.pathname = `${url.pathname.replace(/\/+$/, "")}/`;
url.search = "";
url.hash = "";
return url.toString();
}
+export function environmentEndpointUrl(baseUrl: string, path: string): string {
+ const url = new URL(baseUrl);
+ url.pathname = `${url.pathname.replace(/\/+$/, "")}/`;
+ url.search = "";
+ url.hash = "";
+ return new URL(path.replace(/^\/(?!\/)/, ""), url).toString();
+}
+
+/** Canonical bases end in a slash; existing socket endpoints already include their route. */
+export function environmentWebSocketUrl(wsBaseUrl: string): string {
+ const url = new URL(wsBaseUrl);
+ return url.pathname.endsWith("/") ? environmentEndpointUrl(wsBaseUrl, "/ws") : url.toString();
+}
+
export function deriveWsBaseUrl(httpBaseUrl: string): string {
const url = new URL(normalizeHttpBaseUrl(httpBaseUrl));
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
diff --git a/packages/shared/src/devProxy.ts b/packages/shared/src/devProxy.ts
index 709a5449c22e989d2e6211b44a8f870e10480272..3c90b9f58e64670911ff08039ffc8a339b2da35c 100644
--- a/packages/shared/src/devProxy.ts
+++ b/packages/shared/src/devProxy.ts
@@ -8,7 +8,14 @@
* prefix only Vite knows gets answered with index.html; a prefix only the
* server knows redirect-loops through the proxy.
*/
-export const DEV_PROXIED_PATH_PREFIXES = ["/api", "/oauth", "/.well-known", "/ws", "/mcp"] as const;
+export const DEV_PROXIED_PATH_PREFIXES = [
+ "/api",
+ "/oauth",
+ "/.well-known",
+ "/ws",
+ "/mcp",
+ "/environments",
+] as const;
/**
* Prefixes the proxy must forward with the browser's own Host. MCP OAuth
@@ -22,6 +29,7 @@ export const DEV_PROXIED_ORIGIN_PRESERVING_PREFIXES: ReadonlySet = new S
"/.well-known",
"/mcp",
"/ws",
+ "/environments",
]);
export function isDevProxiedPath(pathname: string): boolean {
diff --git a/packages/shared/src/hostedServerUrl.ts b/packages/shared/src/hostedServerUrl.ts
new file mode 100644
index 0000000000000000000000000000000000000000..74c988c1e5932328382247d4ba939e753c57b002
--- /dev/null
+++ b/packages/shared/src/hostedServerUrl.ts
@@ -0,0 +1,14 @@
+export function normalizeHostedServer(value: string): string {
+ const url = new URL(value);
+ if (
+ url.protocol !== "https:" ||
+ url.username ||
+ url.password ||
+ url.search ||
+ url.hash ||
+ url.pathname !== "/"
+ ) {
+ throw new Error("Enter an HTTPS server address without a path.");
+ }
+ return url.origin;
+}
diff --git a/packages/shared/src/remote.test.ts b/packages/shared/src/remote.test.ts
index 5b7a9973652a5e1d261fd38df4fecca8c87a40af..85f40aadb24d106fb64db6d2518cf53aa8fea376 100644
--- a/packages/shared/src/remote.test.ts
+++ b/packages/shared/src/remote.test.ts
@@ -21,6 +21,25 @@ describe("remote", () => {
});
});
+ it.each([
+ { pairingUrl: "https://hub.example/prefix/environments/one/pair#token=pairing-token" },
+ { pairingUrl: "https://hub.example/prefix/environments/one/#token=pairing-token" },
+ {
+ pairingUrl:
+ "https://app.example/pair?host=https%3A%2F%2Fhub.example%2Fprefix%2Fenvironments%2Fone%2F#token=pairing-token",
+ },
+ {
+ host: "wss://hub.example/prefix/environments/one///?old=1#fragment",
+ pairingCode: "pairing-token",
+ },
+ ])("preserves a proxied environment base through pairing: %o", (input) => {
+ expect(resolveRemotePairingTarget(input)).toEqual({
+ credential: "pairing-token",
+ httpBaseUrl: "https://hub.example/prefix/environments/one/",
+ wsBaseUrl: "wss://hub.example/prefix/environments/one/",
+ });
+ });
+
it("accepts pairing urls that still use a query token", () => {
expect(
resolveRemotePairingTarget({
diff --git a/packages/shared/src/remote.ts b/packages/shared/src/remote.ts
index 55698568fb9efe1e39a05a38760820cd4f671368..baf1d73915976eb4d38fcdbaf14aea5333340961 100644
--- a/packages/shared/src/remote.ts
+++ b/packages/shared/src/remote.ts
@@ -1,3 +1,4 @@
+import { deriveWsBaseUrl, normalizeHttpBaseUrl } from "./advertisedEndpoint.ts";
import * as Schema from "effect/Schema";
const PAIRING_TOKEN_PARAM = "token";
@@ -97,38 +98,9 @@ const normalizeRemoteBaseUrl = (
protocol: url.protocol,
});
}
- url.pathname = "/";
- url.search = "";
- url.hash = "";
return url;
};
-const toHttpBaseUrl = (url: URL): string => {
- const next = new URL(url.toString());
- if (next.protocol === "ws:") {
- next.protocol = "http:";
- } else if (next.protocol === "wss:") {
- next.protocol = "https:";
- }
- next.pathname = "/";
- next.search = "";
- next.hash = "";
- return next.toString();
-};
-
-const toWsBaseUrl = (url: URL): string => {
- const next = new URL(url.toString());
- if (next.protocol === "http:") {
- next.protocol = "ws:";
- } else if (next.protocol === "https:") {
- next.protocol = "wss:";
- }
- next.pathname = "/";
- next.search = "";
- next.hash = "";
- return next.toString();
-};
-
export interface ResolvedRemotePairingTarget {
readonly credential: string;
readonly httpBaseUrl: string;
@@ -211,8 +183,8 @@ export const resolveRemotePairingTarget = (input: {
);
return {
credential: hostedPairingRequest.token,
- httpBaseUrl: toHttpBaseUrl(hostedBackendUrl),
- wsBaseUrl: toWsBaseUrl(hostedBackendUrl),
+ httpBaseUrl: normalizeHttpBaseUrl(hostedBackendUrl.toString()),
+ wsBaseUrl: deriveWsBaseUrl(hostedBackendUrl.toString()),
};
}
@@ -220,10 +192,11 @@ export const resolveRemotePairingTarget = (input: {
if (!credential) {
throw new RemotePairingTokenMissingError({ host: url.host });
}
+ url.pathname = url.pathname.replace(/\/pair\/?$/, "/");
return {
credential,
- httpBaseUrl: toHttpBaseUrl(url),
- wsBaseUrl: toWsBaseUrl(url),
+ httpBaseUrl: normalizeHttpBaseUrl(url.toString()),
+ wsBaseUrl: deriveWsBaseUrl(url.toString()),
};
}
@@ -239,7 +212,7 @@ export const resolveRemotePairingTarget = (input: {
return {
credential: pairingCode,
- httpBaseUrl: toHttpBaseUrl(normalizedHost),
- wsBaseUrl: toWsBaseUrl(normalizedHost),
+ httpBaseUrl: normalizeHttpBaseUrl(normalizedHost.toString()),
+ wsBaseUrl: deriveWsBaseUrl(normalizedHost.toString()),
};
};