Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/devpc-managed-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -47,3 +47,4 @@ jobs:
test -f apps/server/dist/bin.mjs
test -f apps/server/dist/client/index.html
grep -q '/_t3/' apps/server/dist/client/index.html
grep -q '/_devpc/ws-ticket' apps/server/dist/client/assets/*.js
4 changes: 4 additions & 0 deletions .github/workflows/devpc-managed-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,10 @@ jobs:
echo "Missing managed DevPC restart surface"
exit 1
}
grep -q "/_devpc/ws-ticket" artifacts/devpc-t3/dist/client/assets/*.js || {
echo "Missing managed DevPC WebSocket authorization"
exit 1
}
runtime_home="$(mktemp -d)"
HOME="$runtime_home/home" \
T3CODE_HOME="$runtime_home/t3code" \
Expand Down
5 changes: 4 additions & 1 deletion apps/mobile/src/connection/platform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,10 @@ const capabilitiesLayer = Layer.effectContext(
).pipe(
Context.add(
PrimaryEnvironmentAuth,
PrimaryEnvironmentAuth.of({ bearerToken: Effect.succeed(Option.none()) }),
PrimaryEnvironmentAuth.of({
bearerToken: Effect.succeed(Option.none()),
prepareWebSocketUrl: Effect.succeed,
}),
),
Context.add(
RelayDeviceIdentity,
Expand Down
13 changes: 13 additions & 0 deletions apps/web/src/connection/platform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ import {
} from "../environments/primary/target";
import { clearComposerDraftsEnvironment } from "../composerDraftStore";
import { isHostedStaticApp } from "../hostedPairing";
import { prepareManagedWebSocketUrl } from "../managedDevPc";
import { appAtomRegistry } from "../rpc/atomRegistry";
import { acknowledgeRpcRequest, trackRpcRequestSent } from "../rpc/requestLatencyState";
import {
Expand Down Expand Up @@ -215,6 +216,18 @@ const capabilitiesLayer = Layer.effectContext(
detail: `Could not load the desktop primary credential: ${String(cause)}`,
}),
}).pipe(Effect.map(Option.fromNullishOr)),
prepareWebSocketUrl: (socketUrl) =>
Effect.tryPromise({
try: () => prepareManagedWebSocketUrl(socketUrl),
catch: (cause) =>
new ConnectionTransientError({
reason: "network",
detail:
cause instanceof Error
? cause.message
: "Could not authorize the managed WebSocket connection.",
}),
}),
});
const ssh = SshEnvironmentGateway.of({
provision: Effect.fn("web.connectionPlatform.ssh.provision")(function* (target) {
Expand Down
54 changes: 54 additions & 0 deletions apps/web/src/managedDevPc.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,57 @@ describe("managed DevPC preview URLs", () => {
);
});
});

describe("managed DevPC WebSocket authorization", () => {
it("replaces a T3 ticket with a fresh same-origin gateway ticket", async () => {
vi.stubEnv("VITE_DEVPC_MANAGED", "1");
vi.resetModules();
vi.stubGlobal("window", {
location: { origin: "https://app.example.test" },
});
const fetchMock = vi.fn(async () =>
Response.json({ ticket: "gateway-ticket-that-is-long-enough" }),
);
vi.stubGlobal("fetch", fetchMock);

const { prepareManagedWebSocketUrl } = await import("./managedDevPc");
const resolved = await prepareManagedWebSocketUrl(
"wss://app.example.test/ws?wsTicket=local-one-time-ticket",
);

expect(resolved).toBe(
"wss://app.example.test/ws?gatewayTicket=gateway-ticket-that-is-long-enough",
);
expect(fetchMock).toHaveBeenCalledWith("/_devpc/ws-ticket", {
method: "POST",
credentials: "same-origin",
cache: "no-store",
headers: {
accept: "application/json",
"content-type": "application/json",
},
body: "{}",
});
});

it("requests a new ticket for every connection attempt", async () => {
vi.stubEnv("VITE_DEVPC_MANAGED", "1");
vi.resetModules();
vi.stubGlobal("window", {
location: { origin: "https://app.example.test" },
});
const fetchMock = vi
.fn()
.mockResolvedValueOnce(Response.json({ ticket: "first-gateway-ticket-credential" }))
.mockResolvedValueOnce(Response.json({ ticket: "second-gateway-ticket-credential" }));
vi.stubGlobal("fetch", fetchMock);

const { prepareManagedWebSocketUrl } = await import("./managedDevPc");
const first = await prepareManagedWebSocketUrl("wss://app.example.test/ws");
const second = await prepareManagedWebSocketUrl("wss://app.example.test/ws");

expect(first).toContain("gatewayTicket=first-gateway-ticket-credential");
expect(second).toContain("gatewayTicket=second-gateway-ticket-credential");
expect(fetchMock).toHaveBeenCalledTimes(2);
});
});
28 changes: 28 additions & 0 deletions apps/web/src/managedDevPc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export interface ManagedDevPcBootstrap {
export const isManagedDevPc = import.meta.env.VITE_DEVPC_MANAGED === "1";

const BOOTSTRAP_PATH = "/_devpc/bootstrap";
const WEBSOCKET_TICKET_PATH = "/_devpc/ws-ticket";

function updateBootstrapMessage(message: string, failed = false): void {
const root = document.getElementById("root");
Expand Down Expand Up @@ -89,6 +90,33 @@ export async function prepareManagedDevPc(): Promise<void> {
}
}

export async function prepareManagedWebSocketUrl(socketUrl: string): Promise<string> {
if (!isManagedDevPc) return socketUrl;

const response = await fetch(WEBSOCKET_TICKET_PATH, {
method: "POST",
credentials: "same-origin",
cache: "no-store",
headers: {
accept: "application/json",
"content-type": "application/json",
},
body: "{}",
});
if (!response.ok) {
throw new Error("The managed workspace connection could not be authorized.");
}
const payload = (await response.json()) as { ticket?: unknown };
if (typeof payload.ticket !== "string" || payload.ticket.length < 20) {
throw new Error("The managed workspace returned an invalid connection credential.");
}

const resolved = new URL(socketUrl, window.location.origin);
resolved.searchParams.delete("wsTicket");
resolved.searchParams.set("gatewayTicket", payload.ticket);
return resolved.toString();
}

export function resolveManagedPreviewUrl(port: number, path: string): string | null {
if (!isManagedDevPc) return null;
const template = window.__DEVPC_MANAGED_BOOTSTRAP__?.previewUrlTemplate;
Expand Down
1 change: 1 addition & 0 deletions packages/client-runtime/src/connection/resolver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,7 @@ const makeDependencies = Effect.fn("TestConnectionResolver.makeDependencies")((o
ClientCapabilities.PrimaryEnvironmentAuth,
ClientCapabilities.PrimaryEnvironmentAuth.of({
bearerToken: Effect.succeed(Option.fromNullishOr(options?.primaryBearerToken)),
prepareWebSocketUrl: Effect.succeed,
}),
),
Layer.succeed(
Expand Down
5 changes: 4 additions & 1 deletion packages/client-runtime/src/connection/resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,11 +63,12 @@ const makePrimaryBroker = Effect.fn("clientRuntime.connection.broker.makePrimary
) {
const bearerToken = yield* auth.bearerToken;
if (Option.isNone(bearerToken)) {
const socketUrl = yield* auth.prepareWebSocketUrl(primarySocketUrl(target));
return {
environmentId: target.environmentId,
label: target.label,
httpBaseUrl: target.httpBaseUrl,
socketUrl: primarySocketUrl(target),
socketUrl,
httpAuthorization: null,
target,
} satisfies PreparedConnection;
Expand All @@ -79,8 +80,10 @@ const makePrimaryBroker = Effect.fn("clientRuntime.connection.broker.makePrimary
wsBaseUrl: target.wsBaseUrl,
bearerToken: bearerToken.value,
});
const socketUrl = yield* auth.prepareWebSocketUrl(authorized.socketUrl);
return {
...authorized,
socketUrl,
target,
} satisfies PreparedConnection;
});
Expand Down
3 changes: 3 additions & 0 deletions packages/client-runtime/src/platform/capabilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,9 @@ export class PrimaryEnvironmentAuth extends Context.Service<
PrimaryEnvironmentAuth,
{
readonly bearerToken: Effect.Effect<Option.Option<string>, ConnectionAttemptError>;
readonly prepareWebSocketUrl: (
socketUrl: string,
) => Effect.Effect<string, ConnectionAttemptError>;
}
>()("@t3tools/client-runtime/platform/capabilities/PrimaryEnvironmentAuth") {}

Expand Down
Loading