Skip to content
Open
Show file tree
Hide file tree
Changes from 4 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
20 changes: 13 additions & 7 deletions apps/server/src/auth/SessionStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -988,14 +988,20 @@ const make = Effect.gen(function* () {
revokedAt,
})
.pipe(Effect.mapError((cause) => new SessionRevocationError({ sessionId, cause })));
if (revoked) {
yield* Ref.update(connectedSessionsRef, (current) => {
const next = new Map(current);
next.delete(sessionId);
return next;
});
yield* emitRemoved(sessionId);
if (!revoked) {
// Another process, such as the CLI's database fallback, can revoke the row
// without reaching this process's open connections. Signal them anyway.
const row = yield* authSessions
.getById({ sessionId })
.pipe(Effect.mapError((cause) => new SessionRevocationError({ sessionId, cause })));
if (Option.isNone(row)) return false;
}
yield* Ref.update(connectedSessionsRef, (current) => {
const next = new Map(current);
next.delete(sessionId);
return next;
});
yield* emitRemoved(sessionId);
return revoked;
},
);
Expand Down
70 changes: 64 additions & 6 deletions apps/server/src/cli/auth.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,19 @@
import { AuthSessionId, AuthStandardClientScopes } from "@t3tools/contracts";
import {
AuthAccessWriteScope,
AuthSessionId,
AuthStandardClientScopes,
EnvironmentHttpApi,
} from "@t3tools/contracts";
import * as Console from "effect/Console";
import * as Duration from "effect/Duration";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
import * as References from "effect/References";
import * as Schema from "effect/Schema";
import { Argument, Command, Flag, GlobalFlag } from "effect/cli";
import { FetchHttpClient } from "effect/http";
import * as HttpApiClient from "effect/http-api/HttpApiClient";

import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts";

