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
129 changes: 88 additions & 41 deletions apps/desktop/src/backend/DesktopLocalEnvironmentAuth.test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
import * as NodeServices from "@effect/platform-node/NodeServices";
import { assert, describe, it } from "@effect/vitest";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
import * as Path from "effect/Path";
import * as Ref from "effect/Ref";
import * as HttpClient from "effect/unstable/http/HttpClient";
import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse";
import { PRIMARY_LOCAL_ENVIRONMENT_ID } from "@t3tools/contracts";

import * as DesktopConfig from "../app/DesktopConfig.ts";
import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
import * as DesktopBackendPool from "./DesktopBackendPool.ts";
import * as DesktopLocalEnvironmentAuth from "./DesktopLocalEnvironmentAuth.ts";

Expand All @@ -29,53 +34,95 @@ const config = {
captureOutput: true,
};

describe("DesktopLocalEnvironmentAuth", () => {
it.effect("exchanges the desktop bootstrap credential only once", () =>
Effect.gen(function* () {
const requestCount = yield* Ref.make(0);
const httpClientLayer = Layer.succeed(
HttpClient.HttpClient,
HttpClient.make((request) =>
Ref.update(requestCount, (count) => count + 1).pipe(
Effect.as(
HttpClientResponse.fromWeb(
request,
new Response(
JSON.stringify({
access_token: "desktop-bearer-token",
issued_token_type: "urn:ietf:params:oauth:token-type:access_token",
token_type: "Bearer",
expires_in: 3600,
scope: "orchestration:read",
}),
{ status: 200, headers: { "content-type": "application/json" } },
),
),
function makeLayer(baseDir: string, requestCount: Ref.Ref<number>) {
const httpClientLayer = Layer.succeed(
HttpClient.HttpClient,
HttpClient.make((request) =>
Ref.update(requestCount, (count) => count + 1).pipe(
Effect.as(
HttpClientResponse.fromWeb(
request,
new Response(
JSON.stringify({
access_token: "desktop-bearer-token",
issued_token_type: "urn:ietf:params:oauth:token-type:access_token",
token_type: "Bearer",
expires_in: 3600,
scope: "orchestration:read",
}),
{ status: 200, headers: { "content-type": "application/json" } },
),
),
),
);
const poolLayer = Layer.succeed(DesktopBackendPool.DesktopBackendPool, {
list: Effect.succeed([
{
id: PRIMARY_LOCAL_ENVIRONMENT_ID,
label: Effect.succeed("Windows"),
currentConfig: Effect.succeed(Option.some(config)),
},
]),
} as unknown as DesktopBackendPool.DesktopBackendPool["Service"]);
const testLayer = DesktopLocalEnvironmentAuth.layer.pipe(
Layer.provide(Layer.mergeAll(poolLayer, httpClientLayer)),
);
),
),
);

const poolLayer = Layer.succeed(DesktopBackendPool.DesktopBackendPool, {
list: Effect.succeed([
{
id: PRIMARY_LOCAL_ENVIRONMENT_ID,
label: Effect.succeed("Windows"),
currentConfig: Effect.succeed(Option.some(config)),
},
]),
} as unknown as DesktopBackendPool.DesktopBackendPool["Service"]);

const environmentLayer = DesktopEnvironment.layer({
dirname: "/repo/apps/desktop/src",
homeDirectory: baseDir,
platform: "darwin",
processArch: "x64",
appVersion: "1.2.3",
appPath: "/repo",
isPackaged: true,
resourcesPath: "/missing/resources",
runningUnderArm64Translation: false,
}).pipe(
Layer.provide(
Layer.mergeAll(NodeServices.layer, DesktopConfig.layerTest({ T3CODE_HOME: baseDir })),
),
);

const dependencies = Layer.mergeAll(
poolLayer,
httpClientLayer,
environmentLayer,
NodeServices.layer,
);

const [first, second] = yield* Effect.gen(function* () {
return Layer.mergeAll(
DesktopLocalEnvironmentAuth.layer.pipe(Layer.provide(dependencies)),
environmentLayer,
);
}

describe("DesktopLocalEnvironmentAuth", () => {
it.effect("exchanges the desktop bootstrap credential only once per persisted instance id", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const baseDir = yield* fileSystem.makeTempDirectoryScoped({
prefix: "t3-desktop-local-auth-test-",
});
const requestCount = yield* Ref.make(0);

const exchangeTwiceAndReadInstanceId = Effect.gen(function* () {
const auth = yield* DesktopLocalEnvironmentAuth.DesktopLocalEnvironmentAuth;
return yield* Effect.all([auth.getBearerToken, auth.getBearerToken]);
}).pipe(Effect.provide(testLayer));
const environment = yield* DesktopEnvironment.DesktopEnvironment;
const path = yield* Path.Path;
const [first, second] = yield* Effect.all([auth.getBearerToken, auth.getBearerToken]);

assert.strictEqual(first, "desktop-bearer-token");
assert.strictEqual(second, "desktop-bearer-token");

const instanceIdPath = path.join(environment.stateDir, "client-instance-id");
return yield* fileSystem.readFileString(instanceIdPath);
}).pipe(Effect.provide(makeLayer(baseDir, requestCount)));

const storedInstanceId = yield* exchangeTwiceAndReadInstanceId;

assert.strictEqual(first, "desktop-bearer-token");
assert.strictEqual(second, "desktop-bearer-token");
assert.strictEqual(storedInstanceId.trim().length > 0, true);
assert.strictEqual(yield* Ref.get(requestCount), 1);
}),
}).pipe(Effect.provide(NodeServices.layer), Effect.scoped),
);
});
44 changes: 44 additions & 0 deletions apps/desktop/src/backend/DesktopLocalEnvironmentAuth.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,19 @@
import { bootstrapRemoteBearerSession } from "@t3tools/client-runtime/authorization";
import { PRIMARY_LOCAL_ENVIRONMENT_ID } from "@t3tools/contracts";
import * as Context from "effect/Context";
import * as Crypto from "effect/Crypto";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
import * as Path from "effect/Path";
import * as Ref from "effect/Ref";
import * as Schema from "effect/Schema";
import * as Semaphore from "effect/Semaphore";
import * as HttpClient from "effect/unstable/http/HttpClient";

import * as DesktopBackendPool from "./DesktopBackendPool.ts";
import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";

export class DesktopLocalEnvironmentAuthBackendNotConfiguredError extends Schema.TaggedErrorClass<DesktopLocalEnvironmentAuthBackendNotConfiguredError>()(
"DesktopLocalEnvironmentAuthBackendNotConfiguredError",
Expand Down Expand Up @@ -45,9 +49,48 @@ export class DesktopLocalEnvironmentAuth extends Context.Service<
export const make = Effect.gen(function* () {
const pool = yield* DesktopBackendPool.DesktopBackendPool;
const httpClient = yield* HttpClient.HttpClient;
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const crypto = yield* Crypto.Crypto;
const environment = yield* DesktopEnvironment.DesktopEnvironment;
const tokenRef = yield* Ref.make(Option.none<string>());
const instanceIdRef = yield* Ref.make(Option.none<string>());
const mutex = yield* Semaphore.make(1);

// Persisted once per desktop install so every launch reuses the same
// authorized-client session on the local backend instead of adding one.
const resolveInstanceId = Effect.gen(function* () {
const cached = yield* Ref.get(instanceIdRef);
if (Option.isSome(cached)) {
return cached.value;
}
const instanceIdPath = path.join(environment.stateDir, "client-instance-id");
const stored = yield* fileSystem.readFileString(instanceIdPath).pipe(Effect.option);
const instanceId =
Option.isSome(stored) && stored.value.trim() !== ""
? stored.value.trim()
: yield* crypto.randomUUIDv4.pipe(
Effect.mapError(
(cause) => new DesktopLocalEnvironmentAuthSessionBootstrapError({ cause }),
),
);
if (Option.isNone(stored)) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Medium backend/DesktopLocalEnvironmentAuth.ts:77

A whitespace-only client-instance-id file causes a new instanceId to be generated on every desktop launch, so each bearer bootstrap creates a distinct authorized-client session. The persistence branch checks only Option.isNone(stored), so it skips writing the replacement for present-but-empty content; persist whenever the stored value is missing or blank.

Suggested change
if (Option.isNone(stored)) {
if (Option.isNone(stored) || stored.value.trim() === "") {
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/desktop/src/backend/DesktopLocalEnvironmentAuth.ts around line 77:

A whitespace-only `client-instance-id` file causes a new `instanceId` to be generated on every desktop launch, so each bearer bootstrap creates a distinct authorized-client session. The persistence branch checks only `Option.isNone(stored)`, so it skips writing the replacement for present-but-empty content; persist whenever the stored value is missing or blank.

yield* fileSystem
.makeDirectory(path.dirname(instanceIdPath), { recursive: true })
.pipe(Effect.ignore);
yield* fileSystem.writeFileString(instanceIdPath, `${instanceId}\n`).pipe(
Effect.catchCause((cause) =>
Effect.logWarning("Failed to persist the desktop client instance id.", {
instanceIdPath,
cause,
}),
),
);
}
yield* Ref.set(instanceIdRef, Option.some(instanceId));
return instanceId;
}).pipe(Effect.withSpan("desktop.localEnvironmentAuth.resolveInstanceId"));

const getBearerToken = mutex
.withPermits(1)(
Effect.gen(function* () {
Expand All @@ -73,6 +116,7 @@ export const make = Effect.gen(function* () {
clientMetadata: {
label: "T3 Code Desktop",
deviceType: "desktop",
instanceId: yield* resolveInstanceId,
},
}).pipe(
Effect.provideService(HttpClient.HttpClient, httpClient),
Expand Down
5 changes: 4 additions & 1 deletion apps/mobile/src/connection/platform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ const wakeupsLayer = Wakeups.layer({
const capabilitiesLayer = Layer.effectContext(
Effect.gen(function* () {
const storage = yield* MobileStorage.MobileStorage;
const clientInstanceId = yield* storage.loadOrCreateClientInstanceId.pipe(Effect.option);
return Context.make(
CloudSession,
CloudSession.of({
Expand Down Expand Up @@ -166,7 +167,9 @@ const capabilitiesLayer = Layer.effectContext(
Context.add(
ClientPresentation,
ClientPresentation.of({
metadata: authClientMetadata(),
metadata: authClientMetadata(
Option.isSome(clientInstanceId) ? { instanceId: clientInstanceId.value } : {},
),
scopes: AuthStandardClientScopes,
}),
),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ const testLayer = Layer.mergeAll(
saveConnection: () => Effect.void,
clearSavedConnection: () => Effect.void,
loadOrCreateAgentAwarenessDeviceId: Effect.succeed("device-1"),
loadOrCreateClientInstanceId: Effect.succeed("client-instance-1"),
loadAgentAwarenessDeviceId: Effect.succeed("device-1"),
loadAgentAwarenessRegistrationRecord: Effect.succeed(null),
saveAgentAwarenessRegistrationRecord: () => Effect.void,
Expand Down
2 changes: 2 additions & 0 deletions apps/mobile/src/features/cloud/linkEnvironment.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ function cloudClientLayer() {
saveConnection: () => Effect.void,
clearSavedConnection: () => Effect.void,
loadOrCreateAgentAwarenessDeviceId: Effect.succeed("device-1"),
loadOrCreateClientInstanceId: Effect.succeed("client-instance-1"),
loadAgentAwarenessDeviceId: Effect.succeed("device-1"),
loadAgentAwarenessRegistrationRecord: Effect.succeed(null),
saveAgentAwarenessRegistrationRecord: () => Effect.void,
Expand Down Expand Up @@ -489,6 +490,7 @@ describe("mobile cloud link environment client", () => {
expect(environmentTokenBody.get("client_label")).toBe("T3 Code Mobile");
expect(environmentTokenBody.get("client_device_type")).toBe("mobile");
expect(environmentTokenBody.get("client_os")).toBe("iOS");
expect(environmentTokenBody.get("client_instance_id")).toBe("client-instance-1");
}),
);

Expand Down
9 changes: 8 additions & 1 deletion apps/mobile/src/features/cloud/linkEnvironment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -497,6 +497,13 @@ const loadAgentAwarenessDeviceId = Effect.fn("mobile.cloud.loadAgentAwarenessDev
},
);

const loadClientInstanceId = Effect.fn("mobile.cloud.loadClientInstanceId")(function* () {
const storage = yield* MobileStorage.MobileStorage;
return yield* storage.loadOrCreateClientInstanceId.pipe(
Effect.mapError(cloudEnvironmentLinkError("Could not load the client instance id.")),
);
});

const connectRelayManagedEnvironment = Effect.fn("mobile.cloud.connectRelayManagedEnvironment")(
function* (input: {
readonly clerkToken: string;
Expand Down Expand Up @@ -557,7 +564,7 @@ const connectRelayManagedEnvironment = Effect.fn("mobile.cloud.connectRelayManag
httpBaseUrl: connect.endpoint.httpBaseUrl,
credential: connect.credential,
dpopProof: bootstrapDpop,
clientMetadata: authClientMetadata(),
clientMetadata: authClientMetadata({ instanceId: yield* loadClientInstanceId() }),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟠 High cloud/linkEnvironment.ts:567

Cloud connect and refresh now fail before exchangeRemoteDpopAccessToken whenever secure storage cannot load the client instance ID, even though instanceId is optional. Handle that storage failure by omitting the metadata (or using an in-memory ID) so a transient or corrupt secure-storage read does not block authentication.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/mobile/src/features/cloud/linkEnvironment.ts around line 567:

Cloud connect and refresh now fail before `exchangeRemoteDpopAccessToken` whenever secure storage cannot load the client instance ID, even though `instanceId` is optional. Handle that storage failure by omitting the metadata (or using an in-memory ID) so a transient or corrupt secure-storage read does not block authentication.

}).pipe(
Effect.mapError(
cloudEnvironmentLinkError("Could not exchange a managed endpoint DPoP access token."),
Expand Down
5 changes: 4 additions & 1 deletion apps/mobile/src/lib/authClientMetadata.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import type { AuthClientPresentationMetadata } from "@t3tools/contracts";
import { Platform } from "react-native";

export function authClientMetadata(): AuthClientPresentationMetadata {
export function authClientMetadata(
input: { readonly instanceId?: string } = {},
): AuthClientPresentationMetadata {
return {
label: "T3 Code Mobile",
deviceType: "mobile",
...(Platform.OS === "ios" ? { os: "iOS" } : Platform.OS === "android" ? { os: "Android" } : {}),
...(input.instanceId ? { instanceId: input.instanceId } : {}),
};
}
19 changes: 19 additions & 0 deletions apps/mobile/src/persistence/mobile-storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ const CONNECTIONS_KEY = "t3code.connections";
const AGENT_AWARENESS_DEVICE_ID_KEY = "t3code.agent-awareness.device-id";
const AGENT_AWARENESS_REGISTRATION_KEY = "t3code.agent-awareness.registration";
const RECENT_THREAD_SHORTCUTS_KEY = "t3code.recent-thread-shortcuts";
const CLIENT_INSTANCE_ID_KEY = "t3code.client-instance-id";

export class MobileStorageDecodeError extends Schema.TaggedErrorClass<MobileStorageDecodeError>()(
"MobileStorageDecodeError",
Expand Down Expand Up @@ -86,6 +87,10 @@ export class MobileStorage extends Context.Service<
string,
MobileSecureStorage.MobileSecureStorageError | MobileDeviceIdGenerationError
>;
readonly loadOrCreateClientInstanceId: Effect.Effect<
string,
MobileSecureStorage.MobileSecureStorageError | MobileDeviceIdGenerationError
>;
readonly loadAgentAwarenessDeviceId: Effect.Effect<
string | null,
MobileSecureStorage.MobileSecureStorageError
Expand Down Expand Up @@ -199,6 +204,19 @@ export const make = Effect.fn("MobileStorage.make")(function* () {
return deviceId;
});

// Stable per-install id presented during auth bootstrap so repeated
// connections reuse one authorized-client session per environment.
const loadOrCreateClientInstanceId = Effect.gen(function* () {
const existing = yield* secureStorage.getItem(CLIENT_INSTANCE_ID_KEY);
if (existing?.trim()) return existing;
const instanceId = yield* Effect.tryPromise({
try: () => import("../lib/uuid").then(({ uuidv4 }) => uuidv4()),
catch: (cause) => new MobileDeviceIdGenerationError({ cause }),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

MobileDeviceIdGenerationError renders "Failed to generate the mobile agent-awareness device id.", which is inaccurate for a client-instance-id failure, and the error carries no attribute distinguishing the two cases. Consider either a distinct error class for this failure, or adding a structural discriminator (e.g. key) and deriving the message from it.

Suggested change
catch: (cause) => new MobileDeviceIdGenerationError({ cause }),
catch: (cause) => new MobileClientInstanceIdGenerationError({ cause }),

Posted via Macroscope — Effect Service Conventions

});
yield* secureStorage.setItem(CLIENT_INSTANCE_ID_KEY, instanceId);
return instanceId;
});

const loadAgentAwarenessDeviceId = secureStorage
.getItem(AGENT_AWARENESS_DEVICE_ID_KEY)
.pipe(Effect.map((existing) => (existing?.trim() ? existing : null)));
Expand Down Expand Up @@ -250,6 +268,7 @@ export const make = Effect.fn("MobileStorage.make")(function* () {
saveConnection,
clearSavedConnection,
loadOrCreateAgentAwarenessDeviceId,
loadOrCreateClientInstanceId,
loadAgentAwarenessDeviceId,
loadAgentAwarenessRegistrationRecord,
saveAgentAwarenessRegistrationRecord: (record) =>
Expand Down
Loading
Loading