From 79aded1ea27c90d1a679e666591a1e0c0b350b29 Mon Sep 17 00:00:00 2001 From: Michael <8michael@proton.me> Date: Sat, 5 Sep 2026 08:11:28 -0700 Subject: [PATCH] feat(prism): add native host pairing and recovery Share pairing lifecycle across web and React Native, add Swift host management, and keep discovery independent of inference grants. Bind host confirmations to fresh discovery and preserve recovery when the gateway is unavailable. Fork-Feature: mic-identity Upstream: no --- .github/workflows/fork-ci.yml | 1 + .github/workflows/fork-identity-qa.yml | 2 +- .../src/fork/prism/MicPrismPairingSection.tsx | 333 ++++++++++++++++++ .../src/fork/prism/MicPrismThreadSection.tsx | 19 +- .../src/fork/prism/PrismIdentitySection.tsx | 87 +++-- apps/swift-ios/Core/MicPrismClient.swift | 39 +- apps/swift-ios/Core/MicPrismPairing.swift | 82 +++++ apps/swift-ios/Core/PrismWireModels.swift | 9 +- .../MicPrismHostAdministrationView.swift | 185 ++++++++++ apps/swift-ios/Features/Prism/PrismView.swift | 36 +- .../CoreTests/MicPrismPairingTests.swift | 200 +++++++++++ .../fork/mic-identity/MicIdentityPanel.tsx | 45 ++- .../src/fork/mic-identity/MicPrismPairing.tsx | 168 +++------ fork/FEATURES.md | 2 +- fork/docs/mic-identity.md | 8 +- packages/client-runtime/src/fork/index.ts | 1 + .../src/fork/micIdentityClient.test.ts | 69 +++- .../src/fork/micIdentityClient.ts | 35 +- .../src/fork/micPrismPairing.test.ts | 14 + .../src/fork/micPrismPairing.ts | 11 +- .../fork/micPrismPairingController.test.ts | 313 ++++++++++++++++ .../src/fork/micPrismPairingController.ts | 255 ++++++++++++++ scripts/fork/mic-identity-browser-qa.mjs | 275 ++++++++++++++- 23 files changed, 1988 insertions(+), 201 deletions(-) create mode 100644 apps/mobile/src/fork/prism/MicPrismPairingSection.tsx create mode 100644 apps/swift-ios/Core/MicPrismPairing.swift create mode 100644 apps/swift-ios/Features/Prism/MicPrismHostAdministrationView.swift create mode 100644 apps/swift-ios/Tests/CoreTests/MicPrismPairingTests.swift create mode 100644 packages/client-runtime/src/fork/micPrismPairingController.test.ts create mode 100644 packages/client-runtime/src/fork/micPrismPairingController.ts 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(""); + }); }} >