Skip to content
Merged
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
15 changes: 12 additions & 3 deletions apps/mobile/src/features/settings/EnvironmentRoutesSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,10 @@ import {
isLearned,
} from "@t3tools/client-runtime/connection";
import { type EnvironmentId, sessionGrantsScope } from "@t3tools/contracts";
import { AUTH_SCOPE_OPTIONS } from "@t3tools/shared/authScopeOptions";
import {
AUTH_SCOPE_OPTIONS,
sessionPermissionsUnavailableMessage,
} from "@t3tools/shared/authScopeOptions";
import { AsyncResult } from "effect/reactivity";
import * as Option from "effect/Option";
import { useState } from "react";
Expand Down Expand Up @@ -163,8 +166,14 @@ export function EnvironmentRoutesSection({
</>
) : (
<Text className="text-sm text-foreground-muted">
Permissions not checked. Connect to this environment to view this session’s
permissions.
{sessionPermissionsUnavailableMessage({
connected: activeRouteId !== null,
failed: sessionResult._tag === "Failure",
rejected:
sessionResult._tag !== "Failure" &&
!sessionResult.waiting &&
session?.authenticated === false,
})}
</Text>
)}
</View>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
type ServerProvider,
} from "@t3tools/contracts";
import { settlePromise, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
import { sessionPermissionsUnavailableMessage } from "@t3tools/shared/authScopeOptions";
import * as Cause from "effect/Cause";
import * as Exit from "effect/Exit";
import { AsyncResult } from "effect/reactivity";
Expand Down Expand Up @@ -208,11 +209,14 @@ function EnvironmentDetail({ environmentId }: { readonly environmentId: Environm
</Text>
) : !allowed ? (
<Text className="px-2 text-sm text-foreground-muted">
{AsyncResult.isFailure(sessionResult)
? "Could not verify your permissions. Reconnect to try again."
: session === null
? "Checking permissions…"
: "This connection does not have permission to manage the environment."}
{AsyncResult.isFailure(sessionResult) || session === null || !session.authenticated
? sessionPermissionsUnavailableMessage({
connected,
failed: AsyncResult.isFailure(sessionResult),
rejected:
!AsyncResult.isFailure(sessionResult) && session?.authenticated === false,
})
: "This connection does not have permission to manage the environment."}
</Text>
) : null}
{error ? (
Expand Down
11 changes: 9 additions & 2 deletions apps/web/src/components/settings/SessionPermissions.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { type EnvironmentId, sessionGrantsScope } from "@t3tools/contracts";
import { AUTH_SCOPE_OPTIONS } from "@t3tools/shared/authScopeOptions";
import {
AUTH_SCOPE_OPTIONS,
sessionPermissionsUnavailableMessage,
} from "@t3tools/shared/authScopeOptions";
import { useEnvironmentSessionState } from "~/state/session";

export function SessionPermissions({
Expand Down Expand Up @@ -32,7 +35,11 @@ export function SessionPermissions({
</div>
) : (
<p className="py-3 text-xs text-muted-foreground">
Permissions not checked. Connect to this environment to view this session’s permissions.
{sessionPermissionsUnavailableMessage({
connected,
failed: session.hasError,
rejected: !session.hasError && !session.isPending && session.data?.authenticated === false,
})}
</p>
);
}
133 changes: 133 additions & 0 deletions packages/client-runtime/src/state/session.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import { describe, expect, it } from "@effect/vitest";
import { EnvironmentId, type AuthSessionState } from "@t3tools/contracts";
import * as Clock from "effect/Clock";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
import * as Queue from "effect/Queue";
import * as Stream from "effect/Stream";
import * as SubscriptionRef from "effect/SubscriptionRef";
import * as TestClock from "effect/testing/TestClock";
import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity";

import {
AVAILABLE_CONNECTION_STATE,
PrimaryConnectionTarget,
type PreparedConnection,
} from "../connection/model.ts";
import * as EnvironmentRegistry from "../connection/registry.ts";
import * as EnvironmentSupervisor from "../connection/supervisor.ts";
import * as RpcHttp from "../rpc/http.ts";
import type { RpcSession } from "../rpc/session.ts";
import { createEnvironmentSessionAtoms } from "./session.ts";

const TARGET = new PrimaryConnectionTarget({
environmentId: EnvironmentId.make("environment-1"),
label: "Test environment",
httpBaseUrl: "https://environment.example.test",
wsBaseUrl: "wss://environment.example.test",
});
const PREPARED: PreparedConnection = {
environmentId: TARGET.environmentId,
label: TARGET.label,
httpBaseUrl: "https://environment.example.test",
socketUrl: "wss://environment.example.test/ws",
httpAuthorization: { _tag: "Bearer", token: "bearer-token" },
target: TARGET,
};
const SESSION = {
authenticated: true,
auth: {
policy: "remote-reachable",
bootstrapMethods: [],
sessionMethods: [],
sessionCookieName: "t3_session",
},
scopes: ["orchestration:read"],
} satisfies AuthSessionState;

const rpcSession = (): RpcSession => ({ closed: Effect.never }) as unknown as RpcSession;

const makeHarness = Effect.fn("TestSessionState.makeHarness")(function* (
reply: (requestNumber: number) => Promise<Response>,
) {
let requestCount = 0;
const transport = yield* SubscriptionRef.make(Option.some(rpcSession()));
const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({
target: TARGET,
state: yield* SubscriptionRef.make(AVAILABLE_CONNECTION_STATE),
session: transport,
prepared: yield* SubscriptionRef.make(Option.some(PREPARED)),
connect: Effect.void,
disconnect: Effect.void,
retryNow: Effect.void,
} satisfies EnvironmentSupervisor.EnvironmentSupervisor["Service"]);
const followStream: EnvironmentRegistry.EnvironmentRegistry["Service"]["followStream"] = (
_environmentId,
stream,
) => Stream.provideService(stream, EnvironmentSupervisor.EnvironmentSupervisor, supervisor);
const runtime = Atom.runtime(
Layer.mergeAll(
Layer.succeed(
EnvironmentRegistry.EnvironmentRegistry,
EnvironmentRegistry.EnvironmentRegistry.of({
followStream,
} as unknown as EnvironmentRegistry.EnvironmentRegistry["Service"]),
),
RpcHttp.layerRemoteHttpClient(async () => reply(++requestCount)),
// The test's clock, so the retry wait moves with `TestClock.adjust`.
Layer.succeed(Clock.Clock, yield* Clock.Clock),
),
);
const registry = yield* Effect.acquireRelease(Effect.sync(AtomRegistry.make), (registry) =>
Effect.sync(() => registry.dispose()),
);
const results = yield* Queue.unbounded<AsyncResult.AsyncResult<AuthSessionState, unknown>>();
const atom = createEnvironmentSessionAtoms(runtime).sessionStateAtom(TARGET.environmentId);
const unsubscribe = registry.subscribe(atom, (result) => Queue.offerUnsafe(results, result), {
immediate: true,
});
yield* Effect.addFinalizer(() => Effect.sync(unsubscribe));
const nextSettled = Effect.fn("TestSessionState.nextSettled")(function* () {
for (;;) {
const result = yield* Queue.take(results);
if (!result.waiting && result._tag !== "Initial") return result;
}
});
return { transport, nextSettled };
});

const unreachable = () => Promise.reject(new TypeError("Network request failed"));

describe("environment session state", () => {
it.effect("fetches permissions again when the connection is re-established", () =>
Effect.gen(function* () {
const harness = yield* makeHarness((requestNumber) =>
requestNumber === 1 ? unreachable() : Promise.resolve(Response.json(SESSION)),
);
expect((yield* harness.nextSettled())._tag).toBe("Failure");

yield* SubscriptionRef.set(harness.transport, Option.some(rpcSession()));

expect(yield* harness.nextSettled()).toMatchObject({ _tag: "Success", value: SESSION });
}),
);

it.effect("retries a failed permission check with backoff that restarts per connection", () =>
Effect.gen(function* () {
const harness = yield* makeHarness((requestNumber) =>
requestNumber <= 3 ? unreachable() : Promise.resolve(Response.json(SESSION)),
);
expect((yield* harness.nextSettled())._tag).toBe("Failure");

yield* TestClock.adjust("2 seconds");
expect((yield* harness.nextSettled())._tag).toBe("Failure");

yield* SubscriptionRef.set(harness.transport, Option.some(rpcSession()));
expect((yield* harness.nextSettled())._tag).toBe("Failure");

yield* TestClock.adjust("2 seconds");
expect(yield* harness.nextSettled()).toMatchObject({ _tag: "Success", value: SESSION });
}),
);
});
57 changes: 51 additions & 6 deletions packages/client-runtime/src/state/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import type { PreparedConnection } from "../connection/model.ts";
import * as EnvironmentSupervisor from "../connection/supervisor.ts";
import * as ManagedRelay from "../relay/managedRelay.ts";
import type { RemoteEnvironmentRequestError } from "../rpc/http.ts";
import type { RpcSession } from "../rpc/session.ts";
import { safeErrorLogAttributes } from "../errors/safeLog.ts";
import { executeAuthenticatedEnvironmentHttpRequest } from "./environmentHttpAuth.ts";
import { followStreamInEnvironment } from "./environmentStreams.ts";
Expand All @@ -39,6 +40,8 @@ function initialConfigOption<E>(

const DEFAULT_SESSION_STATE_TIMEOUT_MS = 6_000;
const MAX_SESSION_STATE_TIMEOUT_MS = 60_000;
const SESSION_STATE_RETRY_BASE_MS = 2_000;
const SESSION_STATE_RETRY_MAX_MS = 60_000;

export const fetchEnvironmentSessionState = Effect.fn(
"clientRuntime.state.fetchEnvironmentSessionState",
Expand Down Expand Up @@ -136,13 +139,38 @@ function makeEnvironmentSessionAtoms<R, E>(
).pipe(Atom.withLabel(`environment-prepared-connection:${environmentId}`)),
);

const sessionStateAtom = Atom.family((environmentId: EnvironmentId) =>
runtime
const transportSessionAtom = Atom.family((environmentId: EnvironmentId) =>
runtime.atom(
followStreamInEnvironment(
environmentId,
Stream.unwrap(
EnvironmentSupervisor.EnvironmentSupervisor.pipe(
Effect.map((supervisor) => SubscriptionRef.changes(supervisor.session)),
),
),
),
{ initialValue: Option.none() },
),
);

const sessionStateAtom = Atom.family((environmentId: EnvironmentId) => {
let consecutiveFailures = 0;
let backoffConnection: RpcSession | null = null;
return runtime
.atom((get) => {
const prepared = Option.getOrNull(get(preparedConnectionValueAtom(environmentId)));
if (prepared === null) {
// Each established connection fetches anew, and a failure retries while it stays up,
// so a fetch that failed mid-reconnect cannot outlive the socket that recovered.
const connection = AsyncResult.getOrElse(get(transportSessionAtom(environmentId)), () =>
Option.none(),
);
if (prepared === null || Option.isNone(connection)) {
return Effect.never;
}
if (connection.value !== backoffConnection) {
backoffConnection = connection.value;
consecutiveFailures = 0;
}
return Effect.gen(function* () {
const signer = yield* Effect.serviceOption(ManagedRelay.ManagedRelayDpopSigner);
const remoteAuthorization = yield* Effect.serviceOption(
Expand All @@ -155,14 +183,31 @@ function makeEnvironmentSessionAtoms<R, E>(
signer,
remoteAuthorization,
}).pipe(Effect.provideService(HttpClient.HttpClient, client.value));
});
}).pipe(
Effect.tap(() =>
Effect.sync(() => {
consecutiveFailures = 0;
}),
),
Effect.tapError(() => {
const delayMs = Math.min(
SESSION_STATE_RETRY_BASE_MS * 2 ** consecutiveFailures,
SESSION_STATE_RETRY_MAX_MS,
);
consecutiveFailures += 1;
return Effect.sleep(delayMs).pipe(
Effect.andThen(Effect.sync(() => get.refreshSelf())),
Effect.forkScoped,
);
}),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
);
})
.pipe(
Atom.swr({ staleTime: 30_000, revalidateOnMount: true }),
Atom.setIdleTTL(5 * 60_000),
Atom.withLabel(`environment-session-state:${environmentId}`),
),
);
);
});

const sessionStateValueAtom = Atom.family((environmentId: EnvironmentId) =>
Atom.make(
Expand Down
16 changes: 16 additions & 0 deletions packages/shared/src/authScopeOptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,3 +104,19 @@ export const AUTH_SCOPE_OPTIONS: ReadonlyArray<{
description: "Change managed tunnel connectivity.",
},
];

/** Shown instead of the granted scopes while they are unknown, so every surface says the same thing. */
export function sessionPermissionsUnavailableMessage(input: {
readonly connected: boolean;
/** The check errored; the client retries it while connected. */
readonly failed: boolean;
/** The environment answered that this session is not signed in; retrying will not change that. */
readonly rejected: boolean;
}): string {
if (!input.connected) return "Connect to this environment to view this session’s permissions.";
if (input.rejected)
return "This environment did not accept your session. Reconnect to try again.";
return input.failed
? "Could not verify your permissions. Retrying automatically."
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
: "Checking permissions…";
}
Loading