diff --git a/apps/server/src/device/DeviceService.test.ts b/apps/server/src/device/DeviceService.test.ts index 8c7b400a2c5de..a551ffdf5a875 100644 --- a/apps/server/src/device/DeviceService.test.ts +++ b/apps/server/src/device/DeviceService.test.ts @@ -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"; @@ -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(); @@ -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, + ), ), ), ); @@ -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, diff --git a/apps/server/src/device/DeviceService.ts b/apps/server/src/device/DeviceService.ts index 2f6df7dace573..b18cf8a516a98 100644 --- a/apps/server/src/device/DeviceService.ts +++ b/apps/server/src/device/DeviceService.ts @@ -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"; @@ -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([ [localHost.id, localHost], @@ -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 }; }, @@ -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 => Stream.unwrap( diff --git a/apps/server/src/device/EasDeviceHost.test.ts b/apps/server/src/device/EasDeviceHost.test.ts index b5825d3f973e7..47d74e65b4743 100644 --- a/apps/server/src/device/EasDeviceHost.test.ts +++ b/apps/server/src/device/EasDeviceHost.test.ts @@ -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"; @@ -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"; @@ -228,9 +229,24 @@ const fakeExpo = ( return { http, named, stopped, allowPreview, allowCreate, releases, previews }; }; +const makeSecretResolver = (values: Record = {}) => + Layer.succeed(SecretResolver.SecretResolver, { + read: (references) => + Effect.succeed(references.map((reference) => values[reference.reference])), + invalidate: () => Effect.void, + }); + +const layerWith = (secrets: Layer.Layer) => + 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 = ( @@ -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(); diff --git a/apps/server/src/device/EasDeviceHost.ts b/apps/server/src/device/EasDeviceHost.ts index e82638dbd76fa..719cfbf435a8a 100644 --- a/apps/server/src/device/EasDeviceHost.ts +++ b/apps/server/src/device/EasDeviceHost.ts @@ -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"; @@ -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"; @@ -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> >(); @@ -198,13 +211,24 @@ export const make = Effect.fn("EasDeviceHost.make")(function* (config: EasDevice ...(cause === undefined ? {} : { cause }), }); - const api: Effect.Effect = resolveExpoCredential( + let lastApi: ExpoApi | undefined; + const api: Effect.Effect = resolveSavedExpoCredential( + secrets, 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( @@ -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(() => @@ -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( @@ -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), @@ -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) => diff --git a/apps/server/src/secrets/settingsSecretValue.ts b/apps/server/src/secrets/settingsSecretValue.ts index 83a0adc5f11dd..50a99b953d327 100644 --- a/apps/server/src/secrets/settingsSecretValue.ts +++ b/apps/server/src/secrets/settingsSecretValue.ts @@ -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 @@ -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", + { 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; +}); diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index 726756655740f..0e7ce34df0eae 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -1622,6 +1622,10 @@ it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))("server settings" provider: SecretProviderId.make("personal"), reference: "op://Private/Hub/key", }; + const easReference = { + provider: SecretProviderId.make("personal"), + reference: "op://Private/Expo/token", + }; const hub = UsageLimitSourceId.make("hub"); const withUsage = yield* serverSettings.updateSettings({ usageLimitSources: { @@ -1632,6 +1636,17 @@ it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))("server settings" enabled: true, }, }, + easDeviceHosts: [ + { + kind: "eas", + id: "eas", + label: "EAS Simulator", + accessToken: easReference, + maxSessionMinutes: 30, + idleMinutes: 10, + maxConcurrentSessions: 2, + }, + ], }); const legacy = ServerSettingsModule.redactServerSettingsForClient(withUsage, { secretReferences: false, @@ -1639,9 +1654,11 @@ it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))("server settings" assert.notInclude(JSON.stringify(legacy), "op://"); assert.equal(legacy.sourceControlHosts.bitbucket?.accessToken, SECRET_REDACTED); assert.equal(legacy.usageLimitSources[hub]?.managementKey, SECRET_REDACTED); + assert.equal(legacy.easDeviceHosts[0]?.accessToken, SECRET_REDACTED); const echoed = yield* serverSettings.updateSettings({ sourceControlHosts: legacy.sourceControlHosts, usageLimitSources: legacy.usageLimitSources, + easDeviceHosts: legacy.easDeviceHosts, }); assert.deepEqual(echoed.sourceControlHosts.bitbucket?.accessToken, reference); assert.deepEqual(echoed.sourceControlHosts.github?.tokens, { @@ -1649,6 +1666,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))("server settings" "ghe.acme.test": "ghp_ghe", }); assert.deepEqual(echoed.usageLimitSources[hub]?.managementKey, usageReference); + assert.deepEqual(echoed.easDeviceHosts[0]?.accessToken, easReference); assert.isTrue(Option.isSome(yield* stored("github", "tokens", "ghe.acme.test"))); }).pipe(Effect.provide(layerServerSettingsWithSecrets())), ); @@ -1657,6 +1675,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))("server settings" Effect.sync(() => { // Shipped clients decode these as strings. Each must be covered by the older-client case above. assert.deepEqual(strictSecretReferencePaths(ServerSettings.ast), [ + "easDeviceHosts.[].accessToken", "usageLimitSources.*.managementKey", ]); }), diff --git a/apps/server/src/sourceControl/sourceControlHostSecrets.test.ts b/apps/server/src/sourceControl/sourceControlHostSecrets.test.ts index 697e166c324a4..974fc7cc91c49 100644 --- a/apps/server/src/sourceControl/sourceControlHostSecrets.test.ts +++ b/apps/server/src/sourceControl/sourceControlHostSecrets.test.ts @@ -1,5 +1,5 @@ import { assert, it } from "@effect/vitest"; -import { SecretProviderId, UsageLimitSourceId } from "@t3tools/contracts"; +import { DeviceHostId, SecretProviderId, UsageLimitSourceId } from "@t3tools/contracts"; import * as SecretResolver from "@t3tools/secret-provider-core/server/SecretResolver"; import * as Effect from "effect/Effect"; @@ -139,7 +139,19 @@ it.effect("reads a collected reference again once it is invalidated", () => enabled: true, }, } as const; + const easDeviceHosts = [ + { + kind: "eas" as const, + id: DeviceHostId.make("ref"), + label: "Ref", + maxSessionMinutes: 30, + idleMinutes: 10, + maxConcurrentSessions: 2, + accessToken: source("expo#token"), + }, + ]; cache.set("cliproxy#key", "stale"); + cache.set("expo#token", "stale"); assert.deepEqual((yield* read()).bitbucket, { email: "me@example.test", accessToken: "stale", @@ -147,9 +159,10 @@ it.effect("reads a collected reference again once it is invalidated", () => current = "rotated"; yield* invalidateSettingsSecrets( resolver, - Effect.succeed({ sourceControlHosts: hosts, usageLimitSources }), + Effect.succeed({ sourceControlHosts: hosts, usageLimitSources, easDeviceHosts }), ); assert.isFalse(cache.has("cliproxy#key")); + assert.isFalse(cache.has("expo#token")); assert.deepEqual((yield* read()).bitbucket, { email: "me@example.test", accessToken: "rotated", diff --git a/apps/server/src/sourceControl/sourceControlHostSecrets.ts b/apps/server/src/sourceControl/sourceControlHostSecrets.ts index 32d2f41f9c504..21213d27bdb8b 100644 --- a/apps/server/src/sourceControl/sourceControlHostSecrets.ts +++ b/apps/server/src/sourceControl/sourceControlHostSecrets.ts @@ -104,15 +104,24 @@ const collectUsageLimitSecretReferences = ( isSecretReference(managementKey) ? [managementKey] : [], ); +const collectEasSecretReferences = ( + hosts: ServerSettings["easDeviceHosts"], +): ReadonlyArray => + hosts.flatMap(({ accessToken }) => (isSecretReference(accessToken) ? [accessToken] : [])); + /** Settings that cannot be read clear every cached secret, so a refresh never keeps a stale one. */ export const invalidateSettingsSecrets = Effect.fn("SettingsSecrets.invalidate")(function* ( resolver: SecretResolver.SecretResolver["Service"], - settings: Effect.Effect, E>, + settings: Effect.Effect< + Pick, + E + >, ) { const references = yield* settings.pipe( Effect.map((current) => [ ...collectSourceControlHostSecretReferences(current.sourceControlHosts), ...collectUsageLimitSecretReferences(current.usageLimitSources), + ...collectEasSecretReferences(current.easDeviceHosts), ]), Effect.orElseSucceed(() => undefined), ); diff --git a/apps/server/src/usage/UsageLimitSources.test.ts b/apps/server/src/usage/UsageLimitSources.test.ts index 1d955d497ef6b..7b078b7b61202 100644 --- a/apps/server/src/usage/UsageLimitSources.test.ts +++ b/apps/server/src/usage/UsageLimitSources.test.ts @@ -5,6 +5,7 @@ import * as SecretResolver from "@t3tools/secret-provider-core/server/SecretReso import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; import { HttpClient, HttpClientResponse } from "effect/http"; import * as BackgroundPolicy from "../background/BackgroundPolicy.ts"; @@ -97,8 +98,11 @@ it.effect("reads a management key from its secret provider", () => { }); return Effect.gen(function* () { const sources = yield* UsageLimitSources.make; - yield* sources.refresh; - const snapshots = yield* sources.current; + const snapshots = yield* sources.streamChanges.pipe( + Stream.filter((next) => next.length > 0), + Stream.runHead, + Effect.flatMap(Effect.fromOption), + ); expect(snapshots.map(({ id, error }) => ({ id, error }))).toEqual([ { id: "resolved-hub", error: undefined }, { diff --git a/apps/web/src/components/settings/AddUsageLimitSourceDialog.tsx b/apps/web/src/components/settings/AddUsageLimitSourceDialog.tsx index 12b9bbe820869..780fba4a7007d 100644 --- a/apps/web/src/components/settings/AddUsageLimitSourceDialog.tsx +++ b/apps/web/src/components/settings/AddUsageLimitSourceDialog.tsx @@ -36,14 +36,16 @@ export function AddUsageLimitSourceDialog({ const [url, setUrl] = useState(""); const secretSources = useSecretSources(environmentId); const [keyDraft, setKeyDraft] = useState(""); + const [keyValid, setKeyValid] = useState(true); const managementKey = typeof keyDraft === "string" ? keyDraft.trim() : keyDraft; const trimmedUrl = url.trim(); - const canSave = trimmedUrl.length > 0 && managementKey !== ""; + const canSave = keyValid && trimmedUrl.length > 0 && managementKey !== ""; const reset = () => { setLabel(""); setUrl(""); setKeyDraft(""); + setKeyValid(true); }; const save = () => { @@ -107,8 +109,7 @@ export function AddUsageLimitSourceDialog({ sources={secretSources} onChange={(event) => setKeyDraft(event.target.value)} onSecretChange={(secret) => setKeyDraft(secret ?? "")} - onProviderPick={() => setKeyDraft("")} - onReferenceReject={() => setKeyDraft("")} + onValidityChange={setKeyValid} />
diff --git a/apps/web/src/components/settings/EasDeviceHostEditor.tsx b/apps/web/src/components/settings/EasDeviceHostEditor.tsx index 220f5ffa3269b..1223fe09b7d7f 100644 --- a/apps/web/src/components/settings/EasDeviceHostEditor.tsx +++ b/apps/web/src/components/settings/EasDeviceHostEditor.tsx @@ -3,9 +3,11 @@ import { useId, useRef, useState } from "react"; import * as Cause from "effect/Cause"; import * as Option from "effect/Option"; import { + isSecretReference, type DeviceEasAccount, type EasDeviceHostConfig, type EnvironmentId, + type SettingsSecretValue, } from "@t3tools/contracts"; import { XIcon } from "lucide-react"; @@ -25,7 +27,7 @@ import { Input } from "../ui/input"; import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; import { Spinner } from "../ui/spinner"; import { parseEasDeviceHostDraft } from "./easDeviceHostsSettings.logic"; -import { SecretInput } from "./SecretInput"; +import { SecretSourceInput, useSecretSources } from "./SecretSourceInput"; const FROM_THREAD_PROJECT = "thread-project"; @@ -57,9 +59,12 @@ export function EasDeviceHostEditor(props: { const check = useAtomCommand(deviceEnvironment.easAccount, { reportFailure: false }); const [draft, setDraft] = useState(props.host); const tokenId = useId(); + const secretSources = useSecretSources(props.environmentId); + const [tokenValid, setTokenValid] = useState(true); + const [tokenInputKey, setTokenInputKey] = useState(0); // A saved token arrives redacted; only a replacement or a clear changes it. const [token, setToken] = useState< - { kind: "saved" } | { kind: "new"; value: string } | { kind: "cleared" } + { kind: "saved" } | { kind: "new"; value: SettingsSecretValue } | { kind: "cleared" } >(props.host.accessToken ? { kind: "saved" } : { kind: "cleared" }); // Checking on open names the saved project and shows a rejected credential // before anything is changed. A manual check or a token edit replaces it. @@ -87,13 +92,15 @@ export function EasDeviceHostEditor(props: { accessToken: token.kind === "saved" ? props.host.accessToken : token.kind === "new" ? token.value : "", }; - const valid = Option.isSome(parseEasDeviceHostDraft(next)); + const valid = tokenValid && Option.isSome(parseEasDeviceHostDraft(next)); // Why Save is off, for the limits the fields' ranges do not show. const problem = valid ? null - : draft.maxSessionMinutes > 0 && draft.idleMinutes >= draft.maxSessionMinutes - ? "Idle minutes must be smaller than session minutes." - : "Give the host a label and limits within the ranges shown."; + : !tokenValid + ? "Enter a valid secret reference." + : draft.maxSessionMinutes > 0 && draft.idleMinutes >= draft.maxSessionMinutes + ? "Idle minutes must be smaller than session minutes." + : "Give the host a label and limits within the ranges shown."; const apps = account.status === "signed-in" ? account.account.apps : []; // A check answers for the credential it was asked about: a later check or a @@ -192,11 +199,28 @@ export function EasDeviceHostEditor(props: { Expo access token
- { + setTokenValid(valid); + resetAccount(); + }} + onSecretChange={(secret) => { + setToken(secret ? { kind: "new", value: secret } : { kind: "cleared" }); + resetAccount(); + }} disabled={props.busy} placeholder="Optional" onChange={(event) => { @@ -221,6 +245,8 @@ export function EasDeviceHostEditor(props: { aria-label="Clear access token" onClick={() => { setToken({ kind: "cleared" }); + setTokenValid(true); + setTokenInputKey((key) => key + 1); resetAccount(); }} > @@ -243,7 +269,7 @@ export function EasDeviceHostEditor(props: { type="button" size="sm" variant="outline" - disabled={props.busy || account.status === "checking"} + disabled={props.busy || !tokenValid || account.status === "checking"} onClick={() => void checkAccount()} > {account.status === "checking" ? : null} Check account diff --git a/apps/web/src/components/settings/SecretSourceInput.browser.test.tsx b/apps/web/src/components/settings/SecretSourceInput.browser.test.tsx index 18ff271e97974..f7c598ab10568 100644 --- a/apps/web/src/components/settings/SecretSourceInput.browser.test.tsx +++ b/apps/web/src/components/settings/SecretSourceInput.browser.test.tsx @@ -23,7 +23,7 @@ async function commitReference(screen: Screen, value: string) { describe("SecretSourceInput", () => { it("moves a reference to another provider only once one is committed for it", async () => { const onSecretChange = vi.fn(); - const onProviderPick = vi.fn(); + const onValidityChange = vi.fn(); await using screen = await render( { sources={sources} onCommit={() => {}} onSecretChange={onSecretChange} - onProviderPick={onProviderPick} + onValidityChange={onValidityChange} />, ); await userEvent.click(screen.getByRole("combobox", { name: "API key source" })); await userEvent.click(screen.getByRole("option", { name: "Work" })); expect(onSecretChange).not.toHaveBeenCalled(); - expect(onProviderPick).toHaveBeenCalledOnce(); + expect(onValidityChange).toHaveBeenLastCalledWith(false); await expect.element(reference(screen)).toHaveValue(""); await commitReference(screen, "op://Shared/OpenAI/credential"); @@ -46,6 +46,7 @@ describe("SecretSourceInput", () => { provider: work, reference: "op://Shared/OpenAI/credential", }); + expect(onValidityChange).toHaveBeenLastCalledWith(true); }); it("removes the reference when its field is cleared", async () => { @@ -69,7 +70,7 @@ describe("SecretSourceInput", () => { it("rejects a reference 1Password cannot read", async () => { const onSecretChange = vi.fn(); - const onReferenceReject = vi.fn(); + const onValidityChange = vi.fn(); await using screen = await render( { sources={sources} onCommit={() => {}} onSecretChange={onSecretChange} - onReferenceReject={onReferenceReject} + onValidityChange={onValidityChange} />, ); await commitReference(screen, "Private/OpenAI/credential"); expect(onSecretChange).not.toHaveBeenCalled(); - expect(onReferenceReject).toHaveBeenCalledOnce(); - await screen.rerender( - {}} - onSecretChange={onSecretChange} - onReferenceReject={onReferenceReject} - />, - ); + expect(onValidityChange).toHaveBeenLastCalledWith(false); await expect.element(reference(screen)).toHaveAttribute("aria-invalid", "true"); await expect.element(reference(screen)).toHaveValue("Private/OpenAI/credential"); - await commitReference(screen, "op://Private/OpenAI/corrected"); + await commitReference(screen, "op://Private/OpenAI/replacement"); + expect(onValidityChange).toHaveBeenLastCalledWith(true); expect(onSecretChange).toHaveBeenCalledExactlyOnceWith({ provider: personal, - reference: "op://Private/OpenAI/corrected", + reference: "op://Private/OpenAI/replacement", }); }); }); diff --git a/apps/web/src/components/settings/SecretSourceInput.tsx b/apps/web/src/components/settings/SecretSourceInput.tsx index 83ca9ad2ced71..67053a054db38 100644 --- a/apps/web/src/components/settings/SecretSourceInput.tsx +++ b/apps/web/src/components/settings/SecretSourceInput.tsx @@ -64,10 +64,8 @@ type SecretSourceInputProps = Omit, "value" | readonly value: string | SecretReference; readonly sources: ReadonlyArray; readonly onSecretChange: (secret: SecretReference | undefined) => void; - /** Called when a provider is picked, before a reference for it exists. */ - readonly onProviderPick?: () => void; - /** Called when an edited reference is rejected, so the committed one no longer matches the field. */ - readonly onReferenceReject?: () => void; + /** Whether the selected source and committed field can be saved or used. */ + readonly onValidityChange?: (valid: boolean) => void; /** Places the parts in the caller's layout; `source` is null when there is nothing to pick. */ readonly children?: (parts: { readonly source: ReactNode; @@ -80,8 +78,7 @@ export function SecretSourceInput({ value, sources, onSecretChange, - onProviderPick, - onReferenceReject, + onValidityChange, children, ...rest }: SecretSourceInputProps) { @@ -108,11 +105,12 @@ export function SecretSourceInput({ setRejectedReference(null); if (value === STORED_VALUE || value === null) { setPendingProvider(null); + onValidityChange?.(true); if (secret !== undefined) onSecretChange(undefined); return; } const next = value as SecretProviderId; - if (next !== secret?.provider) onProviderPick?.(); + onValidityChange?.(next === secret?.provider); setPendingProvider(next === secret?.provider ? null : next); }} > @@ -157,6 +155,7 @@ export function SecretSourceInput({ onCommit={(next) => { if (next.trim().length === 0) { setRejectedReference(null); + onValidityChange?.(pendingProvider === null && secret !== undefined); if (pendingProvider === null && secret !== undefined) onSecretChange(undefined); return; } @@ -166,12 +165,12 @@ export function SecretSourceInput({ definition?.isReference(decoded.value.reference) === false ) { setRejectedReference(next); - if (onReferenceReject !== undefined) setPendingProvider(provider); - onReferenceReject?.(); + onValidityChange?.(false); return; } setRejectedReference(null); setPendingProvider(null); + onValidityChange?.(true); onSecretChange(decoded.value); }} autoComplete="off" diff --git a/packages/contracts/src/device.ts b/packages/contracts/src/device.ts index 24b72dbe26d7a..417d1c81b418e 100644 --- a/packages/contracts/src/device.ts +++ b/packages/contracts/src/device.ts @@ -3,6 +3,7 @@ import * as Schema from "effect/Schema"; import * as SchemaTransformation from "effect/SchemaTransformation"; import { ThreadId, TrimmedNonEmptyString, TrimmedString } from "./baseSchemas.ts"; +import { SettingsSecretValue } from "./settingsSecretValue.ts"; export const DevicePlatform = Schema.Literals(["ios", "android"]); export type DevicePlatform = typeof DevicePlatform.Type; @@ -40,11 +41,11 @@ export const EasDeviceHostConfig = Schema.Struct({ /** EAS project that sessions run under, when the project's app config does not name one. */ appId: Schema.optional(TrimmedNonEmptyString), /** - * Expo access token, kept in the secret store. When empty the server uses - * EXPO_TOKEN, then the `eas login` session. This is `secretSetting` from - * settings.ts, which imports this module. + * Expo access token, kept in the secret store, or the secret provider to read + * it from. When empty the server uses EXPO_TOKEN, then the `eas login` session. + * This is `secretSetting` from settings.ts, which imports this module. */ - accessToken: TrimmedString.pipe( + accessToken: SettingsSecretValue.pipe( Schema.withDecodingDefaultKey(Effect.succeed("")), Schema.annotateKey({ settingsSecret: true }), ), @@ -116,7 +117,7 @@ export const SimlockDeviceHostConfigs = Schema.Array(SimlockDeviceHostConfig).ch export const DeviceEasAccountInput = Schema.Struct({ /** A saved EAS host whose stored token is checked when no new token is given. */ hostId: Schema.optional(DeviceHostId), - accessToken: Schema.optional(TrimmedString), + accessToken: Schema.optional(SettingsSecretValue), }); export type DeviceEasAccountInput = typeof DeviceEasAccountInput.Type;