Expand All @@ -16,6 +24,7 @@ import {
formatSessionList,
} from "../cliAuthFormat.ts";
import * as ServerConfig from "../config.ts";
import { isProcessAlive, readPersistedServerRuntimeState } from "../serverRuntimeState.ts";
import { authScopesFlag } from "./authScopes.ts";
import {
authLocationFlags,
Expand All @@ -24,9 +33,9 @@ import {
resolveCliAuthConfig,
} from "./config.ts";

const runWithEnvironmentAuth = Effect.fn("auth.runWithEnvironmentAuth")(function* <A, E>(
const runWithEnvironmentAuth = Effect.fn("auth.runWithEnvironmentAuth")(function* <A, E, R>(
flags: CliAuthLocationFlags,
run: (environmentAuth: EnvironmentAuth.EnvironmentAuth["Service"]) => Effect.Effect<A, E>,
run: (environmentAuth: EnvironmentAuth.EnvironmentAuth["Service"]) => Effect.Effect<A, E, R>,
options?: {
readonly quietLogs?: boolean;
},
Expand All @@ -39,8 +48,8 @@ const runWithEnvironmentAuth = Effect.fn("auth.runWithEnvironmentAuth")(function
return yield* run(environmentAuth);
}).pipe(
Effect.provide(
Layer.mergeAll(EnvironmentAuth.layerRuntime).pipe(
Layer.provide(ServerConfig.layer(config)),
Layer.mergeAll(EnvironmentAuth.layerRuntime, FetchHttpClient.layer).pipe(
Layer.provideMerge(ServerConfig.layer(config)),
Layer.provide(Layer.succeed(References.MinimumLogLevel, minimumLogLevel)),
),
),
Expand Down Expand Up @@ -221,6 +230,55 @@ const sessionListCommand = Command.make("list", {
),
);

const LIVE_SERVER_TIMEOUT = Duration.seconds(10);
// Bounds how long the helper session outlives a CLI killed before it can revoke it.
const LIVE_SERVER_SESSION_TTL = Duration.minutes(1);

// Database-only revocation cannot signal the running server's open sockets.
const revokeSession = Effect.fn("auth.revokeSession")(function* (
environmentAuth: EnvironmentAuth.EnvironmentAuth["Service"],
sessionId: AuthSessionId,
) {
const config = yield* ServerConfig.ServerConfig;
const runtimeState = yield* readPersistedServerRuntimeState(config.serverRuntimeStatePath);
if (Option.isSome(runtimeState) && isProcessAlive(runtimeState.value.pid)) {

@only21mil only21mil Nov 8, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note

🤖 Opus 5.5 replying on behalf of Victor.

Thanks, you're right: a live PID doesn't show that the recorded origin still belongs to the server that wrote server-runtime.json. Fixed in 01667fd: the CLI now only contacts the server instance that owns this T3 home.

  • Before it creates the helper session, revokeSession in apps/server/src/cli/auth.ts reads the origin's unauthenticated /.well-known/t3/environment descriptor (the endpoint t3 pair already probes). It goes on only when environmentId matches this home's environment-id. A mismatch, an unreadable descriptor or a timeout means no session is created and no Authorization header is sent. The CLI revokes in the database and prints the existing warning.
  • I used environment identity instead of process start time. Any server running for this home is the right target, and the ownership lock in serverOwnership.ts allows only one. It also needs no per-platform ps call.
  • Regression in apps/server/src/cli/pair.test.ts: "sends no credential when the recorded origin serves another T3 home". It records process.pid (alive) with an origin served by a listener that records every request and returns another home's descriptor. The test asserts the listener got exactly one request, GET /.well-known/t3/environment with no Authorization, the session is revoked locally, and the warning is printed. On the previous head the same test fails: the listener receives POST /api/auth/clients/revoke with a bearer. The positive socket-close test (its server now also serves this home's descriptor) and the unreachable-server test are unchanged in what they check.
  • Runtime check with the real CLI: before, the listener received POST /api/auth/clients/revoke authorization=present. After, it received only GET /.well-known/t3/environment authorization=absent. Against a real t3 serve for the same home, the revoke goes through the server with no warning.

On #23277: still open and still conflicts with this branch in both files, so the landing note in the description stands. I added that the rebase keeps this check.

const origin = runtimeState.value.origin;
const attempted = yield* Effect.result(
Effect.acquireUseRelease(
environmentAuth.issueSession({
ttl: LIVE_SERVER_SESSION_TTL,
scopes: [AuthAccessWriteScope],
subject: "auth-cli",
label: "t3 auth cli",
}),
(issued) =>
HttpApiClient.make(EnvironmentHttpApi, { baseUrl: origin }).pipe(
Effect.flatMap((client) =>
client.auth.revokeClient({
headers: { authorization: `Bearer ${issued.token}` },
payload: { sessionId },
}),
),
Effect.timeout(LIVE_SERVER_TIMEOUT),
),
(issued) =>
environmentAuth.revokeSession(issued.sessionId).pipe(Effect.ignore({ log: true })),
),
);
if (attempted._tag === "Success") {
return attempted.success.revoked;
}
yield* Effect.logDebug("Failed to revoke the session through the running server.", {
origin,
cause: attempted.failure,
});
yield* Console.error(
`Could not reach the T3 Code server at ${origin}. Connections already open with this session stay open until they close.`,
);
}
return yield* environmentAuth.revokeSession(sessionId);
});

const sessionRevokeCommand = Command.make("revoke", {
...authLocationFlags,
sessionId: Argument.String("session-id").pipe(
Expand All @@ -232,7 +290,7 @@ const sessionRevokeCommand = Command.make("revoke", {
Command.withHandler((flags) =>
runWithEnvironmentAuth(flags, (environmentAuth) =>
Effect.gen(function* () {
const revoked = yield* environmentAuth.revokeSession(flags.sessionId);
const revoked = yield* revokeSession(environmentAuth, flags.sessionId);
yield* Console.log(
revoked
? `Revoked session ${flags.sessionId}.\n`
Expand Down
147 changes: 145 additions & 2 deletions apps/server/src/cli/pair.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,24 +4,39 @@ import * as NodeFS from "node:fs";
import * as NodeOS from "node:os";
import * as NodePath from "node:path";

import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer";
import * as NodeServices from "@effect/platform-node/NodeServices";
import { AuthStandardClientScopes, ORCHESTRATION_PROTOCOL_VERSION } from "@t3tools/contracts";
import {
AuthStandardClientScopes,
EnvironmentHttpApi,
ORCHESTRATION_PROTOCOL_VERSION,
} from "@t3tools/contracts";
import * as NetService from "@t3tools/shared/Net";
import * as HostProcess from "@t3tools/shared/HostProcess";
import { assert, describe, expect, it } from "@effect/vitest";
import * as Cause from "effect/Cause";
import * as ConfigProvider from "effect/ConfigProvider";
import * as Context from "effect/Context";
import * as Deferred from "effect/Deferred";
import * as Effect from "effect/Effect";
import * as Exit from "effect/Exit";
import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
import * as Sink from "effect/Sink";
import * as Stream from "effect/Stream";
import { FetchHttpClient } from "effect/http";
import { FetchHttpClient, HttpRouter, HttpServer } from "effect/http";
import * as HttpApi from "effect/http-api/HttpApi";
import * as HttpApiBuilder from "effect/http-api/HttpApiBuilder";
import { ChildProcess, ChildProcessSpawner } from "effect/process";
import * as TestConsole from "effect/testing/TestConsole";
import { Command, CliError } from "effect/cli";

import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts";
import * as AuthHttp from "../auth/http.ts";
import * as SessionStore from "../auth/SessionStore.ts";
import { cli } from "../binCli.ts";
import * as ServerConfig from "../config.ts";
import {
SERVICE_LAUNCHER_CONTEXT_ENV,
SERVICE_LAUNCHER_PROTOCOL,
Expand All @@ -32,6 +47,7 @@ import {
persistServerRuntimeState,
type PersistedServerRuntimeState,
} from "../serverRuntimeState.ts";
import { resolveCliAuthConfig } from "./config.ts";
import { DevServerNotProxiableError, resolveTailscaleLocalTarget } from "./pair.ts";

import packageJson from "../../package.json" with { type: "json" };
Expand Down Expand Up @@ -489,6 +505,133 @@ describe("auth scope options", () => {
);
});

class AuthRoutesApi extends HttpApi.make("environment").add(EnvironmentHttpApi.groups.auth) {}

// A separate store must receive the removal event; a database read alone cannot wake open sockets.
const serveAuthRoutesWithWatchedSession = (baseDir: string) =>
Effect.gen(function* () {
const config = yield* resolveCliAuthConfig({ baseDir: Option.some(baseDir) }, Option.none());
const services = yield* Layer.build(
HttpRouter.serve(
HttpApiBuilder.layer(AuthRoutesApi).pipe(
Layer.provide(AuthHttp.layer),
Layer.provide(AuthHttp.layerAuthenticatedAuth),
),
{ disableListenLog: true, disableLogger: true },
).pipe(
Layer.provideMerge(EnvironmentAuth.layerRuntime),
Layer.provideMerge(ServerConfig.layer(config)),
Layer.provideMerge(NodeHttpServer.layerTest),
),
);
const origin = HttpServer.formatAddress(Context.get(services, HttpServer.HttpServer).address);
const sessions = Context.get(services, SessionStore.SessionStore);
const session = yield* sessions.issue({
subject: "cli-issued-session",
client: { label: "revoke-test", deviceType: "bot" },
});
const removed = yield* Deferred.make<void>();
yield* sessions.streamChanges.pipe(
Stream.filter(
(change) => change.type === "clientRemoved" && change.sessionId === session.sessionId,
),
Stream.runHead,
Effect.andThen(Deferred.succeed(removed, undefined)),
Effect.forkScoped({ startImmediately: true }),
);
const persistRunningServer = Effect.gen(function* () {
yield* persistServerRuntimeState({
path: config.serverRuntimeStatePath,
state: yield* makePersistedServerRuntimeState({
config: { host: "127.0.0.1", devUrl: undefined },
port: Number(new URL(origin).port),
}),
});
});
return { config, session, removed, persistRunningServer };
});

describe("auth session revoke", () => {
it.effect("signals the running server and cleans up its helper session", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-cli-auth-revoke-test-" });
const server = yield* serveAuthRoutesWithWatchedSession(baseDir);
yield* server.persistRunningServer;

const output = yield* captureStdout(
runCli(["auth", "session", "revoke", server.session.sessionId, "--base-dir", baseDir]),
);

assert.include(output, `Revoked session ${server.session.sessionId}.`);
assert.isTrue(yield* Deferred.isDone(server.removed));
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
const listed = yield* captureStdout(
runCli(["auth", "session", "list", "--base-dir", baseDir, "--json"]),
);
assert.deepEqual(JSON.parse(listed), []);
}).pipe(Effect.scoped, Effect.provide(layerCliRuntime)),
);

it.effect("signals the running server when retried after a database-only revoke", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const baseDir = yield* fs.makeTempDirectoryScoped({
prefix: "t3-cli-auth-revoke-retry-test-",
});
const server = yield* serveAuthRoutesWithWatchedSession(baseDir);
const revoke = provideCliTestLayers(
runCli(["auth", "session", "revoke", server.session.sessionId, "--base-dir", baseDir]),
);
yield* persistServerRuntimeState({
path: server.config.serverRuntimeStatePath,
state: { ...baseState, pid: process.pid, origin: "http://127.0.0.1:1" },
});
yield* revoke;
assert.isFalse(yield* Deferred.isDone(server.removed));

yield* server.persistRunningServer;
yield* revoke;

assert.isTrue(yield* Deferred.isDone(server.removed));
}).pipe(Effect.scoped, Effect.provide(layerCliRuntime)),
);

it.effect("still revokes in the database when the running server cannot be reached", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const baseDir = yield* fs.makeTempDirectoryScoped({
prefix: "t3-cli-auth-revoke-unreachable-test-",
});
const config = yield* resolveCliAuthConfig({ baseDir: Option.some(baseDir) }, Option.none());
yield* persistServerRuntimeState({
path: config.serverRuntimeStatePath,
state: { ...baseState, pid: process.pid, origin: "http://127.0.0.1:1" },
});
const issued = JSON.parse(
yield* captureStdout(runCli(["auth", "session", "issue", "--base-dir", baseDir, "--json"])),
) as { readonly sessionId: string };

const stderr = yield* provideCliTestLayers(
Effect.gen(function* () {
yield* runCli(["auth", "session", "revoke", issued.sessionId, "--base-dir", baseDir]);
return yield* TestConsole.errorLines;
}),
);
const listed = JSON.parse(
yield* captureStdout(runCli(["auth", "session", "list", "--base-dir", baseDir, "--json"])),
) as ReadonlyArray<{ readonly sessionId: string }>;

expect(stderr).toContain(
"Could not reach the T3 Code server at http://127.0.0.1:1. Connections already open with this session stay open until they close.",
);
assert.notInclude(
listed.map((entry) => entry.sessionId),
issued.sessionId,
);
}).pipe(Effect.scoped, Effect.provide(layerCliRuntime)),
);
});

describe("auth pairing create --base-url", () => {
it.effect.each(["192.168.1.5:3773", "localhost:3773", "ftp://localhost:3773"])(
"rejects %s before issuing a token",
Expand Down
Loading