Skip to content
Closed
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
70 changes: 70 additions & 0 deletions apps/server/src/auth/RpcAuthorization.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import {
AuthAccessReadScope,
AuthAccessWriteScope,
EnvironmentId,
PeerLinkError,
AuthEnvironmentMaintainScope,
AuthDiagnosticsReadScope,
AuthFilesystemReadScope,
Expand Down Expand Up @@ -470,3 +474,69 @@ it.effect("denies manual cleanup before the handler without settings permission"
expect(handled).toBe(false);
}).pipe(Effect.scoped),
);

it.effect(
"peer links can be read with access:read, while linking and unlinking require access:write",
() =>
Effect.scoped(
Effect.gen(function* () {
const tested = [
WS_METHODS.peerLinksList,
WS_METHODS.peerLinksGet,
WS_METHODS.peerLinksLink,
WS_METHODS.peerLinksUnlink,
] as const;
const group = WsRpcGroup.omit(
...[...WsRpcGroup.requests.keys()].filter(
(tag): tag is Exclude<keyof typeof RPC_REQUIRED_SCOPES, (typeof tested)[number]> =>
!(tested as ReadonlyArray<string>).includes(tag),
),
);
const handled: Array<string> = [];
const handlers = group.toLayer({
[WS_METHODS.peerLinksList]: () => Effect.succeed({ links: [] }),
[WS_METHODS.peerLinksGet]: () => Effect.succeed(null),
[WS_METHODS.peerLinksLink]: () =>
Effect.sync(() => handled.push("link")).pipe(
Effect.andThen(
new PeerLinkError({ reason: "self", message: "That address is this environment." }),
),
),
[WS_METHODS.peerLinksUnlink]: () =>
Effect.sync(() => {
handled.push("unlink");
return { removed: true };
}),
});
const reader = yield* RpcTest.makeClient(group).pipe(
Effect.provide(Layer.merge(handlers, RpcAuthorization.layer([AuthAccessReadScope]))),
);
const peer = { environmentId: EnvironmentId.make("peer") };
const link = {
url: "http://127.0.0.1:1",
pairingCode: "test",
access: "read-only" as const,
};
expect(yield* reader[WS_METHODS.peerLinksList]({})).toEqual({ links: [] });
expect(yield* reader[WS_METHODS.peerLinksGet](peer)).toBeNull();
expect(yield* reader[WS_METHODS.peerLinksLink](link).pipe(Effect.flip)).toMatchObject({
requiredPermission: AuthAccessWriteScope,
});
expect(yield* reader[WS_METHODS.peerLinksUnlink](peer).pipe(Effect.flip)).toMatchObject({
requiredPermission: AuthAccessWriteScope,
});
expect(handled).toEqual([]);
const writer = yield* RpcTest.makeClient(group).pipe(
Effect.provide(Layer.merge(handlers, RpcAuthorization.layer([AuthAccessWriteScope]))),
);
expect(yield* writer[WS_METHODS.peerLinksLink](link).pipe(Effect.flip)).toMatchObject({
_tag: "PeerLinkError",
});
expect(yield* writer[WS_METHODS.peerLinksUnlink](peer)).toEqual({ removed: true });
expect(handled).toEqual(["link", "unlink"]);
expect(yield* writer[WS_METHODS.peerLinksGet](peer).pipe(Effect.flip)).toMatchObject({
requiredPermission: AuthAccessReadScope,
});
}),
),
);
2 changes: 2 additions & 0 deletions apps/server/src/auth/RpcAuthorization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,8 @@ export const RPC_REQUIRED_SCOPES = {
[WS_METHODS.serverGetStorageCleanupReport]: AuthOrchestrationReadScope,
[WS_METHODS.serverGetDatabaseStorage]: AuthOrchestrationReadScope,
[WS_METHODS.serverGetSettings]: AuthOrchestrationReadScope,
[WS_METHODS.peerLinksList]: AuthAccessReadScope,
[WS_METHODS.peerLinksGet]: AuthAccessReadScope,
[WS_METHODS.serverUpdateSettings]: AuthSettingsWriteScope,
[WS_METHODS.serverSearchAcpRegistry]: AuthOrchestrationReadScope,
[WS_METHODS.serverPrepareAcpRegistryAgent]: AuthProvidersManageScope,
Expand Down
2 changes: 2 additions & 0 deletions apps/server/src/binCli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import * as NetService from "@t3tools/shared/Net";
import packageJson from "../package.json" with { type: "json" };
import { acpMcpBridgeCommand, acpMcpCallCommand } from "./cli/acpMcpBridge.ts";
import { authCommand } from "./cli/auth.ts";
import { environmentCommand } from "./cli/environment.ts";
import { appCommand } from "./cli/app.ts";
import { browserCommand } from "./cli/browser.ts";
import { connectCommand } from "./cli/connect.ts";
Expand Down Expand Up @@ -74,6 +75,7 @@ export const makeCli = ({ cloudEnabled = hasCloudPublicConfig } = {}) =>
appCommand,
pairCommand,
authCommand,
environmentCommand,
browserCommand,
projectCommand,
serviceCommand,
Expand Down
191 changes: 191 additions & 0 deletions apps/server/src/cli/environment.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
import { AuthMcpClientAccess, EnvironmentId, PeerLink, PeerLinkSummary } from "@t3tools/contracts";
import * as Console from "effect/Console";
import * as DateTime from "effect/DateTime";
import * as Effect from "effect/Effect";
import * as Option from "effect/Option";
import * as Layer from "effect/Layer";
import * as References from "effect/References";
import * as Schema from "effect/Schema";
import * as Argument from "effect/cli/Argument";
import * as Command from "effect/cli/Command";
import * as Flag from "effect/cli/Flag";
import * as GlobalFlag from "effect/cli/GlobalFlag";
import * as FetchHttpClient from "effect/http/FetchHttpClient";

import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
import * as ServerConfig from "../config.ts";
import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
import * as PeerLinks from "../peer/PeerLinks.ts";
import * as SqlitePersistence from "../persistence/Sqlite.ts";
import { authLocationFlags, type CliAuthLocationFlags, resolveCliAuthConfig } from "./config.ts";

/**
* Links live in this environment's database and secret store, so these
* commands work whether or not its server is running.
*/
const runWithPeerLinks = <A, E>(
flags: CliAuthLocationFlags,
run: (links: PeerLinks.PeerLinks["Service"]) => Effect.Effect<A, E>,
options?: { readonly quietLogs?: boolean },
) =>
Effect.gen(function* () {
const logLevel = yield* GlobalFlag.LogLevel;
const config = yield* resolveCliAuthConfig(flags, logLevel);
return yield* PeerLinks.PeerLinks.pipe(
Effect.flatMap(run),
Effect.provide(
PeerLinks.layer.pipe(
Layer.provide(
Layer.mergeAll(
ServerSecretStore.layer,
SqlitePersistence.layerConfig,
FetchHttpClient.layer,
),
),
Layer.provide(ServerEnvironment.layer.pipe(Layer.provide(ServerSecretStore.layer))),
Layer.provide(ServerConfig.layer(config)),
Layer.provide(
Layer.succeed(
References.MinimumLogLevel,
options?.quietLogs ? "Error" : config.logLevel,
),
),
),
),
);
});

const jsonFlag = Flag.Boolean("json").pipe(
Flag.withDescription("Emit JSON instead of human-readable output."),
Flag.withDefault(false),
);

const encodeLinks = Schema.encodeEffect(Schema.fromJsonString(Schema.Array(PeerLinkSummary)));

const encodeLink = Schema.encodeEffect(Schema.fromJsonString(Schema.NullOr(PeerLink)));

const formatLinks = (links: ReadonlyArray<PeerLinkSummary>) =>
links.length === 0
? "No linked environments.\n"
: `${links
.map(
(link) =>
`${link.label} (${link.environmentId})\n ${link.status}, access ${link.access}, expires ${DateTime.formatIso(link.expiresAt)}\n ${link.urls.join(", ")}${link.lastError === null ? "" : `\n last error: ${link.lastError}`}`,
)
.join("\n\n")}\n`;

const environmentLinkCommand = Command.make("link", {
...authLocationFlags,
url: Argument.String("url").pipe(
Argument.withDescription(
"Where the other environment answers: HTTPS, or HTTP with loopback, a Tailscale .ts.net name, or Tailscale IPv6.",
),
),
alternateUrl: Flag.String("url").pipe(
Flag.withDescription("Another address for the same environment, tried after the first."),
Flag.atLeast(0),
),
pairingCode: Flag.String("pairing-code").pipe(
Flag.withDescription(
"A pairing code from the other environment (`t3 auth pairing create` there).",
),
),
Comment thread
juliusmarminge marked this conversation as resolved.
Resolved
access: Flag.Literals("access", AuthMcpClientAccess.literals).pipe(
Flag.withDescription(
"Access ceiling for this outgoing link: read-only, or the broadest runtime mode.",
),
Flag.withDefault("approval-required" as const),
),
}).pipe(
Command.withDescription(
"Create an outgoing link to another T3 Code environment. Revoke its session in that environment's Settings → Connections.",
),
Command.withHandler((flags) =>
runWithPeerLinks(flags, (links) =>
Effect.gen(function* () {
const linked = yield* links.link({
url: flags.url,
alternateUrls: flags.alternateUrl,
pairingCode: flags.pairingCode,
access: flags.access,
});
yield* Console.log(
`Linked ${linked.label} (${linked.environmentId}) with ${linked.access} access, until ${DateTime.formatIso(linked.expiresAt)}.\n`,
);
}),
),
),
);

const environmentListCommand = Command.make("list", { ...authLocationFlags, json: jsonFlag }).pipe(
Command.withDescription("List linked environments and whether each answers now."),
Command.withHandler((flags) =>
runWithPeerLinks(
flags,
(links) =>
Effect.gen(function* () {
const listed = yield* links.list;
yield* Console.log(flags.json ? `${yield* encodeLinks(listed)}\n` : formatLinks(listed));
}),
{ quietLogs: flags.json },
),
),
);

const environmentGetCommand = Command.make("get", {
...authLocationFlags,
json: jsonFlag,
environmentId: Argument.String("environment-id"),
}).pipe(
Command.withDescription("Read a stored link without contacting the peer."),
Command.withHandler((flags) =>
runWithPeerLinks(
flags,
(links) =>
Effect.gen(function* () {
const linked = yield* links.get(EnvironmentId.make(flags.environmentId));
yield* Console.log(
flags.json
? `${yield* encodeLink(Option.getOrNull(linked))}\n`
: Option.isNone(linked)
? `No linked environment ${flags.environmentId}.\n`
: `${linked.value.label} (${linked.value.environmentId})\n access ${linked.value.access}, expires ${DateTime.formatIso(linked.value.expiresAt)}\n ${linked.value.urls.join(", ")}\n`,
);
}),
{ quietLogs: flags.json },
),
),
);

const environmentUnlinkCommand = Command.make("unlink", {
...authLocationFlags,
environmentId: Argument.String("environment-id").pipe(
Argument.withDescription("The linked environment's id, from `t3 environment list`."),
),
}).pipe(
Command.withDescription(
"Forget a linked environment here. Revoke it in that environment's Connections to end its session there too.",
),
Command.withHandler((flags) =>
runWithPeerLinks(flags, (links) =>
Effect.gen(function* () {
const removed = yield* links.unlink(EnvironmentId.make(flags.environmentId));
yield* Console.log(
removed
? `Forgot ${flags.environmentId}. Revoke it in that environment's Connections to end its session there.\n`
: `No linked environment ${flags.environmentId}.\n`,
);
}),
),
),
);

export const environmentCommand = Command.make("environment").pipe(
Command.withDescription("Manage outgoing links to other T3 Code environments."),
Command.withSubcommands([
environmentLinkCommand,
environmentListCommand,
environmentGetCommand,
environmentUnlinkCommand,
]),
);
2 changes: 2 additions & 0 deletions apps/server/src/environment/ServerEnvironment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,8 @@ export const make = Effect.gen(function* () {
: {}),
...(desktopAppUpdate ? { desktopAppUpdate: true } : {}),
serverBrowser: true,
peerLinks: true,
mcpModeLimitHeader: true,
},
};

Expand Down
4 changes: 4 additions & 0 deletions apps/server/src/observability/RpcInstrumentation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,10 @@ const RPC_AGGREGATES = {
[WS_METHODS.serverGetStorageCleanupReport]: "server",
[WS_METHODS.serverGetDatabaseStorage]: "server",
[WS_METHODS.serverGetSettings]: "server",
[WS_METHODS.peerLinksList]: "peerLinks",
[WS_METHODS.peerLinksGet]: "peerLinks",
[WS_METHODS.peerLinksLink]: "peerLinks",
[WS_METHODS.peerLinksUnlink]: "peerLinks",
[WS_METHODS.serverUpdateSettings]: "server",
[WS_METHODS.serverSearchAcpRegistry]: "server",
[WS_METHODS.serverPrepareAcpRegistryAgent]: "server",
Expand Down
Loading
Loading