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
2 changes: 2 additions & 0 deletions apps/server/src/persistence/Migrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ import Migration0057 from "./Migrations/057_ScheduledTaskWebhooks.ts";
import Migration0058 from "./Migrations/058_WebhookRelayDeliveries.ts";
import Migration0059 from "./Migrations/059_McpAppModelContext.ts";
import Migration0060 from "./Migrations/060_ThreadSnapshotWindowIndexes.ts";
import Migration0061 from "./Migrations/061_RevokeLegacyCloudConnectSessions.ts";

/**
* Migration loader with all migrations defined inline.
Expand Down Expand Up @@ -148,6 +149,7 @@ export const migrationEntries = [
[58, "WebhookRelayDeliveries", Migration0058],
[59, "McpAppModelContext", Migration0059],
[60, "ThreadSnapshotWindowIndexes", Migration0060],
[61, "RevokeLegacyCloudConnectSessions", Migration0061],
] as const;

export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ layer("055_OrchestrationV2", (it) => {
Effect.sync(() => {
assert.deepStrictEqual(
migrationEntries.map(([id]) => id),
Array.from({ length: 60 }, (_, index) => index + 1),
Array.from({ length: 61 }, (_, index) => index + 1),
);
}),
);
Expand All @@ -32,6 +32,7 @@ layer("055_OrchestrationV2", (it) => {
[58, "WebhookRelayDeliveries"],
[59, "McpAppModelContext"],
[60, "ThreadSnapshotWindowIndexes"],
[61, "RevokeLegacyCloudConnectSessions"],
]);
assert.deepStrictEqual(yield* runMigrations(), []);

Expand All @@ -58,6 +59,7 @@ layer("055_OrchestrationV2", (it) => {
{ migration_id: 58, name: "WebhookRelayDeliveries" },
{ migration_id: 59, name: "McpAppModelContext" },
{ migration_id: 60, name: "ThreadSnapshotWindowIndexes" },
{ migration_id: 61, name: "RevokeLegacyCloudConnectSessions" },
]);

const tables = yield* sql<{ readonly name: string }>`
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { assert, it } from "@effect/vitest";
import * as Effect from "effect/Effect";
import * as SqlClient from "effect/sql/SqlClient";
import * as TestClock from "effect/testing/TestClock";
import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";

import { runMigrations } from "../Migrations.ts";

const now = "2026-10-08T20:00:00.000Z";
// Minted for a current client by a server from before the split.
const preSplit = [
"orchestration:read",
"orchestration:operate",
"terminal:operate",
"review:write",
"relay:read",
];
// An older client narrows its request to the scopes it knows.
const narrowedPreSplit = [
"orchestration:read",
"orchestration:operate",
"terminal:operate",
"relay:read",
];
const standard = [
"orchestration:read",
"orchestration:operate",
"terminal:operate",
"filesystem:read",
"filesystem:write",
"relay:read",
];

it.layer(NodeSqliteClient.layer({ filename: ":memory:" }))(
"061_RevokeLegacyCloudConnectSessions",
(it) => {
it.effect("revokes only live T3 Connect sessions minted before the scope split", () =>
Effect.gen(function* () {
const sql = yield* SqlClient.SqlClient;
yield* runMigrations({ toMigrationInclusive: 60 });
const insert = (
id: string,
subject: string,
scopes: ReadonlyArray<string>,
expiresAt: string,
) =>
sql`
INSERT INTO auth_sessions (session_id, subject, scopes, method, issued_at, expires_at)
VALUES (${id}, ${subject}, ${JSON.stringify(scopes)}, 'dpop-access-token',
'2026-10-08T19:39:38.320Z', ${expiresAt})
`;
yield* insert("legacy-connect", "cloud-connect", preSplit, "2026-10-08T20:39:38.320Z");
yield* insert(
"narrowed-connect",
"cloud-connect",
narrowedPreSplit,
"2026-10-08T20:39:38.320Z",
);
yield* insert("current-connect", "cloud-connect", standard, "2026-10-08T20:39:38.320Z");
yield* insert("expired-connect", "cloud-connect", preSplit, "2026-10-08T19:00:00.000Z");
// A paired client keeps the grant the user chose, even a pre-split one.
yield* insert("paired", "one-time-token", preSplit, "2026-11-07T19:39:38.320Z");

yield* TestClock.setTime(Date.parse(now));
yield* runMigrations({ toMigrationInclusive: 61 });

const rows = yield* sql<{ readonly sessionId: string; readonly revokedAt: string | null }>`
SELECT session_id AS "sessionId", revoked_at AS "revokedAt"
FROM auth_sessions ORDER BY session_id
`;
assert.deepStrictEqual(rows, [
{ sessionId: "current-connect", revokedAt: null },
{ sessionId: "expired-connect", revokedAt: null },
{ sessionId: "legacy-connect", revokedAt: now },
{ sessionId: "narrowed-connect", revokedAt: now },
{ sessionId: "paired", revokedAt: null },
]);
}),
);
},
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import * as DateTime from "effect/DateTime";
import * as Effect from "effect/Effect";
import * as SqlClient from "effect/sql/SqlClient";

// The scope vocabulary before permissions were split. Frozen here so later
// scope changes cannot alter which sessions this migration matched.
const preSplitScopes = JSON.stringify([
"orchestration:read",
"orchestration:operate",
"terminal:operate",
"review:write",
"access:read",
"access:write",
"relay:read",
"relay:write",
]);

/**
* T3 Connect sessions carry whatever grant the server chose when it minted
* them, not one the user picked. Sessions minted before the split hold only
* the broad scopes, which no longer imply file access and the other newly
* separated permissions. Revoking them makes clients mint a replacement through
* the normal cloud flow, which receives the current standard grant.
*/
export default Effect.gen(function* () {
const sql = yield* SqlClient.SqlClient;
const now = DateTime.formatIso(yield* DateTime.now);

yield* sql`
UPDATE auth_sessions
SET revoked_at = ${now}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
WHERE subject = 'cloud-connect'
AND revoked_at IS NULL
AND expires_at > ${now}
AND NOT EXISTS (
SELECT 1 FROM json_each(auth_sessions.scopes)
WHERE value NOT IN (SELECT value FROM json_each(${preSplitScopes}))
)
`;
});
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ describe("V2 preview upgrade", () => {
[58, "WebhookRelayDeliveries"],
[59, "McpAppModelContext"],
[60, "ThreadSnapshotWindowIndexes"],
[61, "RevokeLegacyCloudConnectSessions"],
]);
assert.deepStrictEqual(yield* runMigrations(), []);
assert.deepStrictEqual(yield* sql`SELECT * FROM orchestration_v2_legacy_imports`, imports);
Expand Down Expand Up @@ -124,6 +125,7 @@ describe("V2 preview upgrade", () => {
[58, "WebhookRelayDeliveries"],
[59, "McpAppModelContext"],
[60, "ThreadSnapshotWindowIndexes"],
[61, "RevokeLegacyCloudConnectSessions"],
]);
}).pipe(Effect.provide(NodeSqliteClient.layer({ filename: ":memory:" }))),
);
Expand Down
8 changes: 7 additions & 1 deletion docs/internals/environment-auth.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,13 @@ group's `RpcScopeAuthorization` middleware checks it before any handler runs.
Scope changes must not prevent older clients from connecting. Token exchange
intersects recognized requests with the pairing grant; retired and unknown names
are dropped. A request with no granted scopes fails before consuming the link.
Stored credentials are never expanded when scopes split.
Stored credentials are never expanded when scopes split. A paired session keeps
its recorded grant. A T3 Connect session holding only pre-split scopes is instead
revoked once by
[migration 61](../../apps/server/src/persistence/Migrations/061_RevokeLegacyCloudConnectSessions.ts),
because the server chose its grant. The client mints a replacement through the
normal cloud flow and receives whatever it requests from the current standard
grant, so an older client that asks for pre-split scopes gets them back.

Auth responses keep `scopes` within the original wire vocabulary and include
`permissions` for the exact grant. New clients use `permissions` when present,
Expand Down
4 changes: 3 additions & 1 deletion docs/user/remote-access.md
Original file line number Diff line number Diff line change
Expand Up @@ -269,7 +269,9 @@ and the agent it runs can use Git however the environment allows.
Settings changes, provider management, and environment maintenance can be granted
separately from access administration. New standard pairings include these
permissions. Existing clients can stay connected after an update, but newly separated
features may require pairing again with the permissions they need. Older clients
features may require pairing again with the permissions they need. Devices
connected through T3 Connect do not need pairing again: once their T3 Code app is
up to date, they receive the new permissions on their own. Older clients
may show controls that the server denies. Create a fresh pairing link to change
a client's permissions.

Expand Down
Loading