diff --git a/.github/workflows/fork-ci.yml b/.github/workflows/fork-ci.yml index ecd5c3a1683b..b009ef402d83 100644 --- a/.github/workflows/fork-ci.yml +++ b/.github/workflows/fork-ci.yml @@ -8,6 +8,7 @@ on: pull_request: branches: - fork + - "feat/**" push: branches: - fork diff --git a/.github/workflows/fork-identity-qa.yml b/.github/workflows/fork-identity-qa.yml index f9472f8af8da..ab7583be3563 100644 --- a/.github/workflows/fork-identity-qa.yml +++ b/.github/workflows/fork-identity-qa.yml @@ -2,7 +2,7 @@ name: Fork identity browser QA on: pull_request: - branches: [fork] + branches: [fork, "feat/**"] paths: - "apps/web/**" - "packages/client-runtime/**" diff --git a/apps/mobile/src/fork/prism/MicPrismPairingSection.tsx b/apps/mobile/src/fork/prism/MicPrismPairingSection.tsx new file mode 100644 index 000000000000..af8e9f2138c9 --- /dev/null +++ b/apps/mobile/src/fork/prism/MicPrismPairingSection.tsx @@ -0,0 +1,333 @@ +import type { MicIdentityAccess } from "@q1code/core/micIdentityApi"; +import { + createMicPrismPairingController, + type MicPrismPairingClientInput, +} from "@t3tools/client-runtime/fork"; +import * as Clipboard from "expo-clipboard"; +import { useEffect, useLayoutEffect, useRef, useState, useSyncExternalStore } from "react"; +import { Pressable, View } from "react-native"; + +import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; +import { SettingsSection } from "../../features/settings/components/SettingsSection"; +import { runtime } from "../../lib/runtime"; + +export function MicPrismPairingSection(props: { + readonly input: MicPrismPairingClientInput; + readonly access: MicIdentityAccess; + readonly onChanged: () => void; +}) { + if (!props.access.session.permissions.includes("prism:instances:manage")) return null; + return ( + + ); +} + +/** The host keeps its signing key. Mobile only transports public challenge/proof material. */ +function PairingForm({ input, access, onChanged }: Parameters[0]) { + const [expanded, setExpanded] = useState(false); + const [label, setLabel] = useState(""); + const [origin, setOrigin] = useState(""); + const [publicKey, setPublicKey] = useState(""); + const [signature, setSignature] = useState(""); + const [copied, setCopied] = useState(false); + const [selection, setSelection] = useState(null); + const current = useRef({ input, onChanged }); + useLayoutEffect(() => { + current.current = { input, onChanged }; + }, [input, onChanged]); + const [controller] = useState(() => + createMicPrismPairingController({ + input: { + baseUrl: input.baseUrl, + getToken: () => current.current.input.getToken(), + isCurrent: () => current.current.input.isCurrent(), + }, + access, + run: (effect, signal) => runtime.runPromise(effect, { signal }), + onChanged: () => current.current.onChanged(), + }), + ); + useLayoutEffect(() => { + controller.updateAccess(access); + }, [access, controller]); + useEffect(() => { + controller.activate(); + return () => controller.dispose(); + }, [controller]); + const state = useSyncExternalStore( + controller.subscribe, + controller.getSnapshot, + controller.getSnapshot, + ); + const { challenge, paired, confirmation, busy, error, notice } = state; + const service = access.discovery.service; + const reset = () => { + controller.resetPairing(); + setSignature(""); + setCopied(false); + setSelection(null); + }; + const publicText = (value: string, accept: (value: string) => void) => { + if (/PRIVATE KEY/.test(value)) { + controller.setError("Keep private keys on the host. Paste only the public key or signature."); + return; + } + accept(value); + }; + const copyChallenge = async () => { + if (!challenge) return; + try { + const written = await Clipboard.setStringAsync(challenge.challenge); + if (controller.getSnapshot().challenge !== challenge || !input.isCurrent()) return; + if (written) setCopied(true); + else controller.setError("Copy is unavailable. Select and copy the full challenge below."); + } catch { + if (input.isCurrent()) + controller.setError("Copy is unavailable. Select and copy the full challenge below."); + } + }; + return ( + + + + Pair a host or recover the shared connection, even when the current host is offline. + + {error ? ( + + {error} + + ) : null} + {notice ? ( + + {notice} + + ) : null} + {!expanded ? ( + setExpanded(true)} disabled={busy} /> + ) : ( + + {!challenge && !paired ? ( + <> + + Enter the host's approved origin and public verification key. Its private key + stays on the host. + + + + publicText(value, setPublicKey)} + disabled={busy} + maxLength={256} + placeholder="Base64url Ed25519 public key" + multiline + /> + { + setCopied(false); + void controller.start({ + label: label.trim(), + origin: origin.trim(), + publicKey: publicKey.trim(), + }); + }} + /> + + ) : challenge ? ( + <> + + Prove ownership of {label.trim()} + + + Sign this exact text on the host and serve the signed proof at its approved + origin. It expires at {new Date(challenge.expiresAt).toLocaleTimeString()}. + + + {challenge.challenge} + + void copyChallenge()} + /> + publicText(value, setSignature)} + disabled={busy} + maxLength={256} + multiline + placeholder="Base64url signature" + /> + { + void controller.complete(signature.trim()).then((ok) => { + if (ok) setSignature(""); + }); + }} + /> + + + ) : paired ? ( + <> + + {label.trim()} is paired + + + Pairing does not select the host. The shared pool currently uses{" "} + {service?.label ?? "no host"}. + + {selection === null ? ( + setSelection(access.discovery.selectionRevision)} + /> + ) : ( + <> + + Switch the shared pool to {label.trim()}? This affects everyone. Streams on + the previous host will lose access. + + { + void controller.select(selection).then((ok) => { + setSelection(null); + if (ok) setExpanded(false); + }); + }} + /> + setSelection(null)} + /> + + )} + + + ) : null} + + Host proof preparation is required. Pairing does not transfer provider account refresh + ownership. + + { + reset(); + setExpanded(false); + }} + /> + + )} + {confirmation ? ( + + + Revoke {confirmation.label}? New requests will be rejected and active streams will + stop. Pair and select a host again to restore access. + + void controller.revoke()} + /> + + + ) : service ? ( + + ) : null} + + + ); +} + +function Action(props: { + readonly label: string; + readonly onPress: () => void; + readonly disabled?: boolean; + readonly destructive?: boolean; +}) { + return ( + + + {props.label} + + + ); +} + +function Field(props: { + readonly label: string; + readonly value: string; + readonly onChange: (value: string) => void; + readonly disabled: boolean; + readonly maxLength: number; + readonly placeholder: string; + readonly multiline?: boolean; +}) { + return ( + + {props.label} + + + ); +} diff --git a/apps/mobile/src/fork/prism/MicPrismThreadSection.tsx b/apps/mobile/src/fork/prism/MicPrismThreadSection.tsx index 882ad685f62e..5cab0ef48249 100644 --- a/apps/mobile/src/fork/prism/MicPrismThreadSection.tsx +++ b/apps/mobile/src/fork/prism/MicPrismThreadSection.tsx @@ -10,7 +10,10 @@ import { environmentSession } from "../../state/session"; import { useThreadShells } from "../../state/entities"; import { MicPrismThreadBridgeContext } from "./PersistentMicPrismIdentity"; -export function MicPrismThreadSection(props: { readonly environmentId: EnvironmentId }) { +export function MicPrismThreadSection(props: { + readonly environmentId: EnvironmentId; + readonly canInfer: boolean; +}) { const bridge = useContext(MicPrismThreadBridgeContext); const session = useAtomValue(environmentSession.sessionStateAtom(props.environmentId)); const access = Option.getOrNull(AsyncResult.value(session)); @@ -20,16 +23,20 @@ export function MicPrismThreadSection(props: { readonly environmentId: Environme access.scopes?.includes("orchestration:read"); const canOperate = canRead && access?.scopes?.includes("orchestration:operate"); const threads = useThreadShells().filter( - (thread) => canRead && thread.environmentId === props.environmentId && !thread.archivedAt, + (thread) => + canRead && + thread.environmentId === props.environmentId && + !thread.archivedAt && + (props.canInfer || bridge?.bindings.has(`${props.environmentId}/${thread.id}`)), ); const [selected, setSelected] = useState(null); const [choosing, setChoosing] = useState(false); const [busy, setBusy] = useState(false); - if (!bridge) return null; + if (!bridge || (!props.canInfer && threads.length === 0)) return null; const thread = threads.find((thread) => thread.id === selected); const binding = selected ? bridge.bindings.get(`${props.environmentId}/${selected}`) : undefined; const change = async () => { - if (!thread || busy || !canOperate) return; + if (!thread || busy || !canOperate || (!binding && !props.canInfer)) return; setBusy(true); try { if (binding) await bridge.disconnect(props.environmentId, thread.id); @@ -57,7 +64,9 @@ export function MicPrismThreadSection(props: { readonly environmentId: Environme void change()} className="self-start rounded-full bg-subtle px-4 py-2" > diff --git a/apps/mobile/src/fork/prism/PrismIdentitySection.tsx b/apps/mobile/src/fork/prism/PrismIdentitySection.tsx index 7a1580910279..a818278bca46 100644 --- a/apps/mobile/src/fork/prism/PrismIdentitySection.tsx +++ b/apps/mobile/src/fork/prism/PrismIdentitySection.tsx @@ -3,7 +3,7 @@ import { AuthView } from "@clerk/expo/native"; import type { MicIdentityAccess, MicIdentityPublicConfig } from "@q1code/core/micIdentityApi"; import type { PrismRoutingStrategy } from "@q1code/core/config"; import { - getMicIdentityAccess, + getMicIdentityOverview, getMicPrismStatus, getMicPrismRouting, setMicPrismRouting, @@ -33,11 +33,12 @@ import { MicPrismRootPresentContext, MicPrismThreadBridgeContext, } from "./PersistentMicPrismIdentity"; +import { MicPrismPairingSection } from "./MicPrismPairingSection"; import { MicPrismThreadSection } from "./MicPrismThreadSection"; import { MicPrismInferenceSection } from "./MicPrismInferenceSection"; import { MicPrismTokenContext } from "./micIdentityContext"; import { describePrismError, PRISM_ROUTING_OPTIONS } from "./prismSettings.logic"; -import { usePrismApi } from "./usePrismApi"; +import { type PrismApi, usePrismApi } from "./usePrismApi"; const micTokenCache = { getToken: (key: string) => SecureStore.getItemAsync(`q1code.mic-sc.${key}`), @@ -59,25 +60,38 @@ function ConfiguredBoundary(props: Parameters[0]) const persistent = useContext(MicPrismThreadBridgeContext); const rootPresent = useContext(MicPrismRootPresentContext); const api = usePrismApi(props.environmentId); - const [config, setConfig] = useState(null); - const [error, setError] = useState(null); + const [loaded, setLoaded] = useState<{ + readonly api: PrismApi; + readonly config: MicIdentityPublicConfig | null; + readonly error: string | null; + } | null>(null); + const current = api !== null && loaded?.api === api ? loaded : null; + const config = current?.config; useEffect(() => { if (!api) return; let cancelled = false; void api.identityConfig().then((result) => { if (cancelled) return; - if (result._tag === "ok") { - setConfig(result.value); - setError(null); - } else setError(describePrismError(result.error)); + setLoaded({ + api, + config: result._tag === "ok" ? result.value : null, + error: result._tag === "ok" ? null : describePrismError(result.error), + }); }); return () => { cancelled = true; }; }, [api]); - if (config === null) { - return ; + if (!config) { + return ( + + ); } const cloud = resolveCloudPublicConfig(); const mode = persistent @@ -199,16 +213,20 @@ function SignedIdentity(props: { {isSignedIn && !locallySignedOut ? ( - - ) : null} - {isSignedIn && !locallySignedOut ? ( - + ) : null} ); } function MicService(props: { + readonly environmentId: EnvironmentId; readonly config: MicIdentityPublicConfig; readonly source: ReturnType; readonly isCurrent: () => boolean; @@ -278,7 +296,7 @@ function MicService(props: { } }; try { - const result = await runtime.runPromise(getMicIdentityAccess(input).pipe(Effect.result)); + const result = await runtime.runPromise(getMicIdentityOverview(input).pipe(Effect.result)); if (!current()) return; if (result._tag === "Failure") { fail(result.failure); @@ -287,22 +305,26 @@ function MicService(props: { return; } setAccess(result.success); - const status = await runtime.runPromise(getMicPrismStatus(input).pipe(Effect.result)); - if (!current()) return; - if (status._tag === "Failure") { - fail(status.failure); - setRefreshing(false); - setError(describePrismError(status.failure)); - return; - } - setGateway(status.success); + setGateway(null); + setStrategy(null); setError(null); + const service = result.success.discovery.service; + if (!service) return; + const bound = { ...input, expectedService: service }; + if (result.success.session.permissions.includes("prism:inference")) { + const status = await runtime.runPromise(getMicPrismStatus(bound).pipe(Effect.result)); + if (!current()) return; + if (status._tag === "Failure") { + setError(describePrismError(status.failure)); + return; + } + setGateway(status.success); + } if (result.success.session.permissions.includes("prism:routing:read")) { - const routing = await runtime.runPromise(getMicPrismRouting(input).pipe(Effect.result)); + const routing = await runtime.runPromise(getMicPrismRouting(bound).pipe(Effect.result)); if (!current()) return; if (routing._tag === "Success") setStrategy(routing.success.strategy); else { - fail(routing.failure); setError(describePrismError(routing.failure)); } } else setStrategy(null); @@ -328,7 +350,7 @@ function MicService(props: { const canRoute = error === null && !refreshing && - gateway !== null && + strategy !== null && access !== null && access.session.permissions.includes("prism:routing:write"); useEffect(() => { @@ -411,7 +433,16 @@ function MicService(props: { ) : null} - {access && gateway ? ( + + {access ? ( + void refresh()} /> + ) : null} + {access?.session.permissions.includes("prism:inference") && gateway ? ( = 0 else { throw MicPrismError.invalidResponse } - try identity.require(permission) + try identity.require(input.path == "/identity/access" ? nil : permission) if let service = discovery.service { guard service.status == "paired", service.protocolVersion == 1, service.pairingRevision > 0, !service.serviceInstanceId.isEmpty, @@ -68,19 +73,27 @@ public struct MicPrismClient: Sendable { "permissions": .array(identity.permissions.map(JSONValue.string)), "authorizationExpiresAt": .number(identity.authorizationExpiresAt), ]), - "discovery": .object(["service": discovery.service.map { service in .object([ + "discovery": .object(["selectionRevision": .number(Double(discovery.selectionRevision)), "service": discovery.service.map { service in .object([ "id": .string(service.serviceInstanceId), "label": .string(service.displayName), "apiUrl": .string(service.apiOrigin), "inferenceUrl": .string(service.inferenceOrigin), "pairingRevision": .number(Double(service.pairingRevision)), ]) } ?? .null]), ]) } + if permission == "prism:instances:manage" { + if let expected = input.expectedSelectionRevision, expected != discovery.selectionRevision { + throw MicPrismError.pairingConflict + } + let result = try await pairing(input, origin: origin, subject: identity.subject, token: token, isCurrent: isCurrent) + try identity.require(input.path == "/identity/access" ? nil : permission) + return result + } guard let service = discovery.service else { throw MicPrismError.unpaired } if input.path == "/status" { let status: Status = try await request(service.apiOrigin, "/prism/v1/status", token: token, isCurrent: isCurrent) guard status.serviceInstanceId == service.serviceInstanceId, status.pairingRevision == service.pairingRevision, status.authorization == "current", status.engineHealth == "unknown" else { throw MicPrismError.invalidResponse } - try identity.require(permission) + try identity.require(input.path == "/identity/access" ? nil : permission) // Verified access is distinct from engine readiness or provider eligibility. return try Self.response(["state": .string("access-verified"), "capabilities": .object([ "inference": .bool(true), "manage": .bool(false), "accountDetails": .bool(false), @@ -96,7 +109,7 @@ public struct MicPrismClient: Sendable { credential.pairingRevision == service.pairingRevision, credential.expiresAt > now, credential.expiresAt <= now + 930_000, credential.token.range(of: #"^msp1\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$"#, options: .regularExpression) != nil else { throw MicPrismError.invalidResponse } - try identity.require(permission) + try identity.require(input.path == "/identity/access" ? nil : permission) if input.path == "/models" { let catalog: Models = try await request(service.inferenceOrigin, "/v1/models", token: { credential.token }, isCurrent: isCurrent) guard catalog.data.count <= 4096, catalog.data.allSatisfy({ !$0.id.isEmpty && $0.id.count <= 256 }) else { throw MicPrismError.invalidResponse } @@ -122,11 +135,11 @@ public struct MicPrismClient: Sendable { let routing: Routing = try await request(service.apiOrigin, "/prism/v1/routing", method: input.method, body: input.body, token: token, isCurrent: isCurrent) guard ["round-robin", "weighted-round-robin", "fill-first"].contains(routing.strategy), input.method != "PUT" || routing.strategy == input.body?["strategy"]?.stringValue else { throw MicPrismError.invalidResponse } - try identity.require(permission) + try identity.require(input.path == "/identity/access" ? nil : permission) return try Self.response(["strategy": .string(routing.strategy)]) } - private func request( + func request( _ origin: String, _ path: String, method: String = "GET", body: [String: JSONValue]? = nil, token: MicPrismTokenSource, isCurrent: @Sendable () async -> Bool, timeout: TimeInterval = 15 ) async throws -> T { @@ -155,6 +168,8 @@ public struct MicPrismClient: Sendable { case 200..<300: break case 401: throw MicPrismError.signedOut case 403: throw MicPrismError.denied + case 409: throw path.hasPrefix("/v1/prism/instances/") || path.hasPrefix("/v1/prism/pairings/") ? MicPrismError.pairingConflict : MicPrismError.unavailable + case 400: throw path.hasPrefix("/v1/prism/instances/") || path.hasPrefix("/v1/prism/pairings/") ? MicPrismError.invalidPairing : MicPrismError.invalidResponse case 404, 405, 501: throw MicPrismError.unsupported default: throw MicPrismError.unavailable } @@ -162,7 +177,7 @@ public struct MicPrismClient: Sendable { catch { throw MicPrismError.invalidResponse } } - private static func url(_ origin: String, path: String, originOnly: Bool = false) throws -> URL { + static func url(_ origin: String, path: String, originOnly: Bool = false) throws -> URL { guard var parts = URLComponents(string: origin), let host = parts.host, parts.scheme == "https" || (parts.scheme == "http" && ["localhost", "127.0.0.1", "[::1]", "::1"].contains(host)), parts.user == nil, parts.password == nil, parts.query == nil, parts.fragment == nil, @@ -173,16 +188,16 @@ public struct MicPrismClient: Sendable { return url } - private static func response(_ object: [String: JSONValue]) throws -> PrismResponse { + static func response(_ object: [String: JSONValue]) throws -> PrismResponse { try JSONDecoder().decode(PrismResponse.self, from: JSONEncoder().encode(object)) } private struct Identity: Decodable { let contractVersion: Int, subject: String, role: String, permissions: [String] let authorizationExpiresAt: Double, authorizationRevision: String - func require(_ permission: String) throws { + func require(_ permission: String?) throws { guard authorizationExpiresAt > Date().timeIntervalSince1970 * 1000 else { throw MicPrismError.signedOut } - guard permissions.contains(permission) else { throw MicPrismError.denied } + if let permission, !permissions.contains(permission) { throw MicPrismError.denied } } } private struct Discovery: Decodable { diff --git a/apps/swift-ios/Core/MicPrismPairing.swift b/apps/swift-ios/Core/MicPrismPairing.swift new file mode 100644 index 000000000000..d2a3f27a19e3 --- /dev/null +++ b/apps/swift-ios/Core/MicPrismPairing.swift @@ -0,0 +1,82 @@ +import Foundation + +public struct MicPrismPairingChallenge: Codable, Sendable { + public let challengeId: String + public let challenge: String + public let origin: String + public let publicKey: String + public let expiresAt: Double +} + +extension MicPrismClient { + /// Recovery stays on the authority: an absent or unreachable previous host must not block it. + func pairing( + _ input: PrismRequest, origin: String, subject: String, + token: MicPrismTokenSource, isCurrent: @Sendable () async -> Bool + ) async throws -> PrismResponse { + guard let body = input.body else { throw MicPrismError.invalidPairing } + switch input.path { + case "/identity/pairings/start": + guard body.count == 3, let host = body["origin"]?.stringValue, + let publicKey = body["publicKey"]?.stringValue, + let label = body["label"]?.stringValue, + Self.validPairingHost(host), Self.matches(publicKey, #"^MCowBQYDK2VwAyEA[A-Za-z0-9_-]{43}$"#), + !label.isEmpty, label.count <= 80, label == label.trimmingCharacters(in: .whitespacesAndNewlines), + label.unicodeScalars.allSatisfy({ $0.value >= 32 && $0.value != 127 }) else { throw MicPrismError.invalidPairing } + let result: MicPrismPairingChallenge = try await request(origin, "/v1/prism/pairings/start", method: "POST", body: body, token: token, isCurrent: isCurrent) + let proof: PairingProof + do { proof = try JSONDecoder().decode(PairingProof.self, from: Data(result.challenge.utf8)) } + catch { throw MicPrismError.invalidResponse } + let now = Date().timeIntervalSince1970 * 1000 + guard Self.validChallengeID(result.challengeId), result.challenge.utf8.count <= 4096, + result.origin == host, result.publicKey == publicKey, result.expiresAt > now, result.expiresAt <= now + 330_000, + proof.domain == "mic.sc/prism-pairing/v1", Self.matches(proof.nonce, #"^[A-Za-z0-9_-]{43}$"#), + proof.subject == subject, proof.challengeId == result.challengeId, proof.origin == host, + proof.publicKey == publicKey, proof.expiresAt == result.expiresAt, + proof.expectedPairingRevision >= 0, + proof.expectedServiceInstanceId.map(Self.validInstanceID) ?? true else { throw MicPrismError.invalidResponse } + // Preserve the exact bytes returned by mic.sc; reserializing the proof breaks its signature. + return try Self.response(["pairingChallenge": try JSONDecoder().decode(JSONValue.self, from: JSONEncoder().encode(result))]) + case "/identity/pairings/complete": + guard body.count == 2, let id = body["challengeId"]?.stringValue, Self.validChallengeID(id), + let signature = body["signature"]?.stringValue, Self.matches(signature, #"^[A-Za-z0-9_-]{86}$"#) else { throw MicPrismError.invalidPairing } + let result: PairedInstance = try await request(origin, "/v1/prism/pairings/complete", method: "POST", body: body, token: token, isCurrent: isCurrent) + guard Self.validInstanceID(result.serviceInstanceId), result.pairingRevision > 0 else { throw MicPrismError.invalidResponse } + return try Self.response(["serviceInstanceId": .string(result.serviceInstanceId), "pairingRevision": .number(Double(result.pairingRevision))]) + case "/identity/instances/select": + guard body.count == 2, let id = body["serviceInstanceId"]?.stringValue, Self.validInstanceID(id), + let revision = Self.revision(body["expectedSelectionRevision"]), revision < Int.max else { throw MicPrismError.invalidPairing } + let result: SelectedInstance = try await request(origin, "/v1/prism/instances/select", method: "POST", body: body, token: token, isCurrent: isCurrent) + guard result.serviceInstanceId == id, [revision, revision + 1].contains(result.selectionRevision) else { throw MicPrismError.invalidResponse } + return try Self.response(["serviceInstanceId": .string(id), "selectionRevision": .number(Double(result.selectionRevision))]) + case "/identity/instances/revoke": + guard body.count == 2, let id = body["serviceInstanceId"]?.stringValue, Self.validInstanceID(id), + let revision = Self.revision(body["expectedPairingRevision"]), revision > 0, revision < Int.max else { throw MicPrismError.invalidPairing } + let result: RevokedInstance = try await request(origin, "/v1/prism/instances/revoke", method: "POST", body: body, token: token, isCurrent: isCurrent) + guard result.serviceInstanceId == id, result.pairingRevision == revision + 1, result.selectionRevision >= 0 else { throw MicPrismError.invalidResponse } + return try Self.response(["serviceInstanceId": .string(id), "pairingRevision": .number(Double(result.pairingRevision)), "selectionRevision": .number(Double(result.selectionRevision))]) + default: throw MicPrismError.unsupported + } + } + + private static func matches(_ value: String, _ pattern: String) -> Bool { value.range(of: pattern, options: .regularExpression) != nil } + private static func validInstanceID(_ value: String) -> Bool { matches(value, #"^[A-Za-z0-9_.:~-]{1,256}$"#) } + private static func validChallengeID(_ value: String) -> Bool { matches(value, #"^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$"#) } + private static func validPairingHost(_ value: String) -> Bool { + guard value.count <= 256, let url = try? url(value, path: "", originOnly: true), let parts = URLComponents(url: url, resolvingAgainstBaseURL: false), + parts.path.isEmpty, value == url.absoluteString else { return false } + return true + } + private static func revision(_ value: JSONValue?) -> Int? { + guard case .number(let number)? = value, number.isFinite, number >= 0, number < Double(Int.max), number.rounded() == number else { return nil } + return Int(number) + } + private struct PairingProof: Decodable { + let domain: String, challengeId: String, nonce: String, subject: String, origin: String, publicKey: String + let expiresAt: Double, expectedPairingRevision: Int + let expectedServiceInstanceId: String? + } + private struct PairedInstance: Decodable { let serviceInstanceId: String, pairingRevision: Int } + private struct SelectedInstance: Decodable { let serviceInstanceId: String, selectionRevision: Int } + private struct RevokedInstance: Decodable { let serviceInstanceId: String, pairingRevision: Int, selectionRevision: Int } +} diff --git a/apps/swift-ios/Core/PrismWireModels.swift b/apps/swift-ios/Core/PrismWireModels.swift index e3423ae54f2e..fe07b06bf4c8 100644 --- a/apps/swift-ios/Core/PrismWireModels.swift +++ b/apps/swift-ios/Core/PrismWireModels.swift @@ -41,6 +41,10 @@ public struct PrismResponse: Decodable, Sendable { public let discovery: MicPrismDiscovery? public let models: [String]? public let response: String? + public let pairingChallenge: MicPrismPairingChallenge? + public let serviceInstanceId: String? + public let pairingRevision: Int? + public let selectionRevision: Int? public let threadId: String? public let expiresAt: Double? } @@ -60,6 +64,7 @@ public struct MicPrismIdentitySession: Decodable, Sendable { } public struct MicPrismDiscovery: Decodable, Sendable { + public let selectionRevision: Int? public let service: MicPrismDiscoveredService? } @@ -104,13 +109,15 @@ public struct PrismRequest: Sendable { public let body: [String: JSONValue]? public let expectedService: MicPrismDiscoveredService? public let identityAuthorityUrl: String? + public let expectedSelectionRevision: Int? - public init(_ path: String, method: String = "GET", body: [String: JSONValue]? = nil, expectedService: MicPrismDiscoveredService? = nil, identityAuthorityUrl: String? = nil) { + public init(_ path: String, method: String = "GET", body: [String: JSONValue]? = nil, expectedService: MicPrismDiscoveredService? = nil, identityAuthorityUrl: String? = nil, expectedSelectionRevision: Int? = nil) { self.path = path self.method = method self.body = body self.expectedService = expectedService self.identityAuthorityUrl = identityAuthorityUrl + self.expectedSelectionRevision = expectedSelectionRevision } public static func component(_ value: String) -> String { diff --git a/apps/swift-ios/Features/Prism/MicPrismHostAdministrationView.swift b/apps/swift-ios/Features/Prism/MicPrismHostAdministrationView.swift new file mode 100644 index 000000000000..ca688d5fe573 --- /dev/null +++ b/apps/swift-ios/Features/Prism/MicPrismHostAdministrationView.swift @@ -0,0 +1,185 @@ +import SwiftUI + +/// Account-level host recovery remains available when the selected gateway is offline. +struct MicPrismHostAdministrationView: View { + let client: any FeatureClient + let environmentID: String + let authorityURL: String + let identity: PrismResponse + let refresh: @MainActor () async -> Void + @State private var hostOrigin = "" + @State private var hostLabel = "" + @State private var publicKey = "" + @State private var signature = "" + @State private var existingInstanceID = "" + @State private var challenge: MicPrismPairingChallenge? + @State private var pairedInstanceID: String? + @State private var pairedRevision: Int? + @State private var confirmation: Confirmation? + @State private var operation: Task? + @State private var generation = 0 + @State private var message: String? + @State private var failed = false + + private struct Confirmation: Identifiable { + let id = UUID() + let title: String + let action: String + let destructive: Bool + let selectionRevision: Int? + let request: PrismRequest + } + private var allowed: Bool { + identity.session?.permissions.contains("prism:instances:manage") == true && + (identity.session?.authorizationExpiresAt ?? 0) > Date().timeIntervalSince1970 * 1000 + } + private var busy: Bool { operation != nil } + private var targetID: String? { pairedInstanceID ?? identity.discovery?.service?.id } + private var targetRevision: Int? { pairedRevision ?? identity.discovery?.service?.pairingRevision } + + var body: some View { + Section("Prism host") { + if let service = identity.discovery?.service { + LabeledContent("Selected host", value: service.label) + Text(service.apiUrl).font(.caption).foregroundStyle(.secondary).textSelection(.enabled) + } else { + Text("No host selected. Pair a prepared host or select an existing paired host to enable inference.") + .foregroundStyle(.secondary) + } + if let targetID { + if pairedInstanceID != nil { LabeledContent("Paired host", value: targetID) } + if let revision = identity.discovery?.selectionRevision { + Button("Use this host for inference") { + confirmation = Confirmation(title: "Use Prism host \(targetID)?", action: "Use host", destructive: false, selectionRevision: identity.discovery?.selectionRevision, + request: PrismRequest("/identity/instances/select", method: "POST", body: [ + "serviceInstanceId": .string(targetID), "expectedSelectionRevision": .number(Double(revision)), + ], identityAuthorityUrl: authorityURL)) + }.disabled(!allowed || busy) + } + if let revision = targetRevision { + Button("Revoke this host", role: .destructive) { + confirmation = Confirmation(title: "Revoke Prism host \(targetID)? New requests will be rejected and active streams will stop when access is rechecked.", action: "Revoke host", destructive: true, selectionRevision: identity.discovery?.selectionRevision, + request: PrismRequest("/identity/instances/revoke", method: "POST", body: [ + "serviceInstanceId": .string(targetID), "expectedPairingRevision": .number(Double(revision)), + ], identityAuthorityUrl: authorityURL)) + }.disabled(!allowed || busy) + } + } + if let revision = identity.discovery?.selectionRevision { + DisclosureGroup("Use an existing paired host") { + TextField("Paired service instance ID", text: $existingInstanceID) + .textInputAutocapitalization(.never).autocorrectionDisabled().disabled(busy) + Button("Select existing host") { + confirmation = Confirmation(title: "Use Prism host \(existingInstanceID) for inference?", action: "Use host", destructive: false, selectionRevision: identity.discovery?.selectionRevision, + request: PrismRequest("/identity/instances/select", method: "POST", body: [ + "serviceInstanceId": .string(existingInstanceID), "expectedSelectionRevision": .number(Double(revision)), + ], identityAuthorityUrl: authorityURL)) + }.disabled(!allowed || busy || existingInstanceID.isEmpty) + } + } + DisclosureGroup("Pair a prepared host") { + Text("The host must already serve its pairing proof at the approved origin. Create a challenge, sign its exact text with the host key, and paste the signature below. Pairing does not select the host automatically.") + .font(.caption).foregroundStyle(.secondary) + TextField("Host name", text: $hostLabel) + .disabled(busy || challenge != nil) + TextField("Host origin (https://…)", text: $hostOrigin) + .textInputAutocapitalization(.never).autocorrectionDisabled().keyboardType(.URL) + .disabled(busy || challenge != nil) + TextField("Host public key", text: $publicKey, axis: .vertical) + .textInputAutocapitalization(.never).autocorrectionDisabled() + .disabled(busy || challenge != nil) + if let challenge { + Text(challenge.challenge).font(.caption.monospaced()).textSelection(.enabled) + ShareLink("Share exact challenge", item: challenge.challenge) + Text("Expires \(Date(timeIntervalSince1970: challenge.expiresAt / 1000).formatted(date: .omitted, time: .shortened))") + .font(.caption).foregroundStyle(.secondary) + TextField("Host signature", text: $signature, axis: .vertical) + .textInputAutocapitalization(.never).autocorrectionDisabled().disabled(busy) + Button("Complete pairing") { + run(PrismRequest("/identity/pairings/complete", method: "POST", body: ["challengeId": .string(challenge.challengeId), "signature": .string(signature)], identityAuthorityUrl: authorityURL)) + }.disabled(!allowed || busy || signature.isEmpty || challenge.expiresAt <= Date().timeIntervalSince1970 * 1000) + Button("Discard challenge", role: .cancel) { self.challenge = nil; signature = "" } + .disabled(busy) + } else { + Button("Create pairing challenge") { + run(PrismRequest("/identity/pairings/start", method: "POST", body: ["origin": .string(hostOrigin), "publicKey": .string(publicKey), "label": .string(hostLabel)], identityAuthorityUrl: authorityURL)) + }.disabled(!allowed || busy || hostOrigin.isEmpty || publicKey.isEmpty || hostLabel.isEmpty) + } + } + if busy { Text("Verifying host change…").foregroundStyle(.secondary) } + if let message { Text(message).foregroundStyle(failed ? Color.red : Color.secondary) } + } + .confirmationDialog(confirmation?.title ?? "Confirm host change", isPresented: Binding(get: { confirmation != nil }, set: { if !$0 { confirmation = nil } }), titleVisibility: .visible) { + if let confirmation { + Button(confirmation.action, role: confirmation.destructive ? .destructive : nil) { + self.confirmation = nil + run(confirmation.request, expectedSelectionRevision: confirmation.selectionRevision) + } + } + } + .onChange(of: identity.discovery?.selectionRevision) { _, _ in + if confirmation != nil { + confirmation = nil; failed = true + message = "The selected host changed. Review the current host before continuing." + } + } + .onChange(of: identity.discovery?.service?.pairingRevision) { _, _ in + if confirmation != nil { + confirmation = nil; failed = true + message = "The pairing changed. Review the current host before continuing." + } + } + .onDisappear { + generation += 1 + operation?.cancel(); operation = nil; confirmation = nil; challenge = nil; signature = "" + } + } + + @MainActor private func run(_ request: PrismRequest, expectedSelectionRevision: Int? = nil) { + guard allowed, !busy else { return } + if let expectedSelectionRevision, identity.discovery?.selectionRevision != expectedSelectionRevision { + failed = true; message = "The selected host changed. Refresh access before continuing." + return + } + generation += 1 + let current = generation + message = nil; failed = false + operation = Task { + defer { if generation == current { operation = nil } } + do { + let boundRequest = PrismRequest(request.path, method: request.method, body: request.body, identityAuthorityUrl: authorityURL, expectedSelectionRevision: expectedSelectionRevision) + let result = try await client.prism(boundRequest, environmentID: environmentID) + guard !Task.isCancelled, generation == current else { return } + switch request.path { + case "/identity/pairings/start": challenge = result.pairingChallenge + case "/identity/pairings/complete": + pairedInstanceID = result.serviceInstanceId; pairedRevision = result.pairingRevision + challenge = nil; signature = "" + message = "Host paired. Choose Use this host for inference to select it." + case "/identity/instances/select": + pairedInstanceID = nil; pairedRevision = nil; existingInstanceID = "" + message = "Host selected. Refreshing access…" + await refresh() + case "/identity/instances/revoke": + pairedInstanceID = nil; pairedRevision = nil; challenge = nil; signature = "" + message = "Host revoked. Refreshing access…" + await refresh() + default: break + } + } catch is CancellationError { } + catch { + guard !Task.isCancelled, generation == current else { return } + failed = true + message = (error as? MicPrismError)?.localizedDescription ?? "The host change could not be confirmed. Refresh access before trying again." + if let error = error as? MicPrismError { + switch error { + case .signedOut, .denied, .pairingConflict: + challenge = nil; signature = ""; pairedInstanceID = nil; pairedRevision = nil + await refresh() + default: break + } + } + } + } + } +} diff --git a/apps/swift-ios/Features/Prism/PrismView.swift b/apps/swift-ios/Features/Prism/PrismView.swift index 000c6464db4e..2ae330c72457 100644 --- a/apps/swift-ios/Features/Prism/PrismView.swift +++ b/apps/swift-ios/Features/Prism/PrismView.swift @@ -48,6 +48,19 @@ public struct PrismView: View { return controller } + private var gatewayState: String { + if stale { return "Unavailable" } + if identityConfiguration.enabled, let identity { + if identity.discovery?.service == nil { return "No host selected" } + if identity.session?.permissions.contains("prism:inference") != true { + return identity.session?.permissions.contains("prism:instances:manage") == true ? "Host management available" : "Inference access not granted" + } + } + if currentStatus?.state == "access-verified" { return "Access verified" } + if !connected { return "Offline" } + return currentStatus?.state ?? "Checking…" + } + private var identityRoutingRead: Bool { identity?.session?.permissions.contains("prism:routing:read") == true } private var identityRoutingWrite: Bool { identityConfiguration.enabled && !stale && !pending && identityRoutingRead && @@ -84,9 +97,9 @@ public struct PrismView: View { ForEach(environments) { environment in Text(environment.name).tag(environment.id) } } .disabled(login != nil || pending) - LabeledContent("Gateway", value: stale ? "Unavailable" : currentStatus?.state == "access-verified" ? "Access verified" : !connected ? "Offline" : currentStatus?.state ?? "Checking…") + LabeledContent("Gateway", value: gatewayState) if (stale || !connected), let state = currentStatus?.state { - Text("Last known state: \(state). Management is unavailable until the connection recovers.") + Text(identityConfiguration.enabled ? "Last known state: \(state). Host recovery remains available with current mic.sc access." : "Last known state: \(state). Management is unavailable until the connection recovers.") .foregroundStyle(.secondary) } if let role = currentStatus?.role { LabeledContent("Pool role", value: role) } @@ -99,10 +112,15 @@ public struct PrismView: View { } header: { Text("Prism") } if identityConfiguration.enabled { - if let controller = (client as? any MicPrismThreadCapable)?.micPrismThreads, identity != nil { + if let identity, identity.session?.permissions.contains("prism:instances:manage") == true, + let authorityURL = identityConfiguration.authorityUrl { + MicPrismHostAdministrationView(client: client, environmentID: environmentID, authorityURL: authorityURL, identity: identity, refresh: { await load() }) + .id(environmentID + authorityURL + (identity.session?.subject ?? "") + (identityController?.clerk?.session?.id ?? "")) + } + if let controller = (client as? any MicPrismThreadCapable)?.micPrismThreads, identity?.session?.permissions.contains("prism:inference") == true { MicPrismThreadView(controller: controller, client: client, environmentID: environmentID, authorityURL: identityConfiguration.authorityUrl, threads: threads.filter { $0.environmentID == environmentID }) } - if let identity, let service = identity.discovery?.service, currentStatus != nil { + if let identity, identity.session?.permissions.contains("prism:inference") == true, let service = identity.discovery?.service, currentStatus != nil { MicPrismInferenceView(client: client, environmentID: environmentID, enabled: !stale, service: service, authorityUrl: identityConfiguration.authorityUrl) .id(environmentID + (identityConfiguration.authorityUrl ?? "") + service.id + String(service.pairingRevision) + service.apiUrl + (service.inferenceUrl ?? "") + (identity.session?.subject ?? "") + (identityController?.clerk?.session?.id ?? "")) } @@ -263,6 +281,16 @@ public struct PrismView: View { let nextIdentity = try await client.prism(PrismRequest("/identity/access"), environmentID: selected) guard selected == environmentID, generation == loadGeneration, !Task.isCancelled else { return } identity = nextIdentity + if nextIdentity.discovery?.service == nil || nextIdentity.session?.permissions.contains("prism:inference") != true { + loadedEnvironmentID = selected; status = nil; session = nil; accounts = []; strategy = ""; stale = false + errorMessage = nextIdentity.discovery?.service == nil ? "No Prism host is selected." : nil + if let service = nextIdentity.discovery?.service, nextIdentity.session?.permissions.contains("prism:routing:read") == true { + let routing = try await client.prism(PrismRequest("/routing", expectedService: service, identityAuthorityUrl: config.authorityUrl), environmentID: selected) + guard selected == environmentID, generation == loadGeneration, !Task.isCancelled else { return } + strategy = routing.strategy ?? "" + } + return + } } else { identity = nil } let nextStatus = try await client.prism(PrismRequest("/status"), environmentID: selected) let nextSession: AuthSessionState? diff --git a/apps/swift-ios/Tests/CoreTests/MicPrismPairingTests.swift b/apps/swift-ios/Tests/CoreTests/MicPrismPairingTests.swift new file mode 100644 index 000000000000..a9bba9606a7a --- /dev/null +++ b/apps/swift-ios/Tests/CoreTests/MicPrismPairingTests.swift @@ -0,0 +1,200 @@ +import XCTest +@testable import T3Code + +@MainActor +final class MicPrismPairingTests: XCTestCase { + private let configuration = MicPrismIdentityConfiguration(enabled: true, clerkPublishableKey: "fixture-key", authorityUrl: "https://identity.example.test") + private let host = "https://new-prism.example.test" + private let key = "MCowBQYDK2VwAyEA" + String(repeating: "a", count: 43) + private let challengeID = "12345678-1234-4234-8234-123456789abc" + + private func call(_ transport: PairingFixtureTransport, _ path: String, body: [String: JSONValue]? = nil) async throws -> PrismResponse { + try await MicPrismClient(transport: transport).call(PrismRequest(path, method: body == nil ? "GET" : "POST", body: body), configuration: configuration, token: { "fixture-human-session" }, isCurrent: { true }) + } + + func testManagerCanDiscoverAnUnpairedAccountWithoutInferenceGrant() async throws { + let transport = PairingFixtureTransport() + let result = try await call(transport, "/identity/access") + XCTAssertNil(result.discovery?.service) + XCTAssertEqual(result.discovery?.selectionRevision, 4) + XCTAssertEqual(result.session?.permissions, ["prism:instances:manage"]) + } + + func testRoutingOnlyIdentityCanReadOverviewWithoutInferenceGrant() async throws { + let transport = PairingFixtureTransport(permissions: ["prism:routing:read"]) + let result = try await call(transport, "/identity/access") + XCTAssertEqual(result.session?.permissions, ["prism:routing:read"]) + XCTAssertEqual(result.discovery?.selectionRevision, 4) + } + + func testZeroGrantIdentityCanReadOverviewButCannotManageHosts() async throws { + let transport = PairingFixtureTransport(permissions: []) + let result = try await call(transport, "/identity/access") + XCTAssertEqual(result.session?.permissions, []) + do { + _ = try await call(transport, "/identity/instances/select", body: ["serviceInstanceId": .string("fixture-host"), "expectedSelectionRevision": .number(4)]) + XCTFail("Expected host management denial") + } catch MicPrismError.denied { } + let requests = await transport.requests + XCTAssertFalse(requests.contains { $0.httpMethod == "POST" }) + } + + func testExpiredIdentityCannotReadOverview() async throws { + let transport = PairingFixtureTransport(expired: true) + do { + _ = try await call(transport, "/identity/access") + XCTFail("Expected expired identity rejection") + } catch MicPrismError.signedOut { } + let requests = await transport.requests + XCTAssertEqual(requests.count, 1) + } + + func testStartUsesOnlyAuthorityAndPreservesExactChallenge() async throws { + let transport = PairingFixtureTransport() + let result = try await call(transport, "/identity/pairings/start", body: ["origin": .string(host), "publicKey": .string(key), "label": .string("Primary PC")]) + XCTAssertEqual(result.pairingChallenge?.challengeId, challengeID) + XCTAssertTrue(result.pairingChallenge?.challenge.hasPrefix("{\n") == true) + let requests = await transport.requests + XCTAssertEqual(requests.map { $0.url!.path }, ["/v1/identity", "/v1/prism/discovery", "/v1/prism/pairings/start"]) + XCTAssertTrue(requests.allSatisfy { $0.url?.host == "identity.example.test" && $0.value(forHTTPHeaderField: "Authorization") == "Bearer fixture-human-session" }) + XCTAssertTrue(requests.allSatisfy { $0.value(forHTTPHeaderField: "Cookie") == nil && $0.value(forHTTPHeaderField: "x-mic-sc-session") == nil }) + } + + func testOrdinaryInferenceGrantDoesNotPermitHostChanges() async throws { + let transport = PairingFixtureTransport(manage: false) + do { + _ = try await call(transport, "/identity/instances/revoke", body: ["serviceInstanceId": .string("fixture-host"), "expectedPairingRevision": .number(2)]) + XCTFail("Expected permission denial") + } catch MicPrismError.denied { } + let requests = await transport.requests + XCTAssertEqual(requests.count, 1) + } + + func testChallengeForAnotherSubjectIsRejected() async throws { + let transport = PairingFixtureTransport(wrongSubject: true) + do { + _ = try await call(transport, "/identity/pairings/start", body: ["origin": .string(host), "publicKey": .string(key), "label": .string("Primary PC")]) + XCTFail("Expected challenge binding rejection") + } catch MicPrismError.invalidResponse { } + } + + func testCompletionDoesNotImplicitlySelectHost() async throws { + let transport = PairingFixtureTransport() + let result = try await call(transport, "/identity/pairings/complete", body: ["challengeId": .string(challengeID), "signature": .string(String(repeating: "a", count: 86))]) + XCTAssertEqual(result.serviceInstanceId, "fixture-host") + XCTAssertEqual(result.pairingRevision, 2) + let requests = await transport.requests + XCTAssertFalse(requests.contains { $0.url?.path == "/v1/prism/instances/select" }) + } + + func testSelectionSendsObservedRevisionAndRejectsMismatchedAcknowledgement() async throws { + let transport = PairingFixtureTransport(wrongRevision: true) + do { + _ = try await call(transport, "/identity/instances/select", body: ["serviceInstanceId": .string("fixture-host"), "expectedSelectionRevision": .number(4)]) + XCTFail("Expected acknowledgement mismatch") + } catch MicPrismError.invalidResponse { } + let requests = await transport.requests + let body = try JSONDecoder().decode([String: JSONValue].self, from: XCTUnwrap(requests.last?.httpBody)) + XCTAssertEqual(body["expectedSelectionRevision"], .number(4)) + } + + func testRevocationAcknowledgesNewPairingRevisionWithoutGatewayAccess() async throws { + let transport = PairingFixtureTransport() + let result = try await call(transport, "/identity/instances/revoke", body: ["serviceInstanceId": .string("fixture-host"), "expectedPairingRevision": .number(2)]) + XCTAssertEqual(result.pairingRevision, 3) + let requests = await transport.requests + XCTAssertTrue(requests.allSatisfy { $0.url?.host == "identity.example.test" }) + } + + func testFreshDiscoveryRejectsRevocationConfirmedAgainstPreviousSelection() async throws { + let transport = PairingFixtureTransport() + do { + _ = try await MicPrismClient(transport: transport).call(PrismRequest("/identity/instances/revoke", method: "POST", body: ["serviceInstanceId": .string("fixture-host"), "expectedPairingRevision": .number(2)], expectedSelectionRevision: 3), configuration: configuration, token: { "fixture-human-session" }, isCurrent: { true }) + XCTFail("Expected selected-host conflict before revocation") + } catch MicPrismError.pairingConflict { } + let requests = await transport.requests + XCTAssertEqual(requests.count, 2) + XCTAssertFalse(requests.contains { $0.httpMethod == "POST" }) + } + + func testConflictDoesNotRetryMutationOrExposeBackendBody() async throws { + let transport = PairingFixtureTransport(conflict: true) + do { + _ = try await call(transport, "/identity/instances/select", body: ["serviceInstanceId": .string("fixture-host"), "expectedSelectionRevision": .number(4)]) + XCTFail("Expected revision conflict") + } catch MicPrismError.pairingConflict { } + let requests = await transport.requests + XCTAssertEqual(requests.filter { $0.httpMethod == "POST" }.count, 1) + } + + func testSessionChangeDuringTokenRefreshPreventsMutation() async throws { + let transport = PairingFixtureTransport() + let binding = PairingFixtureBinding() + do { + _ = try await MicPrismClient(transport: transport).call(PrismRequest("/identity/instances/revoke", method: "POST", body: ["serviceInstanceId": .string("fixture-host"), "expectedPairingRevision": .number(2)]), configuration: configuration, token: { + await binding.nextToken() + }, isCurrent: { await binding.current }) + XCTFail("Expected old-session rejection") + } catch MicPrismError.signedOut { } + let requests = await transport.requests + XCTAssertEqual(requests.count, 2) + XCTAssertFalse(requests.contains { $0.httpMethod == "POST" }) + } + + func testMalformedOriginAndExtraFieldsDoNotReachMutation() async throws { + let transport = PairingFixtureTransport() + do { + _ = try await call(transport, "/identity/pairings/start", body: ["origin": .string(host + "/path"), "publicKey": .string(key), "label": .string("Primary PC"), "unexpected": .bool(true)]) + XCTFail("Expected invalid pairing input") + } catch MicPrismError.invalidPairing { } + let requests = await transport.requests + XCTAssertFalse(requests.contains { $0.httpMethod == "POST" }) + } +} + +private actor PairingFixtureBinding { + var current = true + private var count = 0 + func nextToken() -> String { + count += 1 + if count == 3 { current = false } + return "fixture-human-session" + } +} + +private actor PairingFixtureTransport: HTTPTransport { + var requests: [URLRequest] = [] + let manage: Bool + let wrongSubject: Bool + let wrongRevision: Bool + let conflict: Bool + let permissions: [String]? + let expired: Bool + init(manage: Bool = true, wrongSubject: Bool = false, wrongRevision: Bool = false, conflict: Bool = false, permissions: [String]? = nil, expired: Bool = false) { + self.manage = manage; self.wrongSubject = wrongSubject; self.wrongRevision = wrongRevision; self.conflict = conflict + self.permissions = permissions; self.expired = expired + } + func data(for request: URLRequest) async throws -> (Data, HTTPURLResponse) { + requests.append(request) + let body: [String: Any] + let now = Date().timeIntervalSince1970 * 1000 + switch request.url!.path { + case "/v1/identity": + body = ["contractVersion": 1, "subject": "fixture-admin", "role": "member", "permissions": permissions ?? [manage ? "prism:instances:manage" : "prism:inference"], "authorizationExpiresAt": now + (expired ? -60_000 : 60_000), "authorizationRevision": "revision-1"] + case "/v1/prism/discovery": + body = ["contractVersion": 1, "selectionRevision": 4, "service": NSNull()] + case "/v1/prism/pairings/start": + let id = "12345678-1234-4234-8234-123456789abc" + let origin = "https://new-prism.example.test" + let key = "MCowBQYDK2VwAyEA" + String(repeating: "a", count: 43) + let expires = now + 300_000 + let proof: [String: Any] = ["domain": "mic.sc/prism-pairing/v1", "challengeId": id, "nonce": String(repeating: "b", count: 43), "subject": wrongSubject ? "another-admin" : "fixture-admin", "origin": origin, "publicKey": key, "expiresAt": expires, "expectedServiceInstanceId": NSNull(), "expectedPairingRevision": 0] + body = ["challengeId": id, "challenge": String(decoding: try JSONSerialization.data(withJSONObject: proof, options: [.prettyPrinted, .sortedKeys]), as: UTF8.self), "origin": origin, "publicKey": key, "expiresAt": expires] + case "/v1/prism/pairings/complete": body = ["serviceInstanceId": "fixture-host", "pairingRevision": 2] + case "/v1/prism/instances/select": body = ["serviceInstanceId": "fixture-host", "selectionRevision": wrongRevision ? 9 : 5] + case "/v1/prism/instances/revoke": body = ["serviceInstanceId": "fixture-host", "pairingRevision": 3, "selectionRevision": 5] + default: throw MicPrismError.unsupported + } + return (try JSONSerialization.data(withJSONObject: body), HTTPURLResponse(url: request.url!, statusCode: conflict && request.httpMethod == "POST" ? 409 : 200, httpVersion: nil, headerFields: nil)!) + } +} diff --git a/apps/web/src/fork/mic-identity/MicIdentityPanel.tsx b/apps/web/src/fork/mic-identity/MicIdentityPanel.tsx index cdc2990d0567..f6e28481c7a4 100644 --- a/apps/web/src/fork/mic-identity/MicIdentityPanel.tsx +++ b/apps/web/src/fork/mic-identity/MicIdentityPanel.tsx @@ -3,6 +3,7 @@ import { CheckIcon, CloudOffIcon, LogInIcon, ShieldCheckIcon, ServerIcon } from import * as Effect from "effect/Effect"; import { getMicIdentityAccess, + getMicIdentityOverview, getMicPrismStatus, getMicPrismRouting, setMicPrismRouting, @@ -43,6 +44,7 @@ export function MicIdentityPanel() { const [view, setView] = useState(null); const [error, setError] = useState(null); const [busy, setBusy] = useState(false); + const [accessRevision, setAccessRevision] = useState(0); const [saved, setSaved] = useState(null); const operation = useRef(0); const mutating = useRef(false); @@ -72,13 +74,21 @@ export function MicIdentityPanel() { getToken: readMicIdentityToken, isCurrent: () => micIdentityGeneration() === generation, }; - const access = yield* getMicIdentityAccess({ ...input, allowUnpaired: true }); - if (!access.discovery.service) return { access, routing: null }; - yield* getMicPrismStatus(input); - const routing = access.session.permissions.includes("prism:routing:read") - ? (yield* getMicPrismRouting(input)).strategy - : null; - return { access, routing }; + const access = yield* getMicIdentityOverview(input); + if (!access.discovery.service) return { access, routing: null, gatewayError: null }; + const bound = { ...input, expectedService: access.discovery.service }; + const gateway = yield* Effect.gen(function* () { + if (access.session.permissions.includes("prism:inference")) + yield* getMicPrismStatus(bound); + return access.session.permissions.includes("prism:routing:read") + ? (yield* getMicPrismRouting(bound)).strategy + : null; + }).pipe(Effect.result); + return { + access, + routing: gateway._tag === "Success" ? gateway.success : null, + gatewayError: gateway._tag === "Failure" ? gateway.failure.message : null, + }; }).pipe(Effect.result), { signal: controller.signal }, ); @@ -91,7 +101,7 @@ export function MicIdentityPanel() { ) setView(null); } else { - setError(null); + setError(result.success.gatewayError); setView({ ...result.success, authorityUrl, generation, receivedAt: Date.now() }); } } catch { @@ -108,7 +118,7 @@ export function MicIdentityPanel() { controller.abort(); window.clearInterval(timer); }; - }, [config?.authorityUrl, generation, session.status, visible, revision]); + }, [config?.authorityUrl, generation, session.status, visible, revision, accessRevision]); const changeRouting = async (strategy: PrismRoutingStrategy) => { if ( @@ -134,7 +144,10 @@ export function MicIdentityPanel() { getToken: readMicIdentityToken, isCurrent: () => micIdentityGeneration() === generation, }; - const access = yield* getMicIdentityAccess(input); + const access = yield* getMicIdentityAccess({ + ...input, + permission: "prism:routing:write", + }); if ( access.discovery.service?.id !== target.id || access.discovery.service.pairingRevision !== target.pairingRevision @@ -241,7 +254,7 @@ export function MicIdentityPanel() {

{error - ? "Showing the last verified host. Changes and new requests are paused." + ? "Showing the last verified host. Inference and routing are paused; administrators can recover the host." : service ? "Your mic.sc session is authorized for this host." : "You're signed in. A Prism administrator needs to pair and select a host before you can send requests."} @@ -254,10 +267,14 @@ export function MicIdentityPanel() { (permission) => permission !== "prism:inference", ) ? "Scoped access" - : "Inference access"} + : current.access.session.permissions.includes("prism:inference") + ? "Inference access" + : "No service permissions"} - {service && config?.authorityUrl ? ( + {service && + config?.authorityUrl && + current.access.session.permissions.includes("prism:inference") ? ( setAccessRevision((value) => value + 1)} /> ) : null} {current.access.session.capabilities.accountDetails ? ( diff --git a/apps/web/src/fork/mic-identity/MicPrismPairing.tsx b/apps/web/src/fork/mic-identity/MicPrismPairing.tsx index 1a38680285f1..4f60ffc9152d 100644 --- a/apps/web/src/fork/mic-identity/MicPrismPairing.tsx +++ b/apps/web/src/fork/mic-identity/MicPrismPairing.tsx @@ -1,19 +1,7 @@ -import { useEffect, useId, useRef, useState } from "react"; +import { useEffect, useId, useLayoutEffect, useRef, useState, useSyncExternalStore } from "react"; import { CheckIcon, CopyIcon, KeyRoundIcon, LinkIcon, ShieldAlertIcon } from "lucide-react"; -import * as Effect from "effect/Effect"; -import type { HttpClient } from "effect/unstable/http"; import type { MicIdentityAccess } from "@q1code/core/micIdentityApi"; -import type { - MicPrismPairingChallenge, - MicPrismPairedInstance, -} from "@q1code/core/micPrismPairing"; -import { - startMicPrismPairing, - completeMicPrismPairing, - selectMicPrismInstance, - revokeMicPrismInstance, - type MicPrismPairingClientError, -} from "@t3tools/client-runtime/fork"; +import { createMicPrismPairingController } from "@t3tools/client-runtime/fork"; import { Button } from "~/components/ui/button"; import { Input } from "~/components/ui/input"; import { runtime } from "~/lib/runtime"; @@ -39,51 +27,38 @@ function PairingForm({ authorityUrl, generation, access, onChanged }: MicPrismPa const [origin, setOrigin] = useState(""); const [publicKey, setPublicKey] = useState(""); const [signature, setSignature] = useState(""); - const [challenge, setChallenge] = useState(null); - const [paired, setPaired] = useState(null); - const [confirmation, setConfirmation] = useState<{ - id: string; - pairingRevision: number; - label: string; - } | null>(null); - const [busy, setBusy] = useState(false); - const [error, setError] = useState(null); - const [notice, setNotice] = useState(null); const [copied, setCopied] = useState(false); - const pending = useRef(null); - useEffect(() => () => pending.current?.abort(), []); + const changed = useRef(onChanged); + useLayoutEffect(() => { + changed.current = onChanged; + }, [onChanged]); + const [controller] = useState(() => + createMicPrismPairingController({ + input: { + baseUrl: authorityUrl, + getToken: readMicIdentityToken, + isCurrent: () => micIdentityGeneration() === generation, + }, + access, + run: (effect, signal) => runtime.runPromise(effect, { signal }), + onChanged: () => changed.current(), + }), + ); + useLayoutEffect(() => { + controller.updateAccess(access); + }, [access, controller]); + useEffect(() => { + controller.activate(); + return () => controller.dispose(); + }, [controller]); + const { challenge, paired, confirmation, busy, error, notice } = useSyncExternalStore( + controller.subscribe, + controller.getSnapshot, + controller.getSnapshot, + ); const isCurrent = () => micIdentityGeneration() === generation; - const input = { baseUrl: authorityUrl, getToken: readMicIdentityToken, isCurrent }; const service = access.discovery.service; - - const run = async ( - effect: Effect.Effect, - accept: (value: A) => void, - ) => { - if (pending.current || !isCurrent()) return; - const controller = new AbortController(); - pending.current = controller; - setBusy(true); - setError(null); - setNotice(null); - try { - const result = await runtime.runPromise(effect.pipe(Effect.result), { - signal: controller.signal, - }); - if (!isCurrent() || controller.signal.aborted) return; - if (result._tag === "Failure") { - setError(result.failure.message); - // Refresh authority facts on rejection; never replay a mutation. - onChanged(); - } else accept(result.success); - } catch { - if (isCurrent() && !controller.signal.aborted) - setError("This change could not be confirmed. Refresh the connection before trying again."); - } finally { - if (pending.current === controller) pending.current = null; - if (isCurrent() && !controller.signal.aborted) setBusy(false); - } - }; + const setError = controller.setError; const copyChallenge = async () => { if (!challenge) return; @@ -141,18 +116,12 @@ function PairingForm({ authorityUrl, generation, access, onChanged }: MicPrismPa className="space-y-3" onSubmit={(event) => { event.preventDefault(); - void run( - startMicPrismPairing({ - ...input, - origin: origin.trim(), - publicKey: publicKey.trim(), - label: label.trim(), - }), - (value) => { - setChallenge(value); - setCopied(false); - }, - ); + setCopied(false); + void controller.start({ + origin: origin.trim(), + publicKey: publicKey.trim(), + label: label.trim(), + }); }} >

@@ -245,21 +214,9 @@ function PairingForm({ authorityUrl, generation, access, onChanged }: MicPrismPa className="space-y-3" onSubmit={(event) => { event.preventDefault(); - void run( - completeMicPrismPairing({ - ...input, - challengeId: challenge.challengeId, - signature: signature.trim(), - }), - (instance) => { - setPaired(instance); - setChallenge(null); - setSignature(""); - setNotice( - "Host paired. Select it below when you are ready to change the shared connection.", - ); - }, - ); + void controller.complete(signature.trim()).then((completed) => { + if (completed && isCurrent()) setSignature(""); + }); }} >