Skip to content
Draft
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
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,13 @@ import { CameraView, useCameraPermissions } from "expo-camera";
import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader";
import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native";
import { AsyncResult } from "effect/unstable/reactivity";
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { Alert, Platform, ScrollView, View } from "react-native";
import {
MATRIX_OS_CONNECT_URL,
MATRIX_OS_SETUP_DESCRIPTION,
MATRIX_OS_SETUP_MOBILE_ACTION_LABEL,
} from "@t3tools/shared/matrixOsConnect";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useThemeColor } from "../../lib/useThemeColor";

Expand All @@ -14,6 +19,7 @@ import { ConnectionSheetButton } from "./ConnectionSheetButton";
import { extractPairingUrlFromQrPayload } from "./pairing";
import { useRemoteConnections } from "../../state/use-remote-environment-registry";
import { buildPairingUrl, parsePairingUrl } from "./pairing";
import { tryOpenExternalUrl } from "../../lib/openExternalUrl";

type ConnectionsNewRouteParams = {
readonly mode?: string;
Expand All @@ -34,9 +40,11 @@ export function ConnectionsNewRouteScreen({
const [hostInput, setHostInput] = useState("");
const [codeInput, setCodeInput] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
const [isOpeningMatrixOs, setIsOpeningMatrixOs] = useState(false);
const [showScanner, setShowScanner] = useState(params.mode === "scan_qr");
const [cameraPermission, requestCameraPermission] = useCameraPermissions();
const [scannerLocked, setScannerLocked] = useState(false);
const matrixOsLaunchInFlightRef = useRef(false);

const headerIconColor = useThemeColor("--color-icon");

Expand Down Expand Up @@ -133,6 +141,27 @@ export function ConnectionsNewRouteScreen({
}
}, [codeInput, hostInput, onChangeConnectionPairingUrl, onConnectPress, navigation]);

const handleConnectMatrixOs = useCallback(async () => {
if (matrixOsLaunchInFlightRef.current) {
return;
}

matrixOsLaunchInFlightRef.current = true;
setIsOpeningMatrixOs(true);
try {
const opened = await tryOpenExternalUrl(MATRIX_OS_CONNECT_URL, "matrix-os-connect");
if (!opened) {
Alert.alert(
"Unable to open Matrix OS",
"Open app.matrix-os.com and connect T3 Code from its Terminal.",
);
}
} finally {
matrixOsLaunchInFlightRef.current = false;
setIsOpeningMatrixOs(false);
}
}, []);

return (
<View collapsable={false} className="flex-1 bg-sheet">
<NativeStackScreenOptions
Expand Down Expand Up @@ -257,6 +286,25 @@ export function ConnectionsNewRouteScreen({
/>
</View>
)}
{!showScanner ? (
<View collapsable={false} className="gap-3 rounded-[24px] bg-card p-4">
<View collapsable={false} className="gap-1.5">
<Text className="text-base font-t3-bold text-foreground">Matrix OS</Text>
<Text className="text-sm leading-normal text-foreground-muted">
{MATRIX_OS_SETUP_DESCRIPTION}
</Text>
</View>
<ConnectionSheetButton
icon="safari"
label={isOpeningMatrixOs ? "Opening..." : MATRIX_OS_SETUP_MOBILE_ACTION_LABEL}
disabled={isOpeningMatrixOs}
tone="secondary"
onPress={() => {
void handleConnectMatrixOs();
}}
/>
</View>
) : null}
</View>
</ScrollView>
</View>
Expand Down
12 changes: 12 additions & 0 deletions apps/mobile/src/lib/openExternalUrl.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Linking } from "react-native";
import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";

import { tryOpenExternalUrl } from "./openExternalUrl";
import { MATRIX_OS_CONNECT_URL } from "@t3tools/shared/matrixOsConnect";

vi.mock("react-native", () => ({
Linking: { openURL: vi.fn() },
Expand Down Expand Up @@ -56,3 +57,14 @@ describe("tryOpenExternalUrl", () => {
expect(diagnosticText).not.toContain("browser-unavailable-secret-sentinel");
});
});

describe("Matrix OS handoff", () => {
it("opens the fixed Matrix OS setup URL", async () => {
openURL.mockResolvedValueOnce(undefined);

await expect(tryOpenExternalUrl(MATRIX_OS_CONNECT_URL, "matrix-os-connect")).resolves.toBe(
true,
);
expect(openURL).toHaveBeenCalledExactlyOnceWith(MATRIX_OS_CONNECT_URL);
});
});
7 changes: 6 additions & 1 deletion apps/mobile/src/lib/openExternalUrl.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import * as Schema from "effect/Schema";
import { Linking } from "react-native";

const ExternalUrlTarget = Schema.Literals(["file-preview", "markdown-link", "pull-request"]);
const ExternalUrlTarget = Schema.Literals([
"file-preview",
"markdown-link",
"pull-request",
"matrix-os-connect",
]);

export type ExternalUrlTarget = typeof ExternalUrlTarget.Type;

Expand Down
5 changes: 4 additions & 1 deletion apps/server/src/auth/EnvironmentAuth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,13 @@ import * as Option from "effect/Option";
import * as Schema from "effect/Schema";
import * as HttpServerRequest from "effect/unstable/http/HttpServerRequest";

import * as ServerConfig from "../config.ts";
import { layerConfig as SqlitePersistenceLayer } from "../persistence/Layers/Sqlite.ts";
import * as EnvironmentAuthPolicy from "./EnvironmentAuthPolicy.ts";
import * as PairingGrantStore from "./PairingGrantStore.ts";
import * as ServerSecretStore from "./ServerSecretStore.ts";
import * as SessionStore from "./SessionStore.ts";
import { verifyRequestDpopProof } from "./dpop.ts";
import { layerConfig as SqlitePersistenceLayer } from "../persistence/Layers/Sqlite.ts";

export const DEFAULT_SESSION_SUBJECT = "cli-issued-session";
export const INTERNAL_ADMINISTRATIVE_BOOTSTRAP_SUBJECT = "administrative-bootstrap";
Expand Down Expand Up @@ -555,6 +556,7 @@ function parseDpopToken(request: HttpServerRequest.HttpServerRequest): string |
}

export const make = Effect.gen(function* () {
const serverConfig = yield* ServerConfig.ServerConfig;
const policy = yield* EnvironmentAuthPolicy.EnvironmentAuthPolicy;
const bootstrapCredentials = yield* PairingGrantStore.PairingGrantStore;
const sessions = yield* SessionStore.SessionStore;
Expand Down Expand Up @@ -613,6 +615,7 @@ export const make = Effect.gen(function* () {
request,
expectedThumbprint: session.proofKeyThumbprint,
expectedAccessToken: dpopToken,
...(serverConfig.pairingBaseUrl ? { pairingBaseUrl: serverConfig.pairingBaseUrl } : {}),
}).pipe(
Effect.provideService(ServerSecretStore.ServerSecretStore, secretStore),
Effect.provideService(Crypto.Crypto, crypto),
Expand Down
14 changes: 13 additions & 1 deletion apps/server/src/auth/dpop.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { describe, expect, it } from "vite-plus/test";
import * as PlatformError from "effect/PlatformError";

import { SecretStorePersistError } from "./ServerSecretStore.ts";
import { mapDpopReplayStoreError } from "./dpop.ts";
import { mapDpopReplayStoreError, resolveDpopRequestUrl } from "./dpop.ts";

const storeFailure = (tag: "AlreadyExists" | "PermissionDenied") =>
new SecretStorePersistError({
Expand Down Expand Up @@ -35,3 +35,15 @@ describe("mapDpopReplayStoreError", () => {
}
});
});

describe("resolveDpopRequestUrl", () => {
it("uses the configured reverse-proxy base instead of forwarded headers", () => {
expect(
resolveDpopRequestUrl({
localUrl: new URL("http://127.0.0.1:3773/oauth/token?code=one-time"),
originalUrl: "/oauth/token?code=one-time",
pairingBaseUrl: new URL("https://app.matrix-os.com/vm/alice/api/integrations/t3/"),
}),
).toBe("https://app.matrix-os.com/vm/alice/api/integrations/t3/oauth/token?code=one-time");
});
});
17 changes: 16 additions & 1 deletion apps/server/src/auth/dpop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,20 @@ export const mapDpopReplayStoreError = (
cause: error,
});

export function resolveDpopRequestUrl(input: {
readonly localUrl: URL;
readonly originalUrl: string;
readonly pairingBaseUrl?: URL;
}): string {
if (!input.pairingBaseUrl) return input.localUrl.href;
return new URL(input.originalUrl.replace(/^\/+/, ""), input.pairingBaseUrl).href;
}

export const verifyRequestDpopProof = (input: {
readonly request: HttpServerRequest.HttpServerRequest;
readonly expectedThumbprint?: string;
readonly expectedAccessToken?: string;
readonly pairingBaseUrl?: URL;
}) =>
Effect.gen(function* () {
const proof = input.request.headers.dpop;
Expand All @@ -39,11 +49,16 @@ export const verifyRequestDpopProof = (input: {
diagnostic: "Invalid DPoP request URL.",
});
}
const requestUrl = resolveDpopRequestUrl({
localUrl: url.value,
originalUrl: input.request.originalUrl,
...(input.pairingBaseUrl ? { pairingBaseUrl: input.pairingBaseUrl } : {}),
});
const now = yield* DateTime.now;
const result = verifyDpopProof({
proof,
method: input.request.method,
url: url.value.href,
url: requestUrl,
nowEpochSeconds: Math.floor(now.epochMilliseconds / 1_000),
...(input.expectedThumbprint ? { expectedThumbprint: input.expectedThumbprint } : {}),
...(input.expectedAccessToken ? { expectedAccessToken: input.expectedAccessToken } : {}),
Expand Down
9 changes: 8 additions & 1 deletion apps/server/src/auth/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder";
import * as EnvironmentAuth from "./EnvironmentAuth.ts";
import * as SessionStore from "./SessionStore.ts";
import { traceAuthenticatedRelayRequest, traceRelayRequest } from "../cloud/traceRelayRequest.ts";
import * as ServerConfig from "../config.ts";
import { deriveAuthClientMetadata } from "./utils.ts";
import { verifyRequestDpopProof } from "./dpop.ts";

Expand Down Expand Up @@ -203,6 +204,7 @@ export const authHttpApiLayer = HttpApiBuilder.group(
Effect.fnUntraced(function* (handlers) {
const serverAuth = yield* EnvironmentAuth.EnvironmentAuth;
const sessions = yield* SessionStore.SessionStore;
const serverConfig = yield* ServerConfig.ServerConfig;

return handlers
.handle(
Expand Down Expand Up @@ -277,7 +279,12 @@ export const authHttpApiLayer = HttpApiBuilder.group(
return yield* failEnvironmentInvalidRequest("invalid_scope");
}
const proofKeyThumbprint = args.headers.dpop
? yield* verifyRequestDpopProof({ request }).pipe(
? yield* verifyRequestDpopProof({
request,
...(serverConfig.pairingBaseUrl
? { pairingBaseUrl: serverConfig.pairingBaseUrl }
: {}),
}).pipe(
Effect.catchIf(EnvironmentAuth.isServerAuthCredentialError, () =>
appendDpopChallengeHeader.pipe(
Effect.andThen(failEnvironmentAuthInvalid("invalid_credential")),
Expand Down
18 changes: 17 additions & 1 deletion apps/server/src/cli/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@ import {
import * as NetService from "@t3tools/shared/Net";
import * as NodeServices from "@effect/platform-node/NodeServices";
import { deriveServerPaths } from "../config.ts";
import { resolveServerConfig } from "./config.ts";
import { PairingBaseUrl, resolveServerConfig } from "./config.ts";

const decodePairingBaseUrl = Schema.decodeUnknownSync(PairingBaseUrl);

const deriveExplicitServerPaths = (baseDir: string, devUrl: URL | undefined) =>
deriveServerPaths(baseDir, devUrl, { baseDirIsExplicit: true });
Expand Down Expand Up @@ -155,6 +157,9 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
logWebSocketEvents: Option.some(true),
tailscaleServeEnabled: Option.some(true),
tailscaleServePort: Option.some(8443),
pairingBaseUrl: Option.some(
new URL("https://app.matrix-os.com/vm/alice/api/integrations/t3"),
),
},
Option.some("Debug"),
).pipe(
Expand Down Expand Up @@ -194,15 +199,26 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
noBrowser: true,
startupPresentation: "browser",
desktopBootstrapToken: undefined,
desktopTelemetryFd: undefined,
desktopTelemetryControlFd: undefined,
resourceMonitorPath: undefined,
autoBootstrapProjectFromCwd: true,
logWebSocketEvents: true,
tailscaleServeEnabled: true,
tailscaleServePort: 8443,
pairingBaseUrl: new URL("https://app.matrix-os.com/vm/alice/api/integrations/t3/"),
});
assert.equal(resolved.dbPath, join(baseDir, "userdata", "state.sqlite"));
}),
);

it("accepts only HTTP(S) pairing base URLs", () => {
expect(decodePairingBaseUrl("https://example.com/proxy")).toEqual(
new URL("https://example.com/proxy"),
);
expect(() => decodePairingBaseUrl("ftp://example.com/proxy")).toThrow(/HTTP\(S\)/);
});

it.effect("preserves explicit false CLI boolean flags over env and bootstrap values", () =>
Effect.gen(function* () {
const { join } = yield* Path.Path;
Expand Down
40 changes: 40 additions & 0 deletions apps/server/src/cli/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,29 @@ export const tailscaleServePortFlag = Flag.integer("tailscale-serve-port").pipe(
Flag.withDescription("HTTPS port for Tailscale Serve when --tailscale-serve is enabled."),
Flag.optional,
);
export const PairingBaseUrl = Schema.URLFromString.pipe(
Schema.refine((url): url is URL => url.protocol === "http:" || url.protocol === "https:", {
message: "Expected an HTTP(S) URL",
}),
);

export const normalizePairingBaseUrl = (value: URL): URL => {
const normalized = new URL(value);
if (!normalized.pathname.endsWith("/")) {
normalized.pathname = `${normalized.pathname}/`;
}
normalized.search = "";
normalized.hash = "";
return normalized;
};

export const pairingBaseUrlFlag = Flag.string("pairing-base-url").pipe(
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Flag.withSchema(PairingBaseUrl),
Flag.withDescription(
"Public HTTP(S) base URL advertised in the headless pairing link when a trusted reverse proxy fronts this server.",
),
Flag.optional,
);
Comment thread
macroscopeapp[bot] marked this conversation as resolved.

const EnvServerConfig = Config.all({
logLevel: Config.logLevel("T3CODE_LOG_LEVEL").pipe(Config.withDefault("Info")),
Expand Down Expand Up @@ -139,6 +162,10 @@ const EnvServerConfig = Config.all({
Config.option,
Config.map(Option.getOrUndefined),
),
pairingBaseUrl: Config.schema(PairingBaseUrl, "T3CODE_PAIRING_BASE_URL").pipe(
Config.option,
Config.map(Option.getOrUndefined),
),
});

export interface CliServerFlags {
Expand All @@ -154,6 +181,7 @@ export interface CliServerFlags {
readonly logWebSocketEvents: Option.Option<boolean>;
readonly tailscaleServeEnabled: Option.Option<boolean>;
readonly tailscaleServePort: Option.Option<number>;
readonly pairingBaseUrl?: Option.Option<URL>;
}

export interface CliAuthLocationFlags {
Expand Down Expand Up @@ -188,6 +216,7 @@ export const sharedServerCommandFlags = {
logWebSocketEvents: logWebSocketEventsFlag,
tailscaleServeEnabled: tailscaleServeFlag,
tailscaleServePort: tailscaleServePortFlag,
pairingBaseUrl: pairingBaseUrlFlag,
} as const;

export const authLocationFlags = sharedServerLocationFlags;
Expand Down Expand Up @@ -233,6 +262,7 @@ export const resolveServerConfig = (
logWebSocketEvents: flags.logWebSocketEvents ?? Option.none(),
tailscaleServeEnabled: flags.tailscaleServeEnabled ?? Option.none(),
tailscaleServePort: flags.tailscaleServePort ?? Option.none(),
pairingBaseUrl: flags.pairingBaseUrl ?? Option.none(),
} satisfies CliServerFlags;
const bootstrapFd = Option.getOrUndefined(normalizedFlags.bootstrapFd) ?? env.bootstrapFd;
const bootstrapEnvelope =
Expand Down Expand Up @@ -338,6 +368,14 @@ export const resolveServerConfig = (
),
() => 443,
);
const pairingBaseUrlValue = Option.getOrUndefined(
resolveOptionPrecedence(
normalizedFlags.pairingBaseUrl,
Option.fromUndefinedOr(env.pairingBaseUrl),
),
);
const pairingBaseUrl =
pairingBaseUrlValue === undefined ? undefined : normalizePairingBaseUrl(pairingBaseUrlValue);
const staticDir = devUrl ? undefined : yield* ServerConfig.resolveStaticDir();
const host = Option.getOrElse(
resolveOptionPrecedence(
Expand Down Expand Up @@ -386,6 +424,7 @@ export const resolveServerConfig = (
logWebSocketEvents,
tailscaleServeEnabled,
tailscaleServePort,
...(pairingBaseUrl ? { pairingBaseUrl } : {}),
};

return config;
Expand All @@ -409,6 +448,7 @@ export const resolveCliAuthConfig = (
logWebSocketEvents: Option.none(),
tailscaleServeEnabled: Option.none(),
tailscaleServePort: Option.none(),
pairingBaseUrl: Option.none(),
},
cliLogLevel,
);
Expand Down
Loading
Loading