Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 77 additions & 0 deletions apps/desktop/src/ssh/DesktopSshRemoteApi.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { assert, describe, it } from "@effect/vitest";
import { SshHttpBridgeError } from "@t3tools/ssh/errors";
import * as DateTime from "effect/DateTime";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http";
Expand Down Expand Up @@ -31,6 +32,13 @@ function makeLayer(
);
}

const authDescriptor = {
policy: "remote-reachable",
bootstrapMethods: ["one-time-token"],
sessionMethods: ["bearer-session-token"],
sessionCookieName: "t3_session",
} as const;

describe("DesktopSshRemoteApi", () => {
it.effect("fetches and decodes the remote environment descriptor", () => {
const requestUrls: string[] = [];
Expand Down Expand Up @@ -58,6 +66,75 @@ describe("DesktopSshRemoteApi", () => {
}).pipe(Effect.provide(layer));
});

it.effect("decodes auth timestamp strings returned by remote JSON endpoints", () => {
const expiresAt = "2026-06-26T13:15:36.023Z";
const requestUrls: string[] = [];
const layer = makeLayer((request) =>
Effect.sync(() => {
requestUrls.push(request.url);

switch (new URL(request.url).pathname) {
case "/api/auth/bootstrap/bearer":
return jsonResponse(request, {
authenticated: true,
role: "client",
sessionMethod: "bearer-session-token",
expiresAt,
sessionToken: "session-token",
});
case "/api/auth/session":
return jsonResponse(request, {
authenticated: true,
auth: authDescriptor,
role: "client",
sessionMethod: "bearer-session-token",
expiresAt,
});
case "/api/auth/ws-token":
return jsonResponse(request, {
token: "websocket-token",
expiresAt,
});
default:
return jsonResponse(request, { error: "unexpected request" }, 404);
}
}),
);

return Effect.gen(function* () {
const remoteApi = yield* DesktopSshRemoteApi.DesktopSshRemoteApi;

const bootstrap = yield* remoteApi.bootstrapBearerSession({
httpBaseUrl: "http://127.0.0.1:41773/",
credential: "pairing-token",
});
assert.equal(bootstrap.sessionToken, "session-token");
assert.equal(DateTime.formatIso(bootstrap.expiresAt), expiresAt);

const session = yield* remoteApi.fetchSessionState({
httpBaseUrl: "http://127.0.0.1:41773/",
bearerToken: bootstrap.sessionToken,
});
if (!session.expiresAt) {
throw new Error("expected authenticated session expiry");
}
assert.equal(DateTime.formatIso(session.expiresAt), expiresAt);

const websocket = yield* remoteApi.issueWebSocketToken({
httpBaseUrl: "http://127.0.0.1:41773/",
bearerToken: bootstrap.sessionToken,
});
assert.equal(websocket.token, "websocket-token");
assert.equal(DateTime.formatIso(websocket.expiresAt), expiresAt);

assert.deepEqual(requestUrls, [
"http://127.0.0.1:41773/api/auth/bootstrap/bearer",
"http://127.0.0.1:41773/api/auth/session",
"http://127.0.0.1:41773/api/auth/ws-token",
]);
}).pipe(Effect.provide(layer));
});

it.effect("wraps schema decode failures in a typed remote api error", () => {
const layer = makeLayer((request) =>
Effect.succeed(jsonResponse(request, { environmentId: "remote-env" })),
Expand Down
12 changes: 6 additions & 6 deletions apps/desktop/src/ssh/DesktopSshRemoteApi.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import {
AuthBearerBootstrapResult,
AuthSessionState,
AuthWebSocketTokenResult,
AuthBearerBootstrapJsonResult,
AuthSessionJsonState,
AuthWebSocketTokenJsonResult,
type AuthBearerBootstrapResult as AuthBearerBootstrapResultType,
type AuthSessionState as AuthSessionStateType,
type AuthWebSocketTokenResult as AuthWebSocketTokenResultType,
Expand Down Expand Up @@ -58,9 +58,9 @@ export class DesktopSshRemoteApi extends Context.Service<
const decodeExecutionEnvironmentDescriptor = Schema.decodeUnknownEffect(
ExecutionEnvironmentDescriptor,
);
const decodeAuthBearerBootstrapResult = Schema.decodeUnknownEffect(AuthBearerBootstrapResult);
const decodeAuthSessionState = Schema.decodeUnknownEffect(AuthSessionState);
const decodeAuthWebSocketTokenResult = Schema.decodeUnknownEffect(AuthWebSocketTokenResult);
const decodeAuthBearerBootstrapResult = Schema.decodeUnknownEffect(AuthBearerBootstrapJsonResult);
const decodeAuthSessionState = Schema.decodeUnknownEffect(AuthSessionJsonState);
const decodeAuthWebSocketTokenResult = Schema.decodeUnknownEffect(AuthWebSocketTokenJsonResult);

const mapError =
(operation: DesktopSshRemoteApiOperation) =>
Expand Down
24 changes: 24 additions & 0 deletions packages/contracts/src/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,12 +122,27 @@ export const AuthBearerBootstrapResult = Schema.Struct({
});
export type AuthBearerBootstrapResult = typeof AuthBearerBootstrapResult.Type;

export const AuthBearerBootstrapJsonResult = Schema.Struct({
authenticated: Schema.Literal(true),
role: AuthSessionRole,
sessionMethod: Schema.Literal("bearer-session-token"),
expiresAt: Schema.DateTimeUtcFromString,
sessionToken: TrimmedNonEmptyString,
});
export type AuthBearerBootstrapJsonResult = typeof AuthBearerBootstrapJsonResult.Type;

export const AuthWebSocketTokenResult = Schema.Struct({
token: TrimmedNonEmptyString,
expiresAt: Schema.DateTimeUtc,
});
export type AuthWebSocketTokenResult = typeof AuthWebSocketTokenResult.Type;

export const AuthWebSocketTokenJsonResult = Schema.Struct({
token: TrimmedNonEmptyString,
expiresAt: Schema.DateTimeUtcFromString,
});
export type AuthWebSocketTokenJsonResult = typeof AuthWebSocketTokenJsonResult.Type;

export const AuthPairingCredentialResult = Schema.Struct({
id: TrimmedNonEmptyString,
credential: TrimmedNonEmptyString,
Expand Down Expand Up @@ -264,3 +279,12 @@ export const AuthSessionState = Schema.Struct({
expiresAt: Schema.optionalKey(Schema.DateTimeUtc),
});
export type AuthSessionState = typeof AuthSessionState.Type;

export const AuthSessionJsonState = Schema.Struct({
authenticated: Schema.Boolean,
auth: ServerAuthDescriptor,
role: Schema.optionalKey(AuthSessionRole),
sessionMethod: Schema.optionalKey(ServerAuthSessionMethod),
expiresAt: Schema.optionalKey(Schema.DateTimeUtcFromString),
});
export type AuthSessionJsonState = typeof AuthSessionJsonState.Type;
Loading