Skip to content
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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
14 changes: 13 additions & 1 deletion apps/server/src/device/DeviceService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import * as Exit from "effect/Exit";
import * as Deferred from "effect/Deferred";
import * as Fiber from "effect/Fiber";
import * as FileSystem from "effect/FileSystem";
import * as SecretResolver from "@t3tools/secret-provider-core/server/SecretResolver";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
import * as PubSub from "effect/PubSub";
Expand Down Expand Up @@ -534,6 +535,11 @@ describe("device setup consent", () => {
);
});

const layerSecretResolver = Layer.succeed(SecretResolver.SecretResolver, {
read: (references) => Effect.succeed(references.map(() => undefined)),
invalidate: () => Effect.void,
});

it.effect("publishes boot progress and does not restore sessions after support is disabled", () =>
Effect.gen(function* () {
const started = yield* Deferred.make<void>();
Expand Down Expand Up @@ -1437,7 +1443,12 @@ it.effect("reconciles SSH host settings without publishing no-op changes", () =>
}).pipe(
Effect.scoped,
Effect.provide(
Layer.mergeAll(ServerSettings.layerTest(), NodeServices.layer, NodeCrypto.layer),
Layer.mergeAll(
ServerSettings.layerTest(),
layerSecretResolver,
NodeServices.layer,
NodeCrypto.layer,
),
),
),
);
Expand Down Expand Up @@ -1496,6 +1507,7 @@ it.effect("a failed tool update reports npm's exit code but not its output", ()
Effect.provide(
Layer.mergeAll(
ServerSettings.layerTest({ enableDeviceSupport: true }),
layerSecretResolver,
ServerConfig.layerTest(process.cwd(), { prefix: "t3-device-update-" }),
NodeCrypto.layer,
NetService.layer,
Expand Down
15 changes: 12 additions & 3 deletions apps/server/src/device/DeviceService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,12 +68,14 @@ import { isLocalSshDeviceHost, remoteSshDeviceHosts } from "./localSshDeviceHost

import { readDeviceDetail, runDeviceAction } from "./DeviceActions.ts";
import * as ProcessRunner from "../processRunner.ts";
import * as SecretResolver from "@t3tools/secret-provider-core/server/SecretResolver";
import * as SecretResolverLayer from "../secrets/SecretResolver.ts";
import * as DeviceHost from "./DeviceHost.ts";
import * as SshDeviceHost from "./SshDeviceHost.ts";
import * as EasDeviceHost from "./EasDeviceHost.ts";
import * as SimlockDeviceHost from "./SimlockDeviceHost.ts";
import * as HostProcess from "@t3tools/shared/HostProcess";
import { makeExpoApi, resolveExpoCredential } from "./ExpoApi.ts";
import { makeExpoApi } from "./ExpoApi.ts";
import * as Exit from "effect/Exit";
import * as LocalDeviceHost from "./LocalDeviceHost.ts";

Expand Down Expand Up @@ -1220,6 +1222,7 @@ export const make = Effect.gen(function* () {
const path = yield* Path.Path;
const runner = yield* ProcessRunner.ProcessRunner;
const settings = yield* ServerSettings.ServerSettingsService;
const secrets = yield* SecretResolver.SecretResolver;
const scope = yield* Scope.Scope;
const hosts = new Map<DeviceHostId, DeviceHost.DeviceHost["Service"]>([
[localHost.id, localHost],
Expand Down Expand Up @@ -1537,7 +1540,10 @@ export const make = Effect.gen(function* () {
? ""
: ((yield* settings.getSettings).easDeviceHosts.find((host) => host.id === input.hostId)
?.accessToken ?? "");
const credential = yield* resolveExpoCredential(input.accessToken ?? saved);
const credential = yield* EasDeviceHost.resolveSavedExpoCredential(
secrets,
input.accessToken ?? saved,
);
const viewer = yield* makeExpoApi(yield* HttpClient.HttpClient, credential).viewer;
return { signedInAs: viewer.name, credentialSource: credential.source, apps: viewer.apps };
},
Expand Down Expand Up @@ -1618,7 +1624,10 @@ export const make = Effect.gen(function* () {
};
});

export const layer = Layer.effect(DeviceService, make).pipe(Layer.provide(LocalDeviceHost.layer));
export const layer = Layer.effect(DeviceService, make).pipe(
Layer.provide(LocalDeviceHost.layer),
Layer.provide(SecretResolverLayer.layer),
);

export const stateStream = (service: DeviceService["Service"]): Stream.Stream<DeviceServiceState> =>
Stream.unwrap(
Expand Down
78 changes: 76 additions & 2 deletions apps/server/src/device/EasDeviceHost.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { describe, expect, it } from "@effect/vitest";
import * as NodeCrypto from "@effect/platform-node/NodeCrypto";
import * as NodeServices from "@effect/platform-node/NodeServices";
import { type EasDeviceHostConfig, ThreadId } from "@t3tools/contracts";
import * as HostProcess from "@t3tools/shared/HostProcess";
import { type EasDeviceHostConfig, SecretProviderId, ThreadId } from "@t3tools/contracts";
import * as SecretResolver from "@t3tools/secret-provider-core/server/SecretResolver";
import * as Deferred from "effect/Deferred";
import * as Effect from "effect/Effect";
import * as Fiber from "effect/Fiber";
Expand All @@ -13,6 +13,7 @@ import * as Path from "effect/Path";
import * as Scope from "effect/Scope";
import * as TestClock from "effect/testing/TestClock";
import { HttpClient, HttpClientResponse } from "effect/http";
import * as HostProcess from "@t3tools/shared/HostProcess";

import * as ServerConfig from "../config.ts";
import * as ProcessRunner from "../processRunner.ts";
Expand Down Expand Up @@ -228,9 +229,24 @@ const fakeExpo = (
return { http, named, stopped, allowPreview, allowCreate, releases, previews };
};

const makeSecretResolver = (values: Record<string, string> = {}) =>
Layer.succeed(SecretResolver.SecretResolver, {
read: (references) =>
Effect.succeed(references.map((reference) => values[reference.reference])),
invalidate: () => Effect.void,
});

const layerWith = (secrets: Layer.Layer<SecretResolver.SecretResolver>) =>
Layer.mergeAll(
ProcessRunner.layer,
ServerConfig.layerTest(process.cwd(), { prefix: "t3-eas-host-" }),
secrets,
).pipe(Layer.provideMerge(NodeServices.layer));

const layer = Layer.mergeAll(
ProcessRunner.layer,
ServerConfig.layerTest(process.cwd(), { prefix: "t3-eas-host-" }),
makeSecretResolver(),
).pipe(Layer.provideMerge(NodeServices.layer));

const makeHost = (
Expand Down Expand Up @@ -299,6 +315,64 @@ describe("EAS Simulator host", () => {
}).pipe(Effect.scoped, Effect.provide(layer)),
);

it.effect("reads its token from the secret provider the setting names", () =>
Effect.gen(function* () {
const expo = fakeExpo();
const source = { provider: SecretProviderId.make("homelab"), reference: "expo#token" };
const host = yield* makeHost(expo, config({ accessToken: source }), yield* Scope.make());
const ready = yield* host.ensureReady(() => Effect.void);
const [catalogDevice] = (yield* ready.devices.list).devices;
expect(yield* ready.devices.boot(catalogDevice!)).toBe("session-1");
}).pipe(
Effect.scoped,
Effect.provide(layerWith(makeSecretResolver({ "expo#token": "expo-token" }))),
),
);

it.effect("fails instead of using another Expo login when its secret cannot be read", () =>
Effect.gen(function* () {
const expo = fakeExpo();
const source = { provider: SecretProviderId.make("homelab"), reference: "expo#token" };
const host = yield* makeHost(expo, config({ accessToken: source }), yield* Scope.make()).pipe(
Effect.provideService(HostProcess.Environment, { EXPO_TOKEN: "expo-token" }),
);
const ready = yield* host.ensureReady(() => Effect.void);
const [catalogDevice] = (yield* ready.devices.list).devices;
const failure = yield* ready.devices.boot(catalogDevice!).pipe(Effect.flip);
expect(failure.message).toContain('secret provider "homelab"');
expect(expo.named("T3CreateEasSimulatorSession")).toHaveLength(0);
}).pipe(Effect.scoped, Effect.provide(layer)),
);

it.effect("still stops its sessions when the secret becomes unreadable after they start", () => {
const readable = { value: true };
return Effect.gen(function* () {
const expo = fakeExpo();
const source = { provider: SecretProviderId.make("homelab"), reference: "expo#token" };
const scope = yield* Scope.make();
const host = yield* makeHost(expo, config({ accessToken: source }), scope);
const ready = yield* host.ensureReady(() => Effect.void);
const [catalogDevice] = (yield* ready.devices.list).devices;
expect(yield* ready.devices.boot(catalogDevice!)).toBe("session-1");

readable.value = false;
yield* ready.devices.shutdown({ ...catalogDevice!, id: "session-1" });
expect(expo.stopped()).toEqual(["session-1"]);
yield* Scope.close(scope, Exit.void);
}).pipe(
Effect.scoped,
Effect.provide(
layerWith(
Layer.succeed(SecretResolver.SecretResolver, {
read: (references) =>
Effect.succeed(references.map(() => (readable.value ? "expo-token" : undefined))),
invalidate: () => Effect.void,
}),
),
),
);
});

it.effect("runs an Android session through serve-emu beside an iOS one", () =>
Effect.gen(function* () {
const expo = fakeExpo();
Expand Down
34 changes: 29 additions & 5 deletions apps/server/src/device/EasDeviceHost.ts
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Resolved
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
DeviceOperationError,
type DevicePlatform,
type EasDeviceHostConfig,
type SettingsSecretValue,
} from "@t3tools/contracts";
import * as Cause from "effect/Cause";
import * as Clock from "effect/Clock";
Expand All @@ -32,6 +33,8 @@ import { writeFileStringAtomically } from "@t3tools/shared/atomicWrite";
import * as HostProcess from "@t3tools/shared/HostProcess";

import * as ServerConfig from "../config.ts";
import * as SecretResolver from "@t3tools/secret-provider-core/server/SecretResolver";
import { requireSettingsSecret } from "../secrets/settingsSecretValue.ts";
import { releaseAgentDeviceSession } from "./AgentDeviceTarget.ts";
import * as DeviceHost from "./DeviceHost.ts";
import { findEasProjectId } from "./easProjectId.ts";
Expand Down Expand Up @@ -140,11 +143,21 @@ const decodePathSegment = (segment: string): string | null => {
}
};

/**
* The Expo credential a host's setting holds or names. Every session bills the account behind
* it, so a named secret that cannot be read fails rather than falling back to another login.
*/
export const resolveSavedExpoCredential = (
secrets: SecretResolver.SecretResolver["Service"],
saved: SettingsSecretValue,
) => requireSettingsSecret(secrets, saved).pipe(Effect.flatMap(resolveExpoCredential));

export const make = Effect.fn("EasDeviceHost.make")(function* (config: EasDeviceHostConfig) {
const http = yield* HttpClient.HttpClient;
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const server = yield* ServerConfig.ServerConfig;
const secrets = yield* SecretResolver.SecretResolver;
const services = yield* Effect.context<
FileSystem.FileSystem | Path.Path | Effect.Services<ReturnType<typeof findEasProjectId>>
>();
Expand Down Expand Up @@ -198,13 +211,24 @@ export const make = Effect.fn("EasDeviceHost.make")(function* (config: EasDevice
...(cause === undefined ? {} : { cause }),
});

const api: Effect.Effect<ExpoApi, DeviceHostUnavailableError> = resolveExpoCredential(
let lastApi: ExpoApi | undefined;
const api: Effect.Effect<ExpoApi, DeviceHostUnavailableError> = resolveSavedExpoCredential(
secrets,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
config.accessToken,
).pipe(
Effect.map((credential) => makeExpoApi(http, credential)),
Effect.tap((expo) =>
Effect.sync(() => {
lastApi = expo;
}),
),
Effect.provide(services),
Effect.mapError((error) => unavailable(error.message, error)),
);
/** Stopping a billed session must not depend on the secret still being readable; starting one must. */
const apiForStop = api.pipe(
Effect.catch((error) => (lastApi === undefined ? Effect.fail(error) : Effect.succeed(lastApi))),
);

/** A missing ledger is empty; an unreadable one is an error, never an empty list. */
const readLedger = fs.readFileString(ledgerPath).pipe(
Expand Down Expand Up @@ -626,7 +650,7 @@ export const make = Effect.fn("EasDeviceHost.make")(function* (config: EasDevice
if (stale.length === 0 && unresolved.size === 0 && uncertain.size === 0 && !reapDue) return;
const reap = reapDue || uncertain.size > 0;
if (reap) reapedAt = now;
const expo = yield* api.pipe(
const expo = yield* apiForStop.pipe(
// Without a credential no sweep can look; a record Expo would have
// listed by now still stops counting, so the limit is not held forever.
Effect.tapError(() =>
Expand Down Expand Up @@ -723,7 +747,7 @@ export const make = Effect.fn("EasDeviceHost.make")(function* (config: EasDevice
Option.match(sessionFor(device.id), {
onNone: () => Effect.void,
onSome: (session) =>
api.pipe(
apiForStop.pipe(
Effect.flatMap((expo) => stopTracked(expo, session)),
Effect.asVoid,
Effect.mapError(
Expand Down Expand Up @@ -853,7 +877,7 @@ export const make = Effect.fn("EasDeviceHost.make")(function* (config: EasDevice
// Interrupted starts stop the sessions they created before this returns.
yield* FiberSet.clear(starts);
yield* FiberSet.clear(background);
const expo = yield* api;
const expo = yield* apiForStop;
yield* Effect.forEach(
[...sessions.values()],
(session) => stopTracked(expo, session).pipe(Effect.ignore),
Expand Down Expand Up @@ -884,7 +908,7 @@ export const make = Effect.fn("EasDeviceHost.make")(function* (config: EasDevice
// Each server signs in on its own, so one with no Expo credential answers for itself. Reading
// the credential is local; whether Expo accepts it is only known once a session starts.
const platformAvailability = (platform: DevicePlatform) =>
resolveExpoCredential(config.accessToken).pipe(
resolveSavedExpoCredential(secrets, config.accessToken).pipe(
Effect.provide(services),
Effect.as({ platform, available: true }),
Effect.catch((error) =>
Expand Down
35 changes: 33 additions & 2 deletions apps/server/src/secrets/settingsSecretValue.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import { secretReferencesInSettings, withSettingsSecrets } from "@t3tools/contracts";
import {
isSecretReference,
secretReferencesInSettings,
type SettingsSecretValue,
withSettingsSecrets,
} from "@t3tools/contracts";
import type * as SecretResolver from "@t3tools/secret-provider-core/server/SecretResolver";
import * as Effect from "effect/Effect";
import type * as Schema from "effect/Schema";
import * as Schema from "effect/Schema";

/**
* Replaces each reference in the slots `schema` declares as `SettingsSecretValue` with the secret
Expand All @@ -18,3 +23,29 @@ export const resolveSettingsSecrets = Effect.fn("SettingsSecrets.resolve")(funct
pending.map((slot, index) => ({ ...slot, value: values[index] ?? "" })),
);
});

export class SettingsSecretUnresolvedError extends Schema.TaggedError<SettingsSecretUnresolvedError>()(
"SettingsSecretUnresolvedError",
{ provider: Schema.String },
) {
override get message(): string {
return `Could not read the credential from the secret provider "${this.provider}". Unlock or fix it in Settings, then try again.`;
}
}

/**
* The secret a credential setting holds or names. A named secret that cannot be read, or reads
* empty, fails instead of passing for a missing token, so callers that would fall back to another
* credential cannot silently switch accounts.
*/
export const requireSettingsSecret = Effect.fn("SettingsSecrets.require")(function* (
resolver: SecretResolver.SecretResolver["Service"],
value: SettingsSecretValue,
) {
if (!isSecretReference(value)) return value;
const [secret] = yield* resolver.read([value]);
if (secret === undefined || secret.trim() === "") {
return yield* new SettingsSecretUnresolvedError({ provider: value.provider });
}
return secret;
});
Loading
Loading