From fce53ad8e1783889620d3ad6b7cab507edb259ba Mon Sep 17 00:00:00 2001 From: Michael <8michael@proton.me> Date: Fri, 4 Sep 2026 23:58:33 -0700 Subject: [PATCH 1/7] feat(identity): scaffold mic.sc Prism integration Fork-Feature: mic-identity Upstream: no --- fork/FEATURES.md | 15 +++++++++++++++ packages/fork-core/src/config.ts | 8 ++++++++ packages/fork-core/src/flags.ts | 5 +++++ 3 files changed, 28 insertions(+) diff --git a/fork/FEATURES.md b/fork/FEATURES.md index 91550cdf63b4..007afd9f311c 100644 --- a/fork/FEATURES.md +++ b/fork/FEATURES.md @@ -53,6 +53,21 @@ Fields per entry: - **upstream**: no. Upstream's discovery is client-driven by design. - **removal condition**: upstream adds server-side release discovery for GitHub-provider updates. +## mic-identity + +- **status**: planned +- **purpose**: signed-in mic.sc users discover their paired Prism service and receive only the inference and management capabilities granted by its authority, without gaining q1code host access. +- **flags**: `mic-identity`, default off; Prism integration additionally requires `prism`. +- **owned dirs**: `packages/fork-core/src/micIdentity*`, `packages/client-runtime/src/fork/`, `apps/server/src/fork/mic-identity/`, `apps/web/src/fork/mic-identity/`, `apps/mobile/src/fork/`, `apps/swift-ios/`. +- **seams**: reuse the existing authenticated Prism API mounting in `apps/server/src/server.ts`, fork navigation slot in `apps/web/src/components/sidebar/SidebarChrome.tsx`, and settings slots. No additional upstream files planned. Identity headers supplement environment bearer/DPoP proofs; they never grant environment access. Fork configuration remains under `fork.json`. +- **migration**: versioned public authority/session and paired-service contracts are pending agreement with the independently owned backend. Contract fixtures are synthetic and do not establish real Clerk, discovery, or inference acceptance. Legacy Prism behavior remains available with this flag off. Provider account lifecycle, eligibility, scheduling and refresh authority remain in Prism. +- **surfaces**: web and desktop share the fork sign-in/status/settings UI; RN and carried SwiftUI consume the same permission/stale-state semantics. Preserve each environment's authorization and defaults. Claude/Codex retain native-provider routes and explicit direct choices; Grok uses its existing native adapter until the engine contract qualifies it. Cursor, OpenCode and Antigravity retain direct operation. Account sign-out/revocation clears client authorization; offline state cannot queue edits. Settings and Prism navigation expose identity; existing model options carry thread overrides on every client. No separate keybinding is required for sign-in. +- **tests**: authority contract validation, fresh credentials per request and renewed origin/DPoP proofs, permission denial before engine calls, no identity-to-host privilege escalation, grant/session revocation, stale/offline mutation rejection, no quota-bypassing or streamed/tool-effect retry. Flags-off tests prove no authority call/header/capability change. +- **upstream**: no. Planned commits: `feat(identity): scaffold mic.sc Prism integration`; `feat(identity): validate mic.sc access and paired discovery`; `feat(prism): enforce identity capabilities and stale-state controls`; `fix(prism): prevent unsafe direct retries`. Generic transport fixes, if discovered, will be isolated as upstream candidates before fork wiring. +- **removal condition**: upstream provides configurable human identity plus independently scoped service discovery/authorization, or the fork stops using mic.sc identity. + +--- + ## prism - **formerly**: `cliproxy`; renamed in the series on 2026-09-04, older commits keep the old trailer. The old `fork.json` key and `T3FORK_CLIPROXY` / `Q1CODE_CLIPROXY_SYNC_*` env vars are ignored; `ForkFlags.ts` logs one warning per leftover at server start. diff --git a/packages/fork-core/src/config.ts b/packages/fork-core/src/config.ts index 37c04059bedd..3bb6e95c2884 100644 --- a/packages/fork-core/src/config.ts +++ b/packages/fork-core/src/config.ts @@ -93,9 +93,17 @@ export const PrismConfig = Schema.Struct({ }); export type PrismConfig = typeof PrismConfig.Type; +/** Public identity configuration. Credentials remain in the signed-in client session. */ +export const MicIdentityConfig = Schema.Struct({ + authorityUrl: Schema.String, + clerkPublishableKey: Schema.String, +}); +export type MicIdentityConfig = typeof MicIdentityConfig.Type; + export const ForkConfig = Schema.Struct({ flags: Schema.optionalKey(ForkFlagOverrides), prism: Schema.optionalKey(PrismConfig), + "mic-identity": Schema.optionalKey(MicIdentityConfig), }); export type ForkConfig = typeof ForkConfig.Type; diff --git a/packages/fork-core/src/flags.ts b/packages/fork-core/src/flags.ts index 1d68a594eafa..c84a1eb91185 100644 --- a/packages/fork-core/src/flags.ts +++ b/packages/fork-core/src/flags.ts @@ -7,6 +7,11 @@ * under the userdata directory, then the registry default. */ export const FORK_FLAGS = { + "mic-identity": { + description: "Use mic.sc identity and permissions for the paired Prism service", + scope: "both", + default: false, + }, "update-check": { description: "Poll q1/q1code GitHub Releases daily and surface a newer version to clients", scope: "server", From 28fd8c801827e461481613e6b77c2283f4fab9af Mon Sep 17 00:00:00 2001 From: Michael <8michael@proton.me> Date: Sat, 5 Sep 2026 00:26:41 -0700 Subject: [PATCH 2/7] fix(prism): prevent policy bypass and replay on direct retry Fork-Feature: prism Upstream: no --- .../src/fork/prism/PrismRoutedAdapter.test.ts | 275 +++++++++++++++++- .../src/fork/prism/PrismRoutedAdapter.ts | 151 +++++++++- 2 files changed, 407 insertions(+), 19 deletions(-) diff --git a/apps/server/src/fork/prism/PrismRoutedAdapter.test.ts b/apps/server/src/fork/prism/PrismRoutedAdapter.test.ts index f94cdd7c1e64..a3cebc472461 100644 --- a/apps/server/src/fork/prism/PrismRoutedAdapter.test.ts +++ b/apps/server/src/fork/prism/PrismRoutedAdapter.test.ts @@ -83,7 +83,10 @@ const fake = Effect.fn("test.fakeAdapter")(function* (name: string, reject = fal readThread: (id) => Effect.succeed({ threadId: id, turns: [] }), rollbackThread: (id) => Effect.succeed({ threadId: id, turns: [] }), }; - const complete = (state: "completed" | "failed" | "cancelled" = "completed") => + const complete = ( + state: "completed" | "failed" | "cancelled" | "interrupted" = "completed", + errorMessage?: string, + ) => PubSub.publish(events, { eventId: EventId.make(`${name}-${state}`), provider, @@ -91,9 +94,10 @@ const fake = Effect.fn("test.fakeAdapter")(function* (name: string, reject = fal createdAt: at, turnId: TurnId.make(name), type: "turn.completed", - payload: { state }, + payload: { state, ...(errorMessage ? { errorMessage } : {}) }, }); - return { adapter, starts, turns, sent, complete, stops: () => stops }; + const emit = (event: ProviderRuntimeEvent) => PubSub.publish(events, event); + return { adapter, starts, turns, sent, complete, emit, stops: () => stops }; }); const observe = Effect.fn("test.observe")(function* ( @@ -243,6 +247,271 @@ it.layer(NodeServices.layer)("Prism routed adapter", (it) => { assert.equal(proxy.turns.length, 1); }), ); + + it.effect("quota and access failures never bypass Prism through setup, start, or send", () => + Effect.gen(function* () { + for (const detail of [ + "quota_exhausted", + "soft-reserve avoidance", + "quota_stale", + "rate_limit_error", + "usageLimitReached", + "insufficient_credits", + "HTTP 429 Too Many Requests", + "HTTP 401", + "HTTP 403", + "permission_error", + "identity_revoked", + "model_unavailable", + ]) { + for (const phase of ["setup", "start", "send"] as const) { + const denied = new ProviderAdapterRequestError({ provider, method: phase, detail }); + const direct = yield* fake("direct"); + const proxy = yield* fake("proxy"); + const adapter = yield* makePrismRoutedAdapter({ + direct: direct.adapter, + enabled: () => true, + proxy: () => + phase === "setup" + ? Effect.fail(denied) + : Effect.succeed({ + ...proxy.adapter, + ...(phase === "start" ? { startSession: () => Effect.fail(denied) } : {}), + ...(phase === "send" ? { sendTurn: () => Effect.fail(denied) } : {}), + }), + }); + if (phase === "send") yield* adapter.startSession(start); + const rejected = yield* phase === "send" + ? adapter.sendTurn({ threadId, input: "hello" }).pipe(Effect.flip) + : adapter.startSession(start).pipe(Effect.flip); + assert.equal(rejected, denied); + assert.equal(direct.starts.length, 0, `${phase}: ${detail}`); + assert.equal(direct.turns.length, 0, `${phase}: ${detail}`); + yield* adapter.stopAll(); + } + } + }), + ); + + it.effect( + "a nested native policy refusal stays blocked through later generic failure events", + () => + Effect.gen(function* () { + for (const detail of [ + { error: { code: "prism_soft_reserve" } }, + { error: { codexErrorInfo: "usageLimitExceeded" } }, + { error: { httpStatusCode: 403 } }, + { error: { codexErrorInfo: { httpStreamConnectionFailed: { httpStatusCode: 429 } } } }, + { error: { message: "Authentication required" } }, + { error: { type: "prism_upstream_unavailable" }, prism: { fallbackAllowed: false } }, + { headers: { "x-prism-fallback-allowed": "false" } }, + ]) { + const direct = yield* fake("direct"); + const proxy = yield* fake("proxy"); + const adapter = yield* makePrismRoutedAdapter({ + direct: direct.adapter, + enabled: () => true, + proxy: () => Effect.succeed(proxy.adapter), + }); + const events = yield* observe(adapter); + yield* adapter.startSession(start); + yield* adapter.sendTurn({ threadId, input: "hello" }); + const rejected: ProviderRuntimeEvent = { + eventId: EventId.make("policy-refusal"), + provider, + threadId, + createdAt: at, + turnId: TurnId.make("proxy"), + type: "runtime.error", + payload: { message: "Request refused", class: "provider_error", detail }, + }; + yield* proxy.emit(rejected); + assert.equal((yield* Queue.take(events)).type, "runtime.warning"); + assert.deepEqual(yield* Queue.take(events), rejected); + yield* proxy.complete("failed"); + assert.equal((yield* Queue.take(events)).type, "turn.completed"); + assert.equal(direct.turns.length, 0); + assert.equal(direct.starts.length, 0); + yield* adapter.sendTurn({ + threadId, + input: "explicit direct", + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "test-model", + options: [{ id: PRISM_ROUTE_OPTION, value: "direct" }], + }, + }); + assert.equal(direct.turns.length, 1); + yield* adapter.stopAll(); + } + }), + ); + + it.effect("a quota failure at completion preserves the actual error and model selection", () => + Effect.gen(function* () { + const direct = yield* fake("direct"); + const proxy = yield* fake("proxy"); + const adapter = yield* makePrismRoutedAdapter({ + direct: direct.adapter, + enabled: () => true, + proxy: () => Effect.succeed(proxy.adapter), + }); + const events = yield* observe(adapter); + const modelSelection = { + instanceId: ProviderInstanceId.make("codex"), + model: "chosen-model", + }; + yield* adapter.startSession({ ...start, modelSelection }); + const result = yield* adapter.sendTurn({ threadId, input: "hello", modelSelection }); + yield* proxy.complete("failed", "No eligible accounts: soft reserve reached"); + assert.equal((yield* Queue.take(events)).type, "runtime.warning"); + const completion = yield* Queue.take(events); + assert.equal(completion.turnId, result.turnId); + assert.equal(completion.type, "turn.completed"); + if (completion.type === "turn.completed") { + assert.equal(completion.payload.errorMessage, "No eligible accounts: soft reserve reached"); + } + assert.deepEqual(proxy.turns[0]?.modelSelection, modelSelection); + assert.equal(direct.turns.length, 0); + }), + ); + + it.effect("a provider quota warning cannot be bypassed when its own retries fail", () => + Effect.gen(function* () { + const direct = yield* fake("direct"); + const proxy = yield* fake("proxy"); + const adapter = yield* makePrismRoutedAdapter({ + direct: direct.adapter, + enabled: () => true, + proxy: () => Effect.succeed(proxy.adapter), + }); + const events = yield* observe(adapter); + yield* adapter.startSession(start); + yield* adapter.sendTurn({ threadId, input: "hello" }); + const nativeWarning: ProviderRuntimeEvent = { + eventId: EventId.make("native-retry"), + provider, + threadId, + createdAt: at, + turnId: TurnId.make("proxy"), + type: "runtime.warning", + payload: { message: "Retrying after quota rejection", detail: { willRetry: true } }, + }; + yield* proxy.emit(nativeWarning); + assert.deepEqual(yield* Queue.take(events), nativeWarning); + yield* proxy.complete("failed", "Request failed"); + assert.equal((yield* Queue.take(events)).type, "runtime.warning"); + assert.equal((yield* Queue.take(events)).type, "turn.completed"); + assert.equal(direct.turns.length, 0); + }), + ); + + it.effect("streamed output and tool execution cannot be replayed after a failure", () => + Effect.gen(function* () { + const eventBase = { + eventId: EventId.make("activity"), + provider, + threadId, + createdAt: at, + turnId: TurnId.make("proxy"), + }; + const activity: ReadonlyArray = [ + { + ...eventBase, + type: "content.delta", + payload: { streamKind: "assistant_text", delta: "Partial response" }, + }, + { + ...eventBase, + type: "content.delta", + payload: { streamKind: "reasoning_text", delta: "Working" }, + }, + { ...eventBase, type: "item.started", payload: { itemType: "command_execution" } }, + { ...eventBase, type: "item.started", payload: { itemType: "file_change" } }, + { ...eventBase, type: "item.started", payload: { itemType: "mcp_tool_call" } }, + ]; + for (const produced of activity) { + const direct = yield* fake("direct"); + const proxy = yield* fake("proxy"); + const adapter = yield* makePrismRoutedAdapter({ + direct: direct.adapter, + enabled: () => true, + proxy: () => Effect.succeed(proxy.adapter), + }); + const events = yield* observe(adapter); + yield* adapter.startSession(start); + yield* adapter.sendTurn({ threadId, input: "hello" }); + yield* proxy.emit(produced); + assert.equal((yield* Queue.take(events)).type, produced.type); + yield* proxy.complete("failed", "Connection reset"); + assert.equal((yield* Queue.take(events)).type, "runtime.warning"); + assert.equal((yield* Queue.take(events)).type, "turn.completed"); + assert.equal(direct.starts.length, 0); + assert.equal(direct.turns.length, 0); + yield* adapter.stopAll(); + } + }), + ); + + it.effect("a provider interruption does not start a direct retry", () => + Effect.gen(function* () { + const direct = yield* fake("direct"); + const proxy = yield* fake("proxy"); + const adapter = yield* makePrismRoutedAdapter({ + direct: direct.adapter, + enabled: () => true, + proxy: () => Effect.succeed(proxy.adapter), + }); + const events = yield* observe(adapter); + yield* adapter.startSession(start); + yield* adapter.sendTurn({ threadId, input: "hello" }); + yield* proxy.complete("interrupted"); + assert.equal((yield* Queue.take(events)).type, "turn.completed"); + assert.equal(direct.turns.length, 0); + }), + ); + + it.effect("a send rejection after streamed output cannot replay the turn", () => + Effect.gen(function* () { + const direct = yield* fake("direct"); + const proxy = yield* fake("proxy"); + const release = yield* Deferred.make(); + const adapter = yield* makePrismRoutedAdapter({ + direct: direct.adapter, + enabled: () => true, + proxy: () => + Effect.succeed({ + ...proxy.adapter, + sendTurn: (input: ProviderSendTurnInput) => + proxy.adapter.sendTurn(input).pipe( + Effect.andThen( + proxy.emit({ + eventId: EventId.make("partial"), + provider, + threadId, + createdAt: at, + turnId: TurnId.make("proxy"), + type: "content.delta", + payload: { streamKind: "assistant_text", delta: "Partial answer" }, + }), + ), + Effect.andThen(Deferred.await(release)), + Effect.andThen(Effect.fail(failure)), + ), + }), + }); + const events = yield* observe(adapter); + yield* adapter.startSession(start); + const sending = yield* adapter + .sendTurn({ threadId, input: "hello" }) + .pipe(Effect.flip, Effect.forkChild); + assert.equal((yield* Queue.take(events)).type, "content.delta"); + yield* Deferred.succeed(release, undefined); + assert.equal(yield* Fiber.join(sending), failure); + assert.equal((yield* Queue.take(events)).type, "runtime.warning"); + assert.equal(direct.turns.length, 0); + }), + ); it.effect("an early failed event and send rejection share one direct retry", () => Effect.gen(function* () { const direct = yield* fake("direct"); diff --git a/apps/server/src/fork/prism/PrismRoutedAdapter.ts b/apps/server/src/fork/prism/PrismRoutedAdapter.ts index 8dbe73c153b8..9ebec359e79c 100644 --- a/apps/server/src/fork/prism/PrismRoutedAdapter.ts +++ b/apps/server/src/fork/prism/PrismRoutedAdapter.ts @@ -1,5 +1,6 @@ import { EventId, + isToolLifecycleItemType, type ThreadId, type TurnId, type ProviderRuntimeEvent, @@ -12,6 +13,7 @@ import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as PubSub from "effect/PubSub"; +import * as Predicate from "effect/Predicate"; import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; import { @@ -31,10 +33,83 @@ interface SessionRoute { nativeId?: TurnId; retried: boolean; cancelled: boolean; + fallbackBlocked: boolean; + policyRefused: boolean; + hasOutput: boolean; retryDone: Deferred.Deferred; }; } +// Native CLIs sometimes retain structured errors and sometimes only their message. +// Inspect error fields only, never arbitrary request/tool data, and bound nested causes. +const blocksDirectFallback = (error: unknown, depth = 0): boolean => { + if (depth > 5) return false; + if (typeof error === "number") return [401, 403, 429].includes(error); + if (typeof error === "string") { + const normalized = error.replace(/[_-]/g, " ").toLowerCase(); + return /quota|reserve|exhaust|rate.?limit|usage.?limit|insufficient.?credits|billing|unauth|forbidden|permission|revok|access denied|inference.?denied|authentication|authoriz|invalid.?grant|invalid.?api.?key|invalid.?token|expired.?token|token.?expired|sign.?in.?required|no eligible|model.?unavailable|model.?not.?found|validation.?error|fallbackallowed"?\s*:\s*false|\b(?:401|403|429)\b/.test( + normalized, + ); + } + if (!Predicate.isObject(error)) return false; + if ( + ("fallbackAllowed" in error && error.fallbackAllowed === false) || + ("x-prism-fallback-allowed" in error && error["x-prism-fallback-allowed"] === "false") + ) + return true; + return [ + "code", + "type", + "class", + "message", + "reason", + "detail", + "error", + "cause", + "prism", + "headers", + "status", + "statusCode", + "httpStatusCode", + "http_status_code", + "codexErrorInfo", + "httpConnectionFailed", + "httpStreamConnectionFailed", + "errorMessage", + "stopReason", + ].some((key) => key in error && blocksDirectFallback(error[key], depth + 1)); +}; + +const producesOutput = (event: ProviderRuntimeEvent): boolean => { + switch (event.type) { + case "content.delta": + return event.payload.delta.length > 0; + case "item.started": + case "item.updated": + case "item.completed": + return ( + isToolLifecycleItemType(event.payload.itemType) || + (["assistant_message", "reasoning", "plan"].includes(event.payload.itemType) && + (event.type !== "item.started" || event.payload.detail !== undefined)) + ); + case "turn.proposed.delta": + case "turn.proposed.completed": + case "turn.diff.updated": + case "files.persisted": + case "request.opened": + case "user-input.requested": + case "tool.progress": + case "tool.summary": + case "task.started": + case "task.progress": + case "task.updated": + case "task.completed": + return true; + default: + return false; + } +}; + /** One provider instance, with routing kept at the adapter boundary and one logical turn across fallback. */ export const makePrismRoutedAdapter = Effect.fn("prism.routedAdapter")(function* (input: { direct: Adapter; @@ -101,10 +176,22 @@ export const makePrismRoutedAdapter = Effect.fn("prism.routedAdapter")(function* }); }); + const blockRetry = (route: SessionRoute) => { + if (!route.turn || route.turn.fallbackBlocked) return Effect.void; + route.turn.fallbackBlocked = true; + return warning( + route, + route.turn.hasOutput + ? "Prism failed after producing output or starting work. Automatic retry is disabled to avoid repeating it." + : "Prism rejected this request. Automatic direct retry is disabled to preserve its access, model, and quota rules.", + ); + }; + const retryDirect = Effect.fn("prism.retryDirect")(function* (route: SessionRoute) { const turn = route.turn; if (!turn || turn.cancelled) return undefined; if (turn.retried) return yield* Deferred.await(turn.retryDone); + if (turn.fallbackBlocked || turn.policyRefused || turn.hasOutput) return undefined; if (route.adapter === input.direct) return undefined; turn.retried = true; return yield* Effect.gen(function* () { @@ -135,12 +222,32 @@ export const makePrismRoutedAdapter = Effect.fn("prism.routedAdapter")(function* turn.logicalId ??= event.turnId; turn.nativeId = event.turnId; } + if (turn && producesOutput(event)) turn.hasOutput = true; + // A provider can announce its own retry before later emitting a generic failure. + if (turn && event.type === "runtime.warning" && blocksDirectFallback(event.payload)) { + turn.policyRefused = true; + } const failed = event.type === "runtime.error" || event.type === "session.exited" || - (event.type === "turn.completed" && - (event.payload.state === "failed" || event.payload.state === "interrupted")); - if (failed && turn && !turn.cancelled && !turn.retried && source !== input.direct) { + (event.type === "turn.completed" && event.payload.state === "failed"); + if ( + failed && + turn && + source !== input.direct && + !turn.fallbackBlocked && + (turn.hasOutput || turn.policyRefused || blocksDirectFallback(event.payload)) + ) { + yield* blockRetry(route); + } + if ( + failed && + turn && + !turn.cancelled && + !turn.retried && + !turn.fallbackBlocked && + source !== input.direct + ) { const retried = yield* retryDirect(route).pipe(Effect.catch(() => Effect.succeed(undefined))); if (retried !== undefined) return; // A failed direct start still produces a terminal event for the original turn. @@ -188,7 +295,9 @@ export const makePrismRoutedAdapter = Effect.fn("prism.routedAdapter")(function* streamEvents: Stream.fromPubSub(events), startSession: Effect.fn("prism.startSession")(function* (start) { const selected = yield* select(start.modelSelection).pipe( - Effect.catch(() => Effect.succeed(input.direct)), + Effect.catch((error) => + blocksDirectFallback(error) ? Effect.fail(error) : Effect.succeed(input.direct), + ), ); // Install before starting: native adapters can emit their first event during startSession. const route: SessionRoute = { adapter: selected, start }; @@ -202,7 +311,7 @@ export const makePrismRoutedAdapter = Effect.fn("prism.routedAdapter")(function* } const result = yield* selected.startSession(cleanStart(start)).pipe( Effect.catch((error) => { - if (selected === input.direct) return Effect.fail(error); + if (selected === input.direct || blocksDirectFallback(error)) return Effect.fail(error); route.adapter = input.direct; return selected .stopSession(start.threadId) @@ -221,11 +330,13 @@ export const makePrismRoutedAdapter = Effect.fn("prism.routedAdapter")(function* const selection = turnInput.modelSelection ?? route.start.modelSelection; route.start = { ...route.start, modelSelection: selection }; const selected = yield* select(selection).pipe( - Effect.catch(() => Effect.succeed(input.direct)), + Effect.catch((error) => + blocksDirectFallback(error) ? Effect.fail(error) : Effect.succeed(input.direct), + ), ); yield* move(route, selected).pipe( Effect.catch((error) => { - if (selected === input.direct) return Effect.fail(error); + if (selected === input.direct || blocksDirectFallback(error)) return Effect.fail(error); return warning( route, "Prism could not start. Using local direct-provider credentials.", @@ -236,21 +347,29 @@ export const makePrismRoutedAdapter = Effect.fn("prism.routedAdapter")(function* input: turnInput, retried: false, cancelled: false, + fallbackBlocked: false, + policyRefused: false, + hasOutput: false, retryDone: yield* Deferred.make< ProviderTurnStartResult | undefined, ProviderAdapterError >(), }; route.turn = turn; - const result = yield* route.adapter - .sendTurn(cleanTurn(turnInput)) - .pipe( - Effect.catch((error) => - retryDirect(route).pipe( - Effect.flatMap((result) => (result ? Effect.succeed(result) : Effect.fail(error))), - ), - ), - ); + const result = yield* route.adapter.sendTurn(cleanTurn(turnInput)).pipe( + Effect.catch((error) => { + if ( + route.adapter !== input.direct && + !turn.fallbackBlocked && + (turn.hasOutput || turn.policyRefused || blocksDirectFallback(error)) + ) { + return blockRetry(route).pipe(Effect.andThen(Effect.fail(error))); + } + return retryDirect(route).pipe( + Effect.flatMap((result) => (result ? Effect.succeed(result) : Effect.fail(error))), + ); + }), + ); const completedStart = turn.retried ? ((yield* Deferred.await(turn.retryDone)) ?? result) : result; From 3fa2ccfb70913489c4b3cc3f7e0577a6a3c03bf0 Mon Sep 17 00:00:00 2001 From: Michael <8michael@proton.me> Date: Sat, 5 Sep 2026 00:27:18 -0700 Subject: [PATCH 3/7] feat(identity): validate mic.sc access and paired Prism discovery Preserve independent environment authorization, renew service credentials per request, and reject shared-key compatibility paths in identity mode. Fork-Feature: mic-identity Upstream: no --- .../fork/mic-identity/MicIdentityAccess.ts | 151 +++++ .../mic-identity/MicIdentityHttpApi.test.ts | 618 ++++++++++++++++++ .../src/fork/prism/PrismEnvironment.test.ts | 42 ++ .../server/src/fork/prism/PrismEnvironment.ts | 16 +- apps/server/src/fork/prism/PrismHttpApi.ts | 186 ++++-- .../src/fork/prism/PrismProviderDriver.ts | 30 +- apps/server/src/fork/prism/PrismService.ts | 17 +- packages/client-runtime/src/fork/index.ts | 3 + .../src/fork/micIdentityClient.test.ts | 291 +++++++++ .../src/fork/micIdentityClient.ts | 168 +++++ .../src/fork/micPrismClient.test.ts | 271 ++++++++ .../client-runtime/src/fork/micPrismClient.ts | 150 +++++ .../src/fork/prismAccess.test.ts | 134 ++++ .../client-runtime/src/fork/prismAccess.ts | 56 ++ .../src/fork/prismClient.test.ts | 136 ++++ .../client-runtime/src/fork/prismClient.ts | 95 ++- packages/fork-core/package.json | 12 + packages/fork-core/src/micIdentity.ts | 276 ++++++++ packages/fork-core/src/micIdentityApi.ts | 15 + packages/fork-core/src/micPrismApi.ts | 20 + packages/fork-core/src/prismApi.test.ts | 2 + packages/fork-core/src/prismApi.ts | 38 +- 22 files changed, 2645 insertions(+), 82 deletions(-) create mode 100644 apps/server/src/fork/mic-identity/MicIdentityAccess.ts create mode 100644 apps/server/src/fork/mic-identity/MicIdentityHttpApi.test.ts create mode 100644 packages/client-runtime/src/fork/micIdentityClient.test.ts create mode 100644 packages/client-runtime/src/fork/micIdentityClient.ts create mode 100644 packages/client-runtime/src/fork/micPrismClient.test.ts create mode 100644 packages/client-runtime/src/fork/micPrismClient.ts create mode 100644 packages/client-runtime/src/fork/prismAccess.test.ts create mode 100644 packages/client-runtime/src/fork/prismAccess.ts create mode 100644 packages/fork-core/src/micIdentity.ts create mode 100644 packages/fork-core/src/micIdentityApi.ts create mode 100644 packages/fork-core/src/micPrismApi.ts diff --git a/apps/server/src/fork/mic-identity/MicIdentityAccess.ts b/apps/server/src/fork/mic-identity/MicIdentityAccess.ts new file mode 100644 index 000000000000..e30412e65bd8 --- /dev/null +++ b/apps/server/src/fork/mic-identity/MicIdentityAccess.ts @@ -0,0 +1,151 @@ +import { + MIC_IDENTITY_API_PATHS, + MIC_IDENTITY_SESSION_HEADER, + MicIdentityForbiddenError, + MicIdentityServiceUrl, + MicIdentityWire, + MicIdentityUnauthorizedError, + MicIdentityUnavailableError, + MicPrismDiscoveryWire, + normalizeMicIdentity, + normalizeMicPrismDiscovery, + type MicPrismCapability, + type MicPrismPermission, + hasMicPrismPermission, +} from "@q1code/core/micIdentity"; +import * as Context from "effect/Context"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import * as HttpServerRequest from "effect/unstable/http/HttpServerRequest"; +import * as Headers from "effect/unstable/http/Headers"; + +import { ForkFlagsService } from "../ForkFlags.ts"; + +const isServiceUrl = Schema.is(MicIdentityServiceUrl); + +/** Injection point for isolated authority contract tests; no session or grant cache. */ +export const MicIdentityFetch = Context.Reference("q1code/MicIdentityFetch", { + defaultValue: () => globalThis.fetch, +}); + +export const micIdentityPublicConfig = Effect.gen(function* () { + const flags = yield* ForkFlagsService; + if (!(yield* flags.current)["mic-identity"]) return { enabled: false }; + const config = (yield* flags.config)["mic-identity"]; + if (!config || !isServiceUrl(config.authorityUrl)) { + return yield* new MicIdentityUnavailableError({ reason: "configuration" }); + } + return { + enabled: true, + clerkPublishableKey: config.clerkPublishableKey, + authorityUrl: config.authorityUrl, + }; +}); + +/** A current authority response supplements, and never replaces, environment authentication. */ +export const requireMicIdentity = ( + permission: MicPrismCapability | MicPrismPermission = "inference", +) => + Effect.gen(function* () { + const capability: MicPrismCapability = + permission === "inference" || permission === "prism:inference" + ? "inference" + : permission === "accountDetails" || permission.endsWith(":read") + ? "accountDetails" + : "manage"; + const flags = yield* ForkFlagsService; + if (!(yield* flags.current)["mic-identity"]) return undefined; + const config = (yield* flags.config)["mic-identity"]; + if (!config || !isServiceUrl(config.authorityUrl)) { + return yield* new MicIdentityUnavailableError({ reason: "configuration" }); + } + const request = yield* HttpServerRequest.HttpServerRequest; + const token = request.headers[MIC_IDENTITY_SESSION_HEADER]; + if (!token || /[\r\n]/.test(token)) { + return yield* new MicIdentityUnauthorizedError({ reason: "sign-in-required" }); + } + const fetchAuthority = yield* MicIdentityFetch; + const read = >( + path: string, + schema: S, + ) => + Effect.gen(function* () { + const response = yield* Effect.tryPromise({ + try: (signal) => + fetchAuthority(`${config.authorityUrl.replace(/\/$/, "")}${path}`, { + headers: { authorization: `Bearer ${token}` }, + redirect: "error", + cache: "no-store", + signal, + }), + catch: () => new MicIdentityUnavailableError({ reason: "transport" }), + }); + if (response.status === 401) { + return yield* new MicIdentityUnauthorizedError({ reason: "invalid-session" }); + } + if (response.status === 403) + return yield* new MicIdentityForbiddenError({ capability: permission }); + if (!response.ok) return yield* new MicIdentityUnavailableError({ reason: "transport" }); + const body = yield* Effect.tryPromise({ + try: () => response.json() as Promise, + catch: () => new MicIdentityUnavailableError({ reason: "invalid-response" }), + }); + return yield* Schema.decodeUnknownEffect(schema)(body).pipe( + Effect.mapError(() => new MicIdentityUnavailableError({ reason: "invalid-response" })), + ); + }).pipe( + Effect.timeoutOrElse({ + duration: "10 seconds", + orElse: () => Effect.fail(new MicIdentityUnavailableError({ reason: "transport" })), + }), + ); + const session = normalizeMicIdentity( + yield* read(MIC_IDENTITY_API_PATHS.session, MicIdentityWire), + ); + if (session.state !== "active") { + return yield* new MicIdentityUnauthorizedError({ reason: "revoked-session" }); + } + if (Date.parse(session.expiresAt) <= DateTime.toEpochMillis(yield* DateTime.now)) { + return yield* new MicIdentityUnauthorizedError({ reason: "expired-session" }); + } + if ( + !(permission.startsWith("prism:") + ? hasMicPrismPermission(session, permission as MicPrismPermission) + : session.capabilities[capability]) + ) { + return yield* new MicIdentityForbiddenError({ capability: permission }); + } + const discovery = normalizeMicPrismDiscovery( + yield* read(MIC_IDENTITY_API_PATHS.prismService, MicPrismDiscoveryWire), + ); + if (!discovery.service) { + return yield* new MicIdentityUnavailableError({ reason: "unpaired-service" }); + } + if (discovery.service.status !== "paired") { + return yield* new MicIdentityUnavailableError({ reason: "revoked-service" }); + } + if (Date.parse(session.expiresAt) <= DateTime.toEpochMillis(yield* DateTime.now)) { + return yield* new MicIdentityUnauthorizedError({ reason: "expired-session" }); + } + return { session, discovery }; + }).pipe( + Effect.provideServiceEffect( + Headers.CurrentRedactedNames, + Effect.map(Headers.CurrentRedactedNames, (names) => [...names, MIC_IDENTITY_SESSION_HEADER]), + ), + ); + +/** Legacy management is allowed only for the authority's paired external service during migration. */ +export const requirePairedPrismTarget = (apiUrl: string) => + Effect.gen(function* () { + const flags = yield* ForkFlagsService; + const prism = (yield* flags.config).prism; + if ( + prism?.mode !== "external" || + !prism.external || + prism.external.baseUrl.replace(/\/$/, "") !== apiUrl.replace(/\/$/, "") + ) { + return yield* new MicIdentityUnavailableError({ reason: "configuration" }); + } + }); diff --git a/apps/server/src/fork/mic-identity/MicIdentityHttpApi.test.ts b/apps/server/src/fork/mic-identity/MicIdentityHttpApi.test.ts new file mode 100644 index 000000000000..3342cfc0eff5 --- /dev/null +++ b/apps/server/src/fork/mic-identity/MicIdentityHttpApi.test.ts @@ -0,0 +1,618 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import { DEFAULT_FORK_FLAGS } from "@q1code/core/flags"; +import type { ForkConfig } from "@q1code/core/config"; +import { + MIC_IDENTITY_API_PATHS, + MIC_IDENTITY_SESSION_HEADER, + MIC_PRISM_PERMISSIONS, + normalizeMicIdentity, + type MicIdentityWire, + type MicPrismDiscoveryWire, +} from "@q1code/core/micIdentity"; +import { PrismHttpApi } from "@q1code/core/prismApi"; +import { + AuthAdministrativeScopes, + AuthSessionId, + AuthStandardClientScopes, + EnvironmentAuthenticatedAuth, + EnvironmentAuthenticatedPrincipal, + EnvironmentAuthInvalidError, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; +import { + Etag, + HttpClientRequest, + HttpClientResponse, + HttpPlatform, + HttpServerRequest, +} from "effect/unstable/http"; +import * as HttpApiTest from "effect/unstable/httpapi/HttpApiTest"; + +import * as ServerConfig from "../../config.ts"; +import { ForkFlagsService } from "../ForkFlags.ts"; +import { prismHttpApiLayer } from "../prism/PrismHttpApi.ts"; +import { PrismService, type PrismStatus } from "../prism/PrismService.ts"; +import { PrismSyncNotConfigured, PrismSyncService } from "../prism/PrismSync.ts"; +import { MicIdentityFetch } from "./MicIdentityAccess.ts"; + +const apiUrl = "https://prism.example.test"; +const SESSION: MicIdentityWire = { + contractVersion: 1, + subject: "user-1", + role: "member", + permissions: ["prism:inference"], + authorizationRevision: "revision-1", + authorizationExpiresAt: 4_070_908_800_000, +}; +const MANAGER: MicIdentityWire = { + ...SESSION, + permissions: [...MIC_PRISM_PERMISSIONS], +}; +const DISCOVERY: MicPrismDiscoveryWire = { + contractVersion: 1, + selectionRevision: 1, + service: { + serviceInstanceId: "prism-pc", + displayName: "Paired PC", + apiOrigin: apiUrl, + inferenceOrigin: apiUrl, + pairingRevision: 1, + protocolVersion: 1, + publicKey: "A".repeat(43), + status: "paired", + }, +}; +const READY: PrismStatus = { + state: "ready", + mode: "external", + port: 8317, + since: "2026-09-04T00:00:00.000Z", + restarts: 0, + version: "7.2.151-prism.1", + baseUrl: apiUrl, + usageSource: true, +}; +const signedIn = { + headers: { authorization: "Bearer read", [MIC_IDENTITY_SESSION_HEADER]: "test-mic-session" }, +}; +const environmentAdmin = { + headers: { ...signedIn.headers, authorization: "Bearer admin" }, +}; + +const assertUnsupported = (error: { readonly _tag: string; readonly reason?: unknown }) => { + assert.equal(error._tag, "MicIdentityUnavailableError"); + assert.equal(error.reason, "unsupported-operation"); +}; + +// Environment auth is intentionally independent of the scripted mic.sc authority. +const authLayer = Layer.succeed(EnvironmentAuthenticatedAuth, (httpEffect) => + Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest; + const scopes = + request.headers.authorization === "Bearer admin" + ? AuthAdministrativeScopes + : request.headers.authorization === "Bearer read" + ? AuthStandardClientScopes + : undefined; + if (!scopes) + return yield* new EnvironmentAuthInvalidError({ + code: "auth_invalid", + reason: "missing_credential", + traceId: "test", + }); + return yield* httpEffect.pipe( + Effect.provideService(EnvironmentAuthenticatedPrincipal, { + sessionId: AuthSessionId.make("environment-session"), + subject: "environment-user", + method: "bearer-access-token", + scopes: new Set(scopes), + }), + ); + }), +); + +const makeFixture = ( + options: { + session?: MicIdentityWire; + identityEnabled?: boolean; + configuredApiUrl?: string; + } = {}, +) => { + const state = { + session: { status: 200, body: options.session ?? (SESSION as unknown) }, + discovery: { status: 200, body: DISCOVERY as unknown }, + transportFailure: false, + stalledBody: false, + restarts: 0, + syncExports: 0, + syncPushes: 0, + flagWrites: 0, + }; + const bodyStarted = Promise.withResolvers(); + const authorityCalls: Array<{ path: string; authorization: string | null }> = []; + const authorityFetch: typeof fetch = Object.assign( + async (input: Parameters[0], init?: Parameters[1]) => { + const path = new URL(input instanceof Request ? input.url : String(input)).pathname; + authorityCalls.push({ path, authorization: new Headers(init?.headers).get("authorization") }); + if (state.transportFailure) throw new Error("Authority offline"); + const response = path === MIC_IDENTITY_API_PATHS.session ? state.session : state.discovery; + const result = Response.json(response.body, { status: response.status }); + return state.stalledBody + ? Object.assign(result, { + json: () => { + bodyStarted.resolve(); + return new Promise(() => {}); + }, + }) + : result; + }, + { preconnect: () => {} }, + ); + const engineCalls: Array = []; + const proxyLayer = Layer.succeed( + PrismService, + PrismService.of({ + status: Effect.succeed(READY), + changes: Stream.empty, + endpoint: Effect.succeed(Option.none()), + restart: Effect.sync(() => { + state.restarts++; + return READY; + }), + reloadUsageSource: Effect.succeed(READY), + codexProxyHomePath: "/unused", + management: { + request: (path, request) => + Effect.sync(() => { + engineCalls.push(`${request?.method ?? "GET"} ${path}`); + const body = + path === "/auth-files" + ? { + files: [ + { + name: "account.json", + type: "codex", + email: "private@example.test", + disabled: true, + }, + ], + } + : path === "/routing/strategy" + ? { strategy: "round-robin" } + : {}; + return HttpClientResponse.fromWeb( + HttpClientRequest.get(`${apiUrl}${path}`), + Response.json(body), + ); + }), + }, + }), + ); + const config: ForkConfig = { + prism: { mode: "external", external: { baseUrl: options.configuredApiUrl ?? apiUrl } }, + "mic-identity": { + authorityUrl: "https://identity.example.test", + clerkPublishableKey: "pk_test_fixture", + }, + }; + const flags = { + ...DEFAULT_FORK_FLAGS, + prism: true, + "mic-identity": options.identityEnabled ?? true, + }; + const flagsLayer = Layer.succeed( + ForkFlagsService, + ForkFlagsService.of({ + current: Effect.succeed(flags), + reload: Effect.succeed(flags), + changes: Stream.empty, + config: Effect.succeed(config), + update: () => + Effect.sync(() => { + state.flagWrites++; + return config; + }), + }), + ); + const syncLayer = Layer.succeed( + PrismSyncService, + PrismSyncService.of({ + status: Effect.succeed({ role: "standalone" }), + changes: Stream.empty, + exportBundle: Effect.sync(() => { + state.syncExports++; + }).pipe( + Effect.andThen(Effect.fail(new PrismSyncNotConfigured({ message: "not configured" }))), + ), + applyPush: () => + Effect.sync(() => { + state.syncPushes++; + }).pipe( + Effect.andThen(Effect.fail(new PrismSyncNotConfigured({ message: "not configured" }))), + ), + syncNow: Effect.void, + recordTombstone: () => Effect.void, + }), + ); + const client = HttpApiTest.groups(PrismHttpApi, ["prism"]).pipe( + Effect.provide( + prismHttpApiLayer.pipe( + Layer.provide(proxyLayer), + Layer.provide(syncLayer), + Layer.provide(flagsLayer), + Layer.provide(Layer.succeed(MicIdentityFetch, authorityFetch)), + Layer.provide( + ServerConfig.layerTest(process.cwd(), { prefix: "q1code-mic-identity-http-" }), + ), + Layer.provideMerge(authLayer), + Layer.provideMerge(Layer.mergeAll(HttpPlatform.layer, Etag.layerWeak)), + ), + ), + ); + return { client, state, authorityCalls, engineCalls, bodyStarted: bodyStarted.promise }; +}; + +it.layer(NodeServices.layer, { excludeTestServices: true })( + "mic.sc Prism HTTP authorization", + (it) => { + it.effect( + "ordinary users see redacted status but cannot read account details or mutate the pool", + () => + Effect.gen(function* () { + const fixture = makeFixture(); + const client = yield* fixture.client; + const status = yield* client.prism.status(signedIn); + assert.deepEqual(status, { + state: "ready", + port: 0, + role: "standalone", + capabilities: normalizeMicIdentity(SESSION).capabilities, + }); + const details = yield* client.prism.listAccounts(signedIn).pipe(Effect.flip); + assert.equal(details._tag, "MicIdentityForbiddenError"); + const mutation = yield* client.prism + .deleteAccount({ ...environmentAdmin, params: { id: "account.json" } }) + .pipe(Effect.flip); + assert.equal(mutation._tag, "MicIdentityForbiddenError"); + assert.deepEqual(fixture.engineCalls, []); + assert.isTrue( + fixture.authorityCalls.every( + (call) => call.authorization === "Bearer test-mic-session", + ), + ); + }), + ); + + it.effect( + "a Prism manager cannot use a mic.sc grant through the shared-key legacy account proxy", + () => + Effect.gen(function* () { + const fixture = makeFixture({ session: MANAGER }); + const client = yield* fixture.client; + const error = yield* client.prism + .patchAccount({ + ...signedIn, + params: { id: "account.json" }, + payload: { disabled: true }, + }) + .pipe(Effect.flip); + assertUnsupported(error); + assert.deepEqual(fixture.engineCalls, []); + assert.equal(MANAGER.role, "member"); + }), + ); + + it.effect( + "even combined Prism and environment administration cannot reach legacy management", + () => + Effect.gen(function* () { + const fixture = makeFixture({ session: MANAGER }); + const client = yield* fixture.client; + const login = { ...environmentAdmin, params: { sessionId: "fixture-login" } }; + const failures = [ + yield* client.prism.listAccounts(environmentAdmin).pipe(Effect.flip), + yield* client.prism + .startLogin({ ...environmentAdmin, payload: { provider: "codex" } }) + .pipe(Effect.flip), + yield* client.prism.loginStatus(login).pipe(Effect.flip), + yield* client.prism.cancelLogin(login).pipe(Effect.flip), + yield* client.prism + .loginCallback({ ...login, payload: { redirectUrl: "http://localhost/callback" } }) + .pipe(Effect.flip), + yield* client.prism + .deleteAccount({ ...environmentAdmin, params: { id: "account.json" } }) + .pipe(Effect.flip), + yield* client.prism.restart(environmentAdmin).pipe(Effect.flip), + yield* client.prism.syncExport(environmentAdmin).pipe(Effect.flip), + yield* client.prism + .syncPush({ ...environmentAdmin, payload: { entries: [] } }) + .pipe(Effect.flip), + yield* client.prism.syncStatus(environmentAdmin).pipe(Effect.flip), + yield* client.prism + .setUsageSource({ ...environmentAdmin, payload: { enabled: false } }) + .pipe(Effect.flip), + yield* client.prism.getUsage(environmentAdmin).pipe(Effect.flip), + ]; + for (const error of failures) assertUnsupported(error); + assert.deepEqual(fixture.engineCalls, []); + assert.equal(fixture.state.restarts, 0); + assert.equal(fixture.state.syncExports, 0); + assert.equal(fixture.state.syncPushes, 0); + assert.equal(fixture.state.flagWrites, 0); + }), + ); + + it.effect( + "Prism management grants do not authorize environment restart, sync, or local settings", + () => + Effect.gen(function* () { + const fixture = makeFixture({ session: MANAGER }); + const client = yield* fixture.client; + const restart = yield* client.prism.restart(signedIn).pipe(Effect.flip); + const syncExport = yield* client.prism.syncExport(signedIn).pipe(Effect.flip); + const syncPush = yield* client.prism + .syncPush({ ...signedIn, payload: { entries: [] } }) + .pipe(Effect.flip); + const usageSource = yield* client.prism + .setUsageSource({ ...signedIn, payload: { enabled: false } }) + .pipe(Effect.flip); + for (const error of [restart, syncExport, syncPush, usageSource]) + assert.equal(error._tag, "EnvironmentScopeRequiredError"); + assert.equal(fixture.state.restarts, 0); + assert.equal(fixture.state.syncExports, 0); + assert.equal(fixture.state.syncPushes, 0); + assert.equal(fixture.state.flagWrites, 0); + }), + ); + + it.effect( + "granted legacy routing is unsupported while missing account grants remain forbidden", + () => + Effect.gen(function* () { + const fixture = makeFixture({ + session: { + ...SESSION, + permissions: ["prism:inference", "prism:routing:read", "prism:routing:write"], + }, + }); + const client = yield* fixture.client; + assertUnsupported(yield* client.prism.getRouting(signedIn).pipe(Effect.flip)); + assertUnsupported( + yield* client.prism + .setRouting({ ...signedIn, payload: { strategy: "round-robin" } }) + .pipe(Effect.flip), + ); + const before = fixture.engineCalls.length; + const error = yield* client.prism + .deleteAccount({ ...signedIn, params: { id: "account.json" } }) + .pipe(Effect.flip); + assert.equal(error._tag, "MicIdentityForbiddenError"); + assert.equal(before, 0); + assert.equal(fixture.engineCalls.length, 0); + }), + ); + + it.effect( + "account read and write grants remain distinct from each other and from routing", + () => + Effect.gen(function* () { + const fixture = makeFixture({ + session: { ...SESSION, permissions: ["prism:inference", "prism:accounts:write"] }, + }); + const client = yield* fixture.client; + assertUnsupported( + yield* client.prism + .deleteAccount({ ...signedIn, params: { id: "account.json" } }) + .pipe(Effect.flip), + ); + assert.equal( + (yield* client.prism.listAccounts(signedIn).pipe(Effect.flip))._tag, + "MicIdentityForbiddenError", + ); + fixture.state.session.body = { + ...SESSION, + permissions: ["prism:inference", "prism:accounts:read"], + }; + assertUnsupported(yield* client.prism.listAccounts(signedIn).pipe(Effect.flip)); + assert.equal( + (yield* client.prism.getRouting(signedIn).pipe(Effect.flip))._tag, + "MicIdentityForbiddenError", + ); + assert.equal( + (yield* client.prism + .deleteAccount({ ...signedIn, params: { id: "account.json" } }) + .pipe(Effect.flip))._tag, + "MicIdentityForbiddenError", + ); + assert.deepEqual(fixture.engineCalls, []); + }), + ); + + it.effect("mic.sc identity alone never grants environment access", () => + Effect.gen(function* () { + const fixture = makeFixture({ session: MANAGER }); + const client = yield* fixture.client; + const error = yield* client.prism + .status({ headers: { [MIC_IDENTITY_SESSION_HEADER]: "test-mic-session" } }) + .pipe(Effect.flip); + assert.equal(error._tag, "EnvironmentAuthInvalidError"); + assert.deepEqual(fixture.authorityCalls, []); + assert.deepEqual(fixture.engineCalls, []); + }), + ); + + it.effect( + "a missing identity session rejects an environment administrator before authority or engine access", + () => + Effect.gen(function* () { + const fixture = makeFixture({ session: MANAGER }); + const client = yield* fixture.client; + const error = yield* client.prism + .listAccounts({ headers: { authorization: "Bearer admin" } }) + .pipe(Effect.flip); + assert.equal(error._tag, "MicIdentityUnauthorizedError"); + assert.deepEqual(fixture.authorityCalls, []); + assert.deepEqual(fixture.engineCalls, []); + }), + ); + + it.effect( + "grant revocation is checked on the next request with the same identity and environment tokens", + () => + Effect.gen(function* () { + const fixture = makeFixture({ session: MANAGER }); + const client = yield* fixture.client; + assertUnsupported( + yield* client.prism + .deleteAccount({ ...signedIn, params: { id: "account.json" } }) + .pipe(Effect.flip), + ); + fixture.state.session.body = { ...SESSION, authorizationRevision: "revision-2" }; + const error = yield* client.prism + .deleteAccount({ ...signedIn, params: { id: "account.json" } }) + .pipe(Effect.flip); + assert.equal(error._tag, "MicIdentityForbiddenError"); + assert.equal( + fixture.authorityCalls.filter((call) => call.path === MIC_IDENTITY_API_PATHS.session) + .length, + 2, + ); + assert.equal(fixture.engineCalls.length, 0); + }), + ); + + it.effect("expired and malformed authority sessions fail closed", () => + Effect.gen(function* () { + for (const [body, expected] of [ + [{ ...MANAGER, authorizationExpiresAt: 1 }, "MicIdentityUnauthorizedError"], + [{ ...MANAGER, authorizationExpiresAt: "tomorrow" }, "MicIdentityUnavailableError"], + [{ ...MANAGER, permissions: [true] }, "MicIdentityUnavailableError"], + [{ ...MANAGER, contractVersion: 2 }, "MicIdentityUnavailableError"], + ] as const) { + const fixture = makeFixture(); + fixture.state.session.body = body; + const client = yield* fixture.client; + const error = yield* client.prism.listAccounts(signedIn).pipe(Effect.flip); + assert.equal(error._tag, expected); + assert.deepEqual(fixture.engineCalls, []); + assert.equal(fixture.authorityCalls.length, 1); + } + }), + ); + + it.effect("authority rejection and transport failures never reuse earlier permissions", () => + Effect.gen(function* () { + for (const status of [401, 403, 503]) { + const fixture = makeFixture({ session: MANAGER }); + const client = yield* fixture.client; + yield* client.prism.status(signedIn); + fixture.state.session.status = status; + const error = yield* client.prism.listAccounts(signedIn).pipe(Effect.flip); + assert.equal( + error._tag, + status === 401 + ? "MicIdentityUnauthorizedError" + : status === 403 + ? "MicIdentityForbiddenError" + : "MicIdentityUnavailableError", + ); + assert.equal(fixture.engineCalls.length, 0); + fixture.state.transportFailure = true; + const offline = yield* client.prism.listAccounts(signedIn).pipe(Effect.flip); + assert.equal(offline._tag, "MicIdentityUnavailableError"); + assert.equal(fixture.engineCalls.length, 0); + } + }), + ); + + it.effect("a service association cannot authorize a different configured Prism host", () => + Effect.gen(function* () { + const fixture = makeFixture({ + session: MANAGER, + configuredApiUrl: "https://other.example.test", + }); + const client = yield* fixture.client; + const error = yield* client.prism.status(signedIn).pipe(Effect.flip); + assert.equal(error._tag, "MicIdentityUnavailableError"); + if (error._tag === "MicIdentityUnavailableError") + assert.equal(error.reason, "configuration"); + assert.deepEqual(fixture.engineCalls, []); + }), + ); + + it.effect("an authority response whose body stalls times out without reaching Prism", () => + Effect.gen(function* () { + const fixture = makeFixture({ session: MANAGER }); + fixture.state.stalledBody = true; + const client = yield* fixture.client; + const request = yield* client.prism + .listAccounts(signedIn) + .pipe(Effect.flip, Effect.forkChild); + yield* Effect.promise(() => fixture.bodyStarted); + yield* TestClock.adjust("11 seconds"); + const error = yield* Fiber.join(request); + assert.equal(error._tag, "MicIdentityUnavailableError"); + assert.deepEqual(fixture.engineCalls, []); + }).pipe(Effect.provide(TestClock.layer())), + ); + + it.effect( + "missing, revoked, and malformed service discovery never reach the configured gateway", + () => + Effect.gen(function* () { + for (const body of [ + { contractVersion: 1, selectionRevision: 1, service: null }, + { ...DISCOVERY, service: { ...DISCOVERY.service, status: "revoked" } }, + { + ...DISCOVERY, + service: { ...DISCOVERY.service, apiOrigin: "http://public.example.test" }, + }, + ]) { + const fixture = makeFixture({ session: MANAGER }); + fixture.state.discovery.body = body; + const client = yield* fixture.client; + const error = yield* client.prism + .deleteAccount({ ...signedIn, params: { id: "account.json" } }) + .pipe(Effect.flip); + assert.equal(error._tag, "MicIdentityUnavailableError"); + assert.deepEqual(fixture.engineCalls, []); + } + }), + ); + + it.effect( + "identity off preserves the existing status response and environment authorization", + () => + Effect.gen(function* () { + const fixture = makeFixture({ identityEnabled: false }); + fixture.state.transportFailure = true; + const client = yield* fixture.client; + const read = { headers: { authorization: "Bearer read" } }; + assert.deepEqual(yield* client.prism.status(read), { + state: "ready", + mode: "external", + port: 8317, + since: "2026-09-04T00:00:00.000Z", + restarts: 0, + version: "7.2.151-prism.1", + baseUrl: apiUrl, + usageSource: true, + role: "standalone", + }); + const accounts = yield* client.prism.listAccounts(read); + assert.equal(accounts.accounts.length, 1); + const error = yield* client.prism + .deleteAccount({ ...read, params: { id: "account.json" } }) + .pipe(Effect.flip); + assert.equal(error._tag, "EnvironmentScopeRequiredError"); + assert.deepEqual(fixture.authorityCalls, []); + }), + ); + }, +); diff --git a/apps/server/src/fork/prism/PrismEnvironment.test.ts b/apps/server/src/fork/prism/PrismEnvironment.test.ts index e04df1bec0d0..4cb6987ebbb8 100644 --- a/apps/server/src/fork/prism/PrismEnvironment.test.ts +++ b/apps/server/src/fork/prism/PrismEnvironment.test.ts @@ -11,6 +11,8 @@ import { prismUsageLimitSource, prismUsageSourceChanges, publishPrismEndpoint, + publishPrismIdentityRequired, + currentPrismEndpoint, withPrismUsageLimitSource, } from "./PrismEnvironment.ts"; @@ -34,6 +36,46 @@ const prismEntry = { enabled: true, } as const; +it.effect( + "identity mode hides shared serving and management access and restores flags-off behavior", + () => + Effect.gen(function* () { + publishPrismIdentityRequired(false); + publishPrismEndpoint(endpoint); + const entries = [hub("https://hub.example")]; + const collected = yield* prismUsageSourceChanges.pipe( + Stream.take(2), + Stream.runCollect, + Effect.forkChild, + Effect.tap(() => Effect.yieldNow), + ); + try { + assert.strictEqual(currentPrismEndpoint(), endpoint); + publishPrismIdentityRequired(true); + assert.isUndefined(currentPrismEndpoint()); + assert.isUndefined(prismUsageLimitSource()); + assert.strictEqual(withPrismUsageLimitSource(entries), entries); + + // Re-publication cannot expose a key while human authorization is required. + const renewed = { ...endpoint, apiKey: "renewed-fixture-key" }; + publishPrismEndpoint(renewed); + assert.isUndefined(currentPrismEndpoint()); + assert.isUndefined(prismUsageLimitSource()); + publishPrismIdentityRequired(false); + assert.strictEqual(currentPrismEndpoint(), renewed); + assert.deepEqual(prismUsageLimitSource(), [UsageLimitSourceId.make("prism"), prismEntry]); + const events = yield* Fiber.join(collected); + assert.deepEqual( + events.map((entry) => entry?.[1].url), + [undefined, endpoint.baseUrl], + ); + } finally { + publishPrismEndpoint(undefined); + publishPrismIdentityRequired(false); + } + }), +); + it("publishes no usage-limit source while off or while the toggle is off", () => { publishPrismEndpoint(undefined); const entries = [hub("https://hub.example")]; diff --git a/apps/server/src/fork/prism/PrismEnvironment.ts b/apps/server/src/fork/prism/PrismEnvironment.ts index a1a546f8d0ff..c9582938c8b8 100644 --- a/apps/server/src/fork/prism/PrismEnvironment.ts +++ b/apps/server/src/fork/prism/PrismEnvironment.ts @@ -32,9 +32,20 @@ export type PrismUsageLimitSourceEntry = readonly [UsageLimitSourceId, UsageLimi let published: PrismEndpoint | undefined; let enabled = false; +let identityRequired = false; const endpointEvents = Effect.runSync(PubSub.unbounded()); export const prismEndpointChanges = Stream.fromPubSub(endpointEvents); export const isPrismEnabled = () => enabled; +export const isPrismIdentityRequired = () => identityRequired; +/** Identity mode cannot publish shared serving or management keys to provider/usage consumers. */ +export const publishPrismIdentityRequired = (value: boolean) => { + if (identityRequired === value) return; + const before = prismUsageLimitSource(); + identityRequired = value; + PubSub.publishUnsafe(endpointEvents, undefined); + const after = prismUsageLimitSource(); + if (!sameEntry(before, after)) PubSub.publishUnsafe(usageSourcePubSub, after); +}; export const publishPrismEnabled = (value: boolean) => { if (enabled === value) return; enabled = value; @@ -64,7 +75,8 @@ export const publishPrismEndpoint = (endpoint: PrismEndpoint | undefined): void if (!sameEntry(before, after)) PubSub.publishUnsafe(usageSourcePubSub, after); }; -export const currentPrismEndpoint = (): PrismEndpoint | undefined => published; +export const currentPrismEndpoint = (): PrismEndpoint | undefined => + identityRequired ? undefined : published; /** * Fires whenever the Prism usage-limit source appears, disappears, or points @@ -83,7 +95,7 @@ export const refreshOnPrismUsageSourceChange = ( /** The `usageLimitSources` entry for the proxy; defined only while it is ready and `prism.usageSource` is on. */ export const prismUsageLimitSource = (): PrismUsageLimitSourceEntry | undefined => { const endpoint = published; - if (endpoint === undefined || !endpoint.usageSource) return undefined; + if (identityRequired || endpoint === undefined || !endpoint.usageSource) return undefined; return [ UsageLimitSourceId.make(PRISM_USAGE_SOURCE_ID), { diff --git a/apps/server/src/fork/prism/PrismHttpApi.ts b/apps/server/src/fork/prism/PrismHttpApi.ts index 5a7d860671bf..9fb9447801e8 100644 --- a/apps/server/src/fork/prism/PrismHttpApi.ts +++ b/apps/server/src/fork/prism/PrismHttpApi.ts @@ -28,6 +28,9 @@ import { } from "@q1code/core/prismApi"; import { PrismRoutingStrategy } from "@q1code/core/config"; import { AuthAccessWriteScope, AuthOrchestrationReadScope } from "@t3tools/contracts"; +import type { MicIdentityAccess } from "@q1code/core/micIdentityApi"; +import { MIC_IDENTITY_SESSION_HEADER, MicIdentityUnavailableError } from "@q1code/core/micIdentity"; +import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -38,6 +41,9 @@ import * as Predicate from "effect/Predicate"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import { HttpClientResponse } from "effect/unstable/http"; +import * as Headers from "effect/unstable/http/Headers"; +import * as HttpEffect from "effect/unstable/http/HttpEffect"; +import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"; import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder"; import { @@ -52,6 +58,16 @@ import * as ForkFlags from "../ForkFlags.ts"; import { prismAuthsDir, prismDirectories } from "./PrismConfig.ts"; import * as Prism from "./PrismService.ts"; import * as PrismSync from "./PrismSync.ts"; +import { + micIdentityPublicConfig, + requireMicIdentity, + requirePairedPrismTarget, +} from "../mic-identity/MicIdentityAccess.ts"; + +const CurrentMicIdentityAccess = Context.Reference( + "q1code/CurrentMicIdentityAccess", + { defaultValue: () => undefined }, +); /** What we read from the sidecar; everything else it returns is ignored. */ const AuthFileEntry = Schema.Struct({ @@ -354,55 +370,123 @@ export const prismHttpApiLayer = HttpApiBuilder.group( ), ); - const withRead = (name: string, body: Effect.Effect) => - annotateEnvironmentRequest(name).pipe( - Effect.andThen(requireEnvironmentScope(AuthOrchestrationReadScope)), - Effect.andThen(body), + const privateResponse = HttpEffect.appendPreResponseHandler((_request, response) => + Effect.succeed(HttpServerResponse.setHeader(response, "cache-control", "no-store")), + ); + const withAccess = (name: string, body: Effect.Effect, write: boolean) => + Effect.gen(function* () { + yield* annotateEnvironmentRequest(name); + const identityEnabled = (yield* flags.current)["mic-identity"]; + yield* requireEnvironmentScope( + write && !identityEnabled ? AuthAccessWriteScope : AuthOrchestrationReadScope, + ); + yield* privateResponse; + const permission = + name === "status" + ? "prism:inference" + : name === "getRouting" + ? "prism:routing:read" + : name === "setRouting" + ? "prism:routing:write" + : write + ? "prism:accounts:write" + : "prism:accounts:read"; + const access = yield* requireMicIdentity(permission).pipe( + Effect.provideService(ForkFlags.ForkFlagsService, flags), + ); + const localOperation = ["restart", "setUsageSource", "syncExport", "syncPush"].includes( + name, + ); + if (write && (!access || localOperation)) + yield* requireEnvironmentScope(AuthAccessWriteScope); + if (access && name !== "status") + return yield* new MicIdentityUnavailableError({ reason: "unsupported-operation" }); + if (access) + yield* requirePairedPrismTarget(access.discovery.service!.apiUrl).pipe( + Effect.provideService(ForkFlags.ForkFlagsService, flags), + ); + return yield* body.pipe(Effect.provideService(CurrentMicIdentityAccess, access)); + }).pipe( + Effect.provideServiceEffect( + Headers.CurrentRedactedNames, + Effect.map(Headers.CurrentRedactedNames, (names) => [ + ...names, + MIC_IDENTITY_SESSION_HEADER, + ]), + ), ); + const withRead = (name: string, body: Effect.Effect) => + withAccess(name, body, false); const withWrite = (name: string, body: Effect.Effect) => - annotateEnvironmentRequest(name).pipe( - Effect.andThen(requireEnvironmentScope(AuthAccessWriteScope)), - Effect.andThen(body), - ); + withAccess(name, body, true); /** The proxy status plus the sync status, in the wire shape. */ const fullStatus = (status: Prism.PrismStatus) => - sync.status.pipe( - Effect.map( - (syncStatus) => - ({ - state: status.state, - port: status.port, - ...(status.version !== undefined ? { version: status.version } : {}), - role: syncStatus.role, - ...(syncStatus.lastSyncAt !== undefined ? { lastSyncAt: syncStatus.lastSyncAt } : {}), - ...(syncStatus.lastSyncError !== undefined - ? { lastSyncError: syncStatus.lastSyncError } - : {}), - mode: status.mode, - ...(status.baseUrl !== undefined ? { baseUrl: status.baseUrl } : {}), - ...(status.lastError !== undefined ? { lastError: status.lastError } : {}), - restarts: status.restarts, - since: status.since, - usageSource: status.usageSource, - }) satisfies PrismStatus, - ), - ); + Effect.gen(function* () { + const syncStatus = yield* sync.status; + const identity = yield* CurrentMicIdentityAccess; + if (identity && !identity.session.capabilities.accountDetails) { + return { + state: status.state, + port: 0, + role: "standalone" as const, + capabilities: identity.session.capabilities, + } satisfies PrismStatus; + } + return { + state: status.state, + port: status.port, + ...(status.version !== undefined ? { version: status.version } : {}), + role: syncStatus.role, + ...(syncStatus.lastSyncAt !== undefined ? { lastSyncAt: syncStatus.lastSyncAt } : {}), + ...(syncStatus.lastSyncError !== undefined + ? { lastSyncError: syncStatus.lastSyncError } + : {}), + mode: status.mode, + ...(status.baseUrl !== undefined ? { baseUrl: status.baseUrl } : {}), + ...(status.lastError !== undefined ? { lastError: status.lastError } : {}), + restarts: status.restarts, + since: status.since, + usageSource: status.usageSource, + ...(identity ? { capabilities: identity.session.capabilities } : {}), + } satisfies PrismStatus; + }); return handlers - .handle("status", (args) => - withRead(args.endpoint.name, proxy.status.pipe(Effect.flatMap(fullStatus))), + .handle("identityConfig", () => + annotateEnvironmentRequest("identityConfig").pipe( + Effect.andThen(requireEnvironmentScope(AuthOrchestrationReadScope)), + Effect.andThen(privateResponse), + Effect.andThen( + micIdentityPublicConfig.pipe(Effect.provideService(ForkFlags.ForkFlagsService, flags)), + ), + ), + ) + .handle("identityAccess", () => + annotateEnvironmentRequest("identityAccess").pipe( + Effect.andThen(requireEnvironmentScope(AuthOrchestrationReadScope)), + Effect.andThen(privateResponse), + Effect.andThen( + requireMicIdentity().pipe(Effect.provideService(ForkFlags.ForkFlagsService, flags)), + ), + Effect.flatMap((access) => + access + ? Effect.succeed(access) + : Effect.fail(new MicIdentityUnavailableError({ reason: "configuration" })), + ), + ), ) - .handle("restart", (args) => + .handle("status", () => withRead("status", proxy.status.pipe(Effect.flatMap(fullStatus)))) + .handle("restart", () => withWrite( - args.endpoint.name, + "restart", requireFlag.pipe(Effect.andThen(proxy.restart), Effect.flatMap(fullStatus)), ), ) .handle("setUsageSource", (args) => withWrite( - args.endpoint.name, + "setUsageSource", requireFlag.pipe( Effect.andThen(persistPrismSection({ usageSource: args.payload.enabled })), Effect.andThen(proxy.reloadUsageSource), @@ -410,9 +494,9 @@ export const prismHttpApiLayer = HttpApiBuilder.group( ), ), ) - .handle("listAccounts", (args) => + .handle("listAccounts", () => withRead( - args.endpoint.name, + "listAccounts", requireReady.pipe( Effect.andThen(listAccounts), Effect.map((accounts) => ({ accounts })), @@ -421,7 +505,7 @@ export const prismHttpApiLayer = HttpApiBuilder.group( ) .handle("startLogin", (args) => withWrite( - args.endpoint.name, + "startLogin", Effect.gen(function* () { yield* requireReady; yield* requireAccountOwner; @@ -445,13 +529,13 @@ export const prismHttpApiLayer = HttpApiBuilder.group( ) .handle("loginStatus", (args) => withRead( - args.endpoint.name, + "loginStatus", requireReady.pipe(Effect.andThen(loginStatus(args.params.sessionId))), ), ) .handle("loginCallback", (args) => withWrite( - args.endpoint.name, + "loginCallback", Effect.gen(function* () { yield* requireReady; yield* requireAccountOwner; @@ -469,7 +553,7 @@ export const prismHttpApiLayer = HttpApiBuilder.group( ) .handle("cancelLogin", (args) => withWrite( - args.endpoint.name, + "cancelLogin", Effect.gen(function* () { yield* requireReady; yield* requireAccountOwner; @@ -489,7 +573,7 @@ export const prismHttpApiLayer = HttpApiBuilder.group( ) .handle("patchAccount", (args) => withWrite( - args.endpoint.name, + "patchAccount", Effect.gen(function* () { yield* requireReady; yield* requireAccountOwner; @@ -515,7 +599,7 @@ export const prismHttpApiLayer = HttpApiBuilder.group( ) .handle("deleteAccount", (args) => withWrite( - args.endpoint.name, + "deleteAccount", Effect.gen(function* () { yield* requireReady; yield* requireAccountOwner; @@ -536,12 +620,12 @@ export const prismHttpApiLayer = HttpApiBuilder.group( }), ), ) - .handle("getRouting", (args) => - withRead(args.endpoint.name, requireReady.pipe(Effect.andThen(getRouting))), + .handle("getRouting", () => + withRead("getRouting", requireReady.pipe(Effect.andThen(getRouting))), ) .handle("setRouting", (args) => withWrite( - args.endpoint.name, + "setRouting", requireReady.pipe( Effect.andThen( call(Ignored, "/routing/strategy", json("PUT", { value: args.payload.strategy })), @@ -551,9 +635,9 @@ export const prismHttpApiLayer = HttpApiBuilder.group( ), ), ) - .handle("getUsage", (args) => + .handle("getUsage", () => withRead( - args.endpoint.name, + "getUsage", requireReady.pipe( Effect.andThen(call(UsageResponse, "/api-key-usage")), Effect.map((usage): PrismUsage => @@ -576,9 +660,9 @@ export const prismHttpApiLayer = HttpApiBuilder.group( ), ), ) - .handle("syncExport", (args) => + .handle("syncExport", () => withWrite( - args.endpoint.name, + "syncExport", requireFlag.pipe( Effect.andThen(sync.exportBundle), Effect.catchTag("PrismSyncNotConfigured", () => unavailable("sync-not-configured")), @@ -587,14 +671,14 @@ export const prismHttpApiLayer = HttpApiBuilder.group( ) .handle("syncPush", (args) => withWrite( - args.endpoint.name, + "syncPush", requireFlag.pipe( Effect.andThen(sync.applyPush(args.payload.entries, args.payload.tombstones ?? [])), Effect.catchTag("PrismSyncNotConfigured", () => unavailable("sync-not-configured")), ), ), ) - .handle("syncStatus", (args) => withRead(args.endpoint.name, sync.status)); + .handle("syncStatus", () => withRead("syncStatus", sync.status)); }), ); diff --git a/apps/server/src/fork/prism/PrismProviderDriver.ts b/apps/server/src/fork/prism/PrismProviderDriver.ts index eff0309055fd..856132fd778a 100644 --- a/apps/server/src/fork/prism/PrismProviderDriver.ts +++ b/apps/server/src/fork/prism/PrismProviderDriver.ts @@ -11,7 +11,12 @@ import type { ProviderDriver } from "../../provider/ProviderDriver.ts"; import { ProviderAdapterRequestError } from "../../provider/Errors.ts"; import { ServerConfig } from "../../config.ts"; import { materializeCodexProxyHome } from "./CodexProxyHome.ts"; -import { currentPrismEndpoint, isPrismEnabled, prismEndpointChanges } from "./PrismEnvironment.ts"; +import { + currentPrismEndpoint, + isPrismEnabled, + isPrismIdentityRequired, + prismEndpointChanges, +} from "./PrismEnvironment.ts"; import { withPrismRouteOption } from "./PrismRouting.ts"; import { makePrismRoutedAdapter } from "./PrismRoutedAdapter.ts"; @@ -36,6 +41,14 @@ export const withPrismProvider = lock.withPermits(1)( Effect.gen(function* () { + if (isPrismIdentityRequired()) { + return yield* new ProviderAdapterRequestError({ + provider: driver.driverKind, + method: "prism.setup", + detail: + "Prism authorization required: mic.sc inference credentials are not available for this provider session.", + }); + } const endpoint = currentPrismEndpoint(); if (!endpoint) return undefined; if (cached?.baseUrl === endpoint.baseUrl && cached.apiKey === endpoint.apiKey) @@ -73,13 +86,14 @@ export const withPrismProvider = - new ProviderAdapterRequestError({ - provider: driver.driverKind, - method: "prism.setup", - detail: "Could not prepare the Prism provider connection.", - }), + Effect.mapError((error) => + error._tag === "ProviderAdapterRequestError" + ? error + : new ProviderAdapterRequestError({ + provider: driver.driverKind, + method: "prism.setup", + detail: "Could not prepare the Prism provider connection.", + }), ), ), ); diff --git a/apps/server/src/fork/prism/PrismService.ts b/apps/server/src/fork/prism/PrismService.ts index 146cccef693b..48664e9e9bf9 100644 --- a/apps/server/src/fork/prism/PrismService.ts +++ b/apps/server/src/fork/prism/PrismService.ts @@ -67,6 +67,7 @@ import { type PrismEndpoint, publishPrismEndpoint, publishPrismEnabled, + publishPrismIdentityRequired, } from "./PrismEnvironment.ts"; import { materializeCodexProxyHome } from "./CodexProxyHome.ts"; @@ -353,9 +354,10 @@ const make = Effect.gen(function* () { const lifecycle = yield* Semaphore.make(1); const runScope = yield* Scope.make("sequential"); yield* Effect.addFinalizer(() => - Effect.sync(() => publishPrismEnabled(false)).pipe( - Effect.andThen(Scope.close(runScope, Exit.void)), - ), + Effect.sync(() => { + publishPrismEnabled(false); + publishPrismIdentityRequired(false); + }).pipe(Effect.andThen(Scope.close(runScope, Exit.void))), ); /** Apply a patch derived from the current status; entering a new state stamps `since`. */ @@ -627,11 +629,12 @@ const make = Effect.gen(function* () { yield* Effect.logInfo("prism: stopped"); }); - const apply = (values: { readonly prism: boolean }) => + const apply = (values: { readonly prism: boolean; readonly "mic-identity": boolean }) => lifecycle.withPermits(1)( - Effect.sync(() => publishPrismEnabled(values.prism)).pipe( - Effect.andThen(values.prism ? start : stop), - ), + Effect.sync(() => { + publishPrismEnabled(values.prism); + publishPrismIdentityRequired(values["mic-identity"]); + }).pipe(Effect.andThen(values.prism && !values["mic-identity"] ? start : stop)), ); yield* apply(yield* flags.current); diff --git a/packages/client-runtime/src/fork/index.ts b/packages/client-runtime/src/fork/index.ts index 9e00dbb92e8d..02fb88073bee 100644 --- a/packages/client-runtime/src/fork/index.ts +++ b/packages/client-runtime/src/fork/index.ts @@ -1,2 +1,5 @@ export * from "./forkFlags.ts"; +export * from "./micIdentityClient.ts"; +export * from "./micPrismClient.ts"; export * from "./prismClient.ts"; +export * from "./prismAccess.ts"; diff --git a/packages/client-runtime/src/fork/micIdentityClient.test.ts b/packages/client-runtime/src/fork/micIdentityClient.test.ts new file mode 100644 index 000000000000..acb1a7f1b05f --- /dev/null +++ b/packages/client-runtime/src/fork/micIdentityClient.test.ts @@ -0,0 +1,291 @@ +import { describe, expect, it } from "@effect/vitest"; +import { + MicIdentityUnavailableError, + type MicIdentityWire, + type MicPrismDiscoveryWire, +} from "@q1code/core/micIdentity"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as TestClock from "effect/testing/TestClock"; + +import { remoteHttpClientLayer } from "../rpc/http.ts"; +import { getMicIdentityAccess } from "./micIdentityClient.ts"; + +// Actual v1 contract shapes with synthetic identity, grants and pairing only. +const session = { + contractVersion: 1, + subject: "user_fixture", + role: "member", + permissions: ["prism:inference"], + authorizationRevision: '1000:["member","active",[]]', + authorizationExpiresAt: 4_070_908_800_000, +} satisfies MicIdentityWire; +const discovery = { + contractVersion: 1, + selectionRevision: 1, + service: { + serviceInstanceId: "prism-pc", + displayName: "PC Prism", + apiOrigin: "https://prism.example.test", + inferenceOrigin: "https://inference.example.test", + pairingRevision: 1, + protocolVersion: 1, + publicKey: "MCowBQYDK2VwAyEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + status: "paired", + }, +} satisfies MicPrismDiscoveryWire; + +const capture = (respond: (url: string, init: RequestInit) => Response) => { + const calls: Array<{ url: string; init: RequestInit }> = []; + const fetchFn = ((request, init) => { + const url = String(request); + calls.push({ url, init: init ?? {} }); + return Promise.resolve(respond(url, init ?? {})); + }) satisfies typeof fetch; + return { calls, layer: remoteHttpClientLayer(fetchFn) }; +}; +const input = { + baseUrl: "https://identity.example.test", + getToken: () => Effect.succeed("fixture-session-token"), +}; + +describe("mic.sc identity transport", () => { + it.effect("bounds an unavailable sign-in adapter without making a network request", () => + Effect.gen(function* () { + const server = capture(() => Response.json(session)); + const fiber = yield* getMicIdentityAccess({ + ...input, + getToken: () => Effect.never, + timeoutMs: 100, + }).pipe(Effect.provide(server.layer), Effect.flip, Effect.forkChild); + yield* TestClock.adjust(100); + const failure = yield* Fiber.join(fiber); + expect(failure).toMatchObject({ _tag: "MicIdentityUnavailableError", reason: "transport" }); + expect(server.calls).toHaveLength(0); + }), + ); + + it.effect("gets fresh identity, permissions and pairing for every operation", () => + Effect.gen(function* () { + let tokenNumber = 0; + const server = capture((url) => + Response.json(url.endsWith("/identity") ? session : discovery), + ); + const operation = getMicIdentityAccess({ + ...input, + getToken: () => Effect.sync(() => `fixture-token-${++tokenNumber}`), + }).pipe(Effect.provide(server.layer)); + const first = yield* operation; + yield* operation; + expect(first.session).toMatchObject({ + state: "active", + subject: session.subject, + permissions: session.permissions, + authorizationRevision: session.authorizationRevision, + expiresAt: "2099-01-01T00:00:00.000Z", + }); + expect(first.session).not.toHaveProperty("sessionId"); + expect(first.discovery).toMatchObject({ + selectionRevision: 1, + service: { id: "prism-pc", apiUrl: discovery.service.apiOrigin, pairingRevision: 1 }, + }); + expect(first.session.capabilities).toEqual({ + inference: true, + manage: false, + accountDetails: false, + }); + expect( + server.calls.map((call) => new Headers(call.init.headers).get("authorization")), + ).toEqual([ + "Bearer fixture-token-1", + "Bearer fixture-token-1", + "Bearer fixture-token-2", + "Bearer fixture-token-2", + ]); + expect(server.calls.map((call) => call.url)).toEqual([ + "https://identity.example.test/v1/identity", + "https://identity.example.test/v1/prism/discovery", + "https://identity.example.test/v1/identity", + "https://identity.example.test/v1/prism/discovery", + ]); + for (const call of server.calls) { + expect(call.init).toMatchObject({ + redirect: "error", + credentials: "omit", + cache: "no-store", + }); + } + }), + ); + + it.effect("does not infer grants from global admin or reuse a previously allowed session", () => + Effect.gen(function* () { + let checks = 0; + const server = capture((url) => { + if (!url.endsWith("/identity")) return Response.json(discovery); + checks++; + return Response.json({ + ...session, + role: "global_admin", + authorizationRevision: `revision-${checks}`, + permissions: + checks === 1 ? ["prism:inference", "prism:routing:write"] : ["prism:inference"], + }); + }); + const operation = getMicIdentityAccess({ ...input, permission: "prism:routing:write" }).pipe( + Effect.provide(server.layer), + ); + yield* operation; + const failure = yield* operation.pipe(Effect.flip); + expect(failure).toMatchObject({ + _tag: "MicIdentityForbiddenError", + capability: "prism:routing:write", + }); + expect(server.calls).toHaveLength(3); + }), + ); + + it.effect("rejects an expired authorization before discovery", () => + Effect.gen(function* () { + const server = capture(() => Response.json({ ...session, authorizationExpiresAt: 0 })); + const failure = yield* getMicIdentityAccess(input).pipe( + Effect.provide(server.layer), + Effect.flip, + ); + expect(failure._tag).toBe("MicIdentityUnauthorizedError"); + expect(server.calls).toHaveLength(1); + }), + ); + + it.effect("does not retain a host after pairing revocation", () => + Effect.gen(function* () { + let paired = true; + const server = capture((url) => + Response.json( + url.endsWith("/identity") + ? session + : { + ...discovery, + service: paired ? discovery.service : null, + }, + ), + ); + const operation = getMicIdentityAccess(input).pipe(Effect.provide(server.layer)); + yield* operation; + paired = false; + const failure = yield* operation.pipe(Effect.flip); + expect(failure).toMatchObject({ + _tag: "MicIdentityUnavailableError", + reason: "unpaired-service", + }); + expect(server.calls).toHaveLength(4); + }), + ); + + it.effect("rejects unpaired discovery and invalid service endpoints", () => + Effect.gen(function* () { + for (const service of [ + null, + { ...discovery.service, apiOrigin: "not an origin" }, + { ...discovery.service, apiOrigin: "https://user:secret@example.test" }, + { ...discovery.service, apiOrigin: "http://public.example.test" }, + { ...discovery.service, apiOrigin: "https://example.test/arbitrary/path" }, + { ...discovery.service, inferenceOrigin: "https://example.test?key=secret" }, + ]) { + const server = capture((url) => + Response.json(url.endsWith("/identity") ? session : { ...discovery, service }), + ); + const failure = yield* getMicIdentityAccess(input).pipe( + Effect.provide(server.layer), + Effect.flip, + ); + expect(failure).toMatchObject({ + _tag: "MicIdentityUnavailableError", + reason: service === null ? "unpaired-service" : "invalid-response", + }); + } + }), + ); + + it.effect("fails closed on malformed identity responses without echoing their contents", () => + Effect.gen(function* () { + for (const response of [ + { ...session, contractVersion: 2 }, + { ...session, permissions: [true] }, + { ...session, role: "unknown" }, + { ...session, authorizationExpiresAt: "fixture-secret-not-a-date" }, + { ...session, authorizationExpiresAt: 9_000_000_000_000_000 }, + { ...session, subject: "" }, + ]) { + const server = capture(() => Response.json(response)); + const failure = yield* getMicIdentityAccess(input).pipe( + Effect.provide(server.layer), + Effect.flip, + ); + expect(failure).toMatchObject({ + _tag: "MicIdentityUnavailableError", + reason: "invalid-response", + }); + expect(String(failure)).not.toContain("fixture-secret"); + expect(failure).not.toHaveProperty("cause"); + expect(server.calls).toHaveLength(1); + } + }), + ); + + it.effect("never sends a request without a current token", () => + Effect.gen(function* () { + const server = capture(() => Response.json(session)); + const failure = yield* getMicIdentityAccess({ + ...input, + getToken: () => Effect.succeed(null), + }).pipe(Effect.provide(server.layer), Effect.flip); + expect(failure).toMatchObject({ + _tag: "MicIdentityUnauthorizedError", + reason: "sign-in-required", + }); + expect(server.calls).toHaveLength(0); + }), + ); + + it.effect("does not retry rejections, redirects, or server errors", () => + Effect.gen(function* () { + for (const status of [401, 403, 302, 500]) { + const server = capture(() => new Response("fixture-private-upstream-message", { status })); + const failure = yield* getMicIdentityAccess(input).pipe( + Effect.provide(server.layer), + Effect.flip, + ); + expect(failure._tag).toBe( + status === 401 + ? "MicIdentityUnauthorizedError" + : status === 403 + ? "MicIdentityForbiddenError" + : "MicIdentityUnavailableError", + ); + expect(String(failure)).not.toContain("fixture-private"); + expect(failure).not.toHaveProperty("cause"); + expect(server.calls).toHaveLength(1); + } + }), + ); + + it.effect("sanitizes sign-in adapter and transport failures", () => + Effect.gen(function* () { + const server = capture(() => Response.json(session)); + const signInFailure = yield* getMicIdentityAccess({ + ...input, + getToken: () => Effect.fail(new MicIdentityUnavailableError({ reason: "transport" })), + }).pipe(Effect.provide(server.layer), Effect.flip); + const fetchFn = (() => + Promise.reject(new Error("fixture-private-token"))) satisfies typeof fetch; + const networkFailure = yield* getMicIdentityAccess(input).pipe( + Effect.provide(remoteHttpClientLayer(fetchFn)), + Effect.flip, + ); + expect(signInFailure).not.toHaveProperty("cause"); + expect(networkFailure).not.toHaveProperty("cause"); + expect(String(networkFailure)).not.toContain("fixture-private-token"); + }), + ); +}); diff --git a/packages/client-runtime/src/fork/micIdentityClient.ts b/packages/client-runtime/src/fork/micIdentityClient.ts new file mode 100644 index 000000000000..b0e9258aaad5 --- /dev/null +++ b/packages/client-runtime/src/fork/micIdentityClient.ts @@ -0,0 +1,168 @@ +/** Request-time access checks against the mic.sc v1 identity authority. */ +import { + MIC_IDENTITY_API_PATHS, + MicIdentityForbiddenError, + MicIdentityServiceUrl, + type MicIdentitySession, + MicIdentityWire, + MicIdentityUnauthorizedError, + MicIdentityUnavailableError, + type MicPrismDiscovery, + MicPrismDiscoveryWire, + hasMicPrismPermission, + normalizeMicIdentity, + normalizeMicPrismDiscovery, + type MicIdentityClientError, + type MicPrismCapability, + type MicPrismPermission, +} from "@q1code/core/micIdentity"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import { FetchHttpClient, HttpClient, HttpClientResponse } from "effect/unstable/http"; + +export type { MicIdentityClientError } from "@q1code/core/micIdentity"; + +/** The sign-in adapter resolves the current token; callers must not cache it. */ +export type MicIdentityTokenSource = () => Effect.Effect; + +export interface MicIdentityClientInput { + readonly baseUrl: string; + readonly getToken: MicIdentityTokenSource; + readonly capability?: MicPrismCapability; + /** Prefer exact permissions for gateway operations; capability is presentation compatibility. */ + readonly permission?: MicPrismPermission; + readonly timeoutMs?: number; + /** Binds an operation to the initiating signed-in account across async steps. */ + readonly isCurrent?: () => boolean; +} + +const decodeServiceUrl = Schema.decodeUnknownEffect(MicIdentityServiceUrl); + +export const requireCurrentMicIdentity = Effect.fn("requireCurrentMicIdentity")(function* ( + input: Pick, +) { + if (input.isCurrent && !input.isCurrent()) { + return yield* new MicIdentityUnauthorizedError({ reason: "revoked-session" }); + } +}); + +export const resolveMicIdentityToken = Effect.fn("resolveMicIdentityToken")(function* ( + getToken: MicIdentityTokenSource, +) { + const token = yield* Effect.suspend(getToken).pipe( + Effect.mapError(() => new MicIdentityUnauthorizedError({ reason: "sign-in-required" })), + ); + if (token === null || token.length === 0) { + return yield* new MicIdentityUnauthorizedError({ reason: "sign-in-required" }); + } + if (!/^[A-Za-z0-9._~-]+$/.test(token)) { + return yield* new MicIdentityUnauthorizedError({ reason: "invalid-session" }); + } + return token; +}); + +/** Both the UI and server verify the authority's explicit grants and expiry. */ +export const requireMicIdentityCapability = Effect.fn("requireMicIdentityCapability")(function* ( + session: MicIdentitySession, + capability: MicPrismCapability | MicPrismPermission, +) { + if (session.state !== "active") { + return yield* new MicIdentityUnauthorizedError({ reason: "revoked-session" }); + } + const now = yield* DateTime.now; + if ( + DateTime.toEpochMillis(DateTime.makeUnsafe(session.expiresAt)) <= DateTime.toEpochMillis(now) + ) { + return yield* new MicIdentityUnauthorizedError({ reason: "expired-session" }); + } + const permitted = + capability === "inference" || capability === "manage" || capability === "accountDetails" + ? session.capabilities[capability] + : hasMicPrismPermission(session, capability); + if (!permitted) { + return yield* new MicIdentityForbiddenError({ capability }); + } +}); + +/** No stored authorization, retries, redirect following, or provider credentials. */ +export const getMicIdentityAccess = Effect.fn("getMicIdentityAccess")( + function* ( + input: MicIdentityClientInput, + ): Effect.fn.Return< + { readonly session: MicIdentitySession; readonly discovery: MicPrismDiscovery }, + MicIdentityClientError, + HttpClient.HttpClient + > { + yield* requireCurrentMicIdentity(input); + const baseUrl = yield* decodeServiceUrl(input.baseUrl).pipe( + Effect.mapError(() => new MicIdentityUnavailableError({ reason: "configuration" })), + ); + const token = yield* resolveMicIdentityToken(input.getToken); + yield* requireCurrentMicIdentity(input); + const client = yield* HttpClient.HttpClient; + const capability = input.permission ?? input.capability ?? "prism:inference"; + + const read = >( + path: string, + schema: S, + ) => + Effect.gen(function* () { + yield* requireCurrentMicIdentity(input); + const response = yield* client + .get(`${baseUrl.replace(/\/$/, "")}${path}`, { + headers: { authorization: `Bearer ${token}`, accept: "application/json" }, + }) + .pipe( + // The service CORS contract permits authentication, not trace headers. + Effect.provideService(HttpClient.TracerPropagationEnabled, false), + Effect.mapError(() => new MicIdentityUnavailableError({ reason: "transport" })), + ); + yield* requireCurrentMicIdentity(input); + if (response.status === 401) { + return yield* new MicIdentityUnauthorizedError({ reason: "invalid-session" }); + } + if (response.status === 403) return yield* new MicIdentityForbiddenError({ capability }); + if (response.status < 200 || response.status >= 300) { + return yield* new MicIdentityUnavailableError({ reason: "transport" }); + } + const body = yield* HttpClientResponse.schemaBodyJson(schema)(response).pipe( + Effect.mapError(() => new MicIdentityUnavailableError({ reason: "invalid-response" })), + ); + yield* requireCurrentMicIdentity(input); + return body; + }); + + return yield* Effect.gen(function* () { + const session = normalizeMicIdentity( + yield* read(MIC_IDENTITY_API_PATHS.session, MicIdentityWire), + ); + yield* requireMicIdentityCapability(session, capability); + const discovery = normalizeMicPrismDiscovery( + yield* read(MIC_IDENTITY_API_PATHS.prismService, MicPrismDiscoveryWire), + ); + if (discovery.service === null) { + return yield* new MicIdentityUnavailableError({ reason: "unpaired-service" }); + } + if (discovery.service.status !== "paired") { + return yield* new MicIdentityUnavailableError({ reason: "revoked-service" }); + } + // A slow discovery call cannot extend the authority's expiry. + yield* requireMicIdentityCapability(session, capability); + return { session, discovery }; + }).pipe( + Effect.provideService(FetchHttpClient.RequestInit, { + redirect: "error", + credentials: "omit", + cache: "no-store", + }), + ); + }, + (effect, input) => + effect.pipe( + Effect.timeoutOrElse({ + duration: input.timeoutMs ?? 15_000, + orElse: () => Effect.fail(new MicIdentityUnavailableError({ reason: "transport" })), + }), + ), +); diff --git a/packages/client-runtime/src/fork/micPrismClient.test.ts b/packages/client-runtime/src/fork/micPrismClient.test.ts new file mode 100644 index 000000000000..6b6afd041449 --- /dev/null +++ b/packages/client-runtime/src/fork/micPrismClient.test.ts @@ -0,0 +1,271 @@ +import { describe, expect, it } from "@effect/vitest"; +import type { MicIdentityWire, MicPrismDiscoveryWire } from "@q1code/core/micIdentity"; +import * as Effect from "effect/Effect"; +import { HttpClient } from "effect/unstable/http"; + +import { remoteHttpClientLayer } from "../rpc/http.ts"; +import { getMicPrismRouting, getMicPrismStatus, setMicPrismRouting } from "./micPrismClient.ts"; + +const identity: MicIdentityWire = { + contractVersion: 1, + subject: "user_fixture", + role: "member", + permissions: ["prism:inference"], + authorizationExpiresAt: 4_070_908_800_000, + authorizationRevision: '1000:["member","active",[]]', +}; +const discovery: MicPrismDiscoveryWire = { + contractVersion: 1, + selectionRevision: 1, + service: { + serviceInstanceId: "prism-pc", + displayName: "PC Prism", + apiOrigin: "https://gateway.example.test", + inferenceOrigin: "https://gateway.example.test", + pairingRevision: 1, + protocolVersion: 1, + publicKey: "MCowBQYDK2VwAyEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + status: "paired", + }, +}; +const status = { + serviceInstanceId: "prism-pc", + pairingRevision: 1, + authorization: "current", + engineHealth: "unknown", +}; + +const harness = ( + options: { + permissions?: ReadonlyArray; + gateway?: (url: string, init: RequestInit) => Response; + discovery?: () => MicPrismDiscoveryWire; + } = {}, +) => { + const calls: Array<{ url: string; init: RequestInit }> = []; + let tokenNumber = 0; + const fetchFn = ((request, init) => { + const url = String(request); + calls.push({ url, init: init ?? {} }); + return Promise.resolve( + url.endsWith("/v1/identity") + ? Response.json({ ...identity, permissions: options.permissions ?? identity.permissions }) + : url.endsWith("/v1/prism/discovery") + ? Response.json(options.discovery?.() ?? discovery) + : (options.gateway?.(url, init ?? {}) ?? Response.json(status)), + ); + }) satisfies typeof fetch; + return { + calls, + layer: remoteHttpClientLayer(fetchFn), + input: { + baseUrl: "https://identity.example.test", + getToken: () => Effect.sync(() => `fixture-token-${++tokenNumber}`), + }, + }; +}; + +describe("direct mic.sc Prism gateway", () => { + it.effect( + "keeps trace headers out of cross-origin service requests without disabling other tracing", + () => + Effect.gen(function* () { + const h = harness(); + yield* Effect.gen(function* () { + yield* getMicPrismStatus(h.input); + // The scoped service override must not alter ordinary environment HTTP. + const client = yield* HttpClient.HttpClient; + yield* client.get("https://control.example.test"); + }).pipe( + Effect.withSpan("mic-identity-cors-regression"), + Effect.withTracerEnabled(true), + Effect.provideService(HttpClient.TracerPropagationEnabled, true), + Effect.provide(h.layer), + ); + expect(h.calls).toHaveLength(4); + for (const request of h.calls.slice(0, 3)) { + const headers = new Headers(request.init.headers); + expect(headers.get("authorization")).toMatch(/^Bearer fixture-token-/); + expect(headers.has("b3")).toBe(false); + expect(headers.has("traceparent")).toBe(false); + } + const controlHeaders = new Headers(h.calls[3]?.init.headers); + expect(controlHeaders.has("b3")).toBe(true); + expect(controlHeaders.has("traceparent")).toBe(true); + }), + ); + + it.effect("uses admitted identity without issuing or sending environment credentials", () => + Effect.gen(function* () { + const h = harness(); + expect(yield* getMicPrismStatus(h.input).pipe(Effect.provide(h.layer))).toEqual(status); + expect(h.calls.map((call) => call.url)).toEqual([ + "https://identity.example.test/v1/identity", + "https://identity.example.test/v1/prism/discovery", + "https://gateway.example.test/prism/v1/status", + ]); + expect(h.calls.map((call) => new Headers(call.init.headers).get("authorization"))).toEqual([ + "Bearer fixture-token-1", + "Bearer fixture-token-1", + "Bearer fixture-token-2", + ]); + const headers = new Headers(h.calls[2]?.init.headers); + expect(headers.has("dpop")).toBe(false); + expect(headers.has("x-mic-sc-session")).toBe(false); + expect(h.calls[2]?.init).toMatchObject({ + credentials: "omit", + redirect: "error", + cache: "no-store", + }); + }), + ); + + it.effect("requires routing read and write independently", () => + Effect.gen(function* () { + const h = harness({ + permissions: ["prism:inference", "prism:routing:read"], + gateway: () => Response.json({ strategy: "round-robin" }), + }); + expect(yield* getMicPrismRouting(h.input).pipe(Effect.provide(h.layer))).toEqual({ + strategy: "round-robin", + }); + const failure = yield* setMicPrismRouting({ ...h.input, strategy: "fill-first" }).pipe( + Effect.provide(h.layer), + Effect.flip, + ); + expect(failure).toMatchObject({ + _tag: "MicIdentityForbiddenError", + capability: "prism:routing:write", + }); + expect(h.calls).toHaveLength(4); + expect(h.calls.some((call) => call.init.method === "PUT")).toBe(false); + }), + ); + + it.effect("does not turn account or routing write grants into routing read", () => + Effect.gen(function* () { + for (const permission of ["prism:accounts:read", "prism:routing:write"]) { + const h = harness({ permissions: ["prism:inference", permission] }); + const failure = yield* getMicPrismRouting(h.input).pipe( + Effect.provide(h.layer), + Effect.flip, + ); + expect(failure).toMatchObject({ + _tag: "MicIdentityForbiddenError", + capability: "prism:routing:read", + }); + expect(h.calls).toHaveLength(1); + } + }), + ); + + it.effect("sends one supported routing mutation and verifies the applied strategy", () => + Effect.gen(function* () { + const h = harness({ + permissions: ["prism:inference", "prism:routing:write"], + gateway: () => Response.json({ strategy: "fill-first" }), + }); + expect( + yield* setMicPrismRouting({ ...h.input, strategy: "fill-first" }).pipe( + Effect.provide(h.layer), + ), + ).toEqual({ strategy: "fill-first" }); + const request = h.calls[2]; + expect(request?.init.method).toBe("PUT"); + const body = request?.init.body; + expect(typeof body === "string" ? body : new TextDecoder().decode(body as Uint8Array)).toBe( + '{"strategy":"fill-first"}', + ); + expect(h.calls).toHaveLength(3); + }), + ); + + it.effect("rejects status from a different instance or pairing revision", () => + Effect.gen(function* () { + for (const change of [{ serviceInstanceId: "another-instance" }, { pairingRevision: 2 }]) { + const h = harness({ gateway: () => Response.json({ ...status, ...change }) }); + const failure = yield* getMicPrismStatus(h.input).pipe( + Effect.provide(h.layer), + Effect.flip, + ); + expect(failure).toMatchObject({ + _tag: "MicIdentityUnavailableError", + reason: "revoked-service", + }); + } + }), + ); + + it.effect("does not replay a rejected or unconfirmed mutation", () => + Effect.gen(function* () { + for (const response of [ + Response.json({ error: { code: "access_revoked" } }, { status: 403 }), + Response.json({ strategy: "round-robin" }), + Response.json({ error: { code: "upstream_unavailable" } }, { status: 503 }), + ]) { + const h = harness({ + permissions: ["prism:inference", "prism:routing:write"], + gateway: () => response, + }); + const failure = yield* setMicPrismRouting({ ...h.input, strategy: "fill-first" }).pipe( + Effect.provide(h.layer), + Effect.flip, + ); + expect(["MicIdentityForbiddenError", "MicIdentityUnavailableError"]).toContain( + failure._tag, + ); + expect(h.calls.filter((call) => call.init.method === "PUT")).toHaveLength(1); + } + }), + ); + + it.effect("reports an unsupported adapter instead of falling back to shared management", () => + Effect.gen(function* () { + const h = harness({ + permissions: ["prism:inference", "prism:routing:read"], + gateway: () => + Response.json({ error: { type: "prism_route_not_available" } }, { status: 404 }), + }); + const failure = yield* getMicPrismRouting(h.input).pipe(Effect.provide(h.layer), Effect.flip); + expect(failure).toMatchObject({ + _tag: "MicIdentityUnavailableError", + reason: "unsupported-operation", + }); + expect(h.calls).toHaveLength(3); + expect( + h.calls.some( + (call) => call.url.includes("/api/fork/prism") || call.url.includes("/v0/management"), + ), + ).toBe(false); + }), + ); + + it.effect("discovers the current selected origin on every operation", () => + Effect.gen(function* () { + let instance = 0; + const h = harness({ + discovery: () => { + instance++; + return { + ...discovery, + selectionRevision: instance, + service: { + ...discovery.service!, + serviceInstanceId: `instance-${instance}`, + apiOrigin: `https://gateway-${instance}.example.test`, + }, + }; + }, + gateway: () => Response.json({ ...status, serviceInstanceId: `instance-${instance}` }), + }); + yield* getMicPrismStatus(h.input).pipe(Effect.provide(h.layer)); + yield* getMicPrismStatus(h.input).pipe(Effect.provide(h.layer)); + expect( + h.calls.filter((call) => call.url.includes("/prism/v1/status")).map((call) => call.url), + ).toEqual([ + "https://gateway-1.example.test/prism/v1/status", + "https://gateway-2.example.test/prism/v1/status", + ]); + }), + ); +}); diff --git a/packages/client-runtime/src/fork/micPrismClient.ts b/packages/client-runtime/src/fork/micPrismClient.ts new file mode 100644 index 000000000000..54affbee5de0 --- /dev/null +++ b/packages/client-runtime/src/fork/micPrismClient.ts @@ -0,0 +1,150 @@ +import type { PrismRoutingStrategy } from "@q1code/core/config"; +import { + MicIdentityForbiddenError, + MicIdentityUnauthorizedError, + MicIdentityUnavailableError, + type MicIdentityClientError, + type MicPrismPermission, + type MicPrismService, +} from "@q1code/core/micIdentity"; +import { + MIC_PRISM_API_PATHS, + MicPrismGatewayRouting, + MicPrismGatewayStatus, +} from "@q1code/core/micPrismApi"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import { + FetchHttpClient, + HttpClient, + HttpClientRequest, + HttpClientResponse, +} from "effect/unstable/http"; + +import { + getMicIdentityAccess, + requireMicIdentityCapability, + requireCurrentMicIdentity, + resolveMicIdentityToken, + type MicIdentityClientInput, +} from "./micIdentityClient.ts"; + +export type { MicPrismGatewayRouting, MicPrismGatewayStatus } from "@q1code/core/micPrismApi"; + +/** Each named operation resolves current identity/pairing and uses only Prism grants. */ +const call = Effect.fn("micPrismClient.call")( + function* >( + input: MicIdentityClientInput, + permission: MicPrismPermission, + path: string, + schema: S, + strategy?: PrismRoutingStrategy, + ): Effect.fn.Return< + { readonly value: S["Type"]; readonly service: MicPrismService }, + MicIdentityClientError, + HttpClient.HttpClient + > { + const access = yield* getMicIdentityAccess({ ...input, permission }); + const service = access.discovery.service; + if (service === null) + return yield* new MicIdentityUnavailableError({ reason: "unpaired-service" }); + const token = yield* resolveMicIdentityToken(input.getToken); + yield* requireCurrentMicIdentity(input); + yield* requireMicIdentityCapability(access.session, permission); + const url = `${service.apiUrl.replace(/\/$/, "")}${path}`; + const client = yield* HttpClient.HttpClient; + const request = ( + strategy === undefined ? HttpClientRequest.get(url) : HttpClientRequest.put(url) + ).pipe( + HttpClientRequest.setHeaders({ + authorization: `Bearer ${token}`, + accept: "application/json", + }), + ); + const encoded = + strategy === undefined + ? request + : yield* HttpClientRequest.schemaBodyJson(MicPrismGatewayRouting)(request, { + strategy, + }).pipe( + Effect.mapError(() => new MicIdentityUnavailableError({ reason: "invalid-response" })), + ); + yield* requireCurrentMicIdentity(input); + const response = yield* client.execute(encoded).pipe( + Effect.provideService(HttpClient.TracerPropagationEnabled, false), + Effect.provideService(FetchHttpClient.RequestInit, { + credentials: "omit", + redirect: "error", + cache: "no-store", + }), + Effect.mapError(() => new MicIdentityUnavailableError({ reason: "transport" })), + ); + yield* requireCurrentMicIdentity(input); + if (response.status === 401) + return yield* new MicIdentityUnauthorizedError({ reason: "invalid-session" }); + if (response.status === 403) + return yield* new MicIdentityForbiddenError({ capability: permission }); + if (response.status === 404 || response.status === 405 || response.status === 501) { + return yield* new MicIdentityUnavailableError({ reason: "unsupported-operation" }); + } + if (response.status < 200 || response.status >= 300) { + return yield* new MicIdentityUnavailableError({ reason: "transport" }); + } + const value = yield* HttpClientResponse.schemaBodyJson(schema)(response).pipe( + Effect.mapError(() => new MicIdentityUnavailableError({ reason: "invalid-response" })), + ); + yield* requireCurrentMicIdentity(input); + yield* requireMicIdentityCapability(access.session, permission); + return { value, service }; + }, + (effect, input) => + effect.pipe( + Effect.timeoutOrElse({ + duration: input.timeoutMs ?? 15_000, + orElse: () => Effect.fail(new MicIdentityUnavailableError({ reason: "transport" })), + }), + ), +); + +export const getMicPrismStatus = Effect.fn("getMicPrismStatus")(function* ( + input: MicIdentityClientInput, +) { + const { value, service } = yield* call( + input, + "prism:inference", + MIC_PRISM_API_PATHS.status, + MicPrismGatewayStatus, + ); + if (value.serviceInstanceId !== service.id || value.pairingRevision !== service.pairingRevision) { + return yield* new MicIdentityUnavailableError({ reason: "revoked-service" }); + } + return value; +}); + +export const getMicPrismRouting = Effect.fn("getMicPrismRouting")(function* ( + input: MicIdentityClientInput, +) { + const { value } = yield* call( + input, + "prism:routing:read", + MIC_PRISM_API_PATHS.routing, + MicPrismGatewayRouting, + ); + return value; +}); + +export const setMicPrismRouting = Effect.fn("setMicPrismRouting")(function* ( + input: MicIdentityClientInput & { readonly strategy: PrismRoutingStrategy }, +) { + const { value } = yield* call( + input, + "prism:routing:write", + MIC_PRISM_API_PATHS.routing, + MicPrismGatewayRouting, + input.strategy, + ); + if (value.strategy !== input.strategy) { + return yield* new MicIdentityUnavailableError({ reason: "invalid-response" }); + } + return value; +}); diff --git a/packages/client-runtime/src/fork/prismAccess.test.ts b/packages/client-runtime/src/fork/prismAccess.test.ts new file mode 100644 index 000000000000..620d5be1c6ba --- /dev/null +++ b/packages/client-runtime/src/fork/prismAccess.test.ts @@ -0,0 +1,134 @@ +import { describe, expect, it } from "@effect/vitest"; +import type { PrismStatus } from "@q1code/core/prismApi"; + +import { INITIAL_PRISM_HEALTH, reducePrismHealth, resolvePrismAccess } from "./prismAccess.ts"; + +const status: PrismStatus = { state: "ready", role: "primary", port: 8317 }; +const admin = { authenticated: true, scopes: ["orchestration:read", "access:write"] }; +const received = (value = status) => + reducePrismHealth(INITIAL_PRISM_HEALTH, { type: "received", status: value, receivedAt: 100 }); + +describe("Prism client access", () => { + it("preserves the last known state after a failed probe but disables every mutation until recovery", () => { + const live = received(); + const failed = reducePrismHealth(live, { type: "failed", error: "Connection refused" }); + expect(failed.status).toBe(status); + expect(failed.receivedAt).toBe(100); + const offline = resolvePrismAccess({ health: failed, connected: true, session: admin }); + expect(offline).toMatchObject({ + live: false, + inference: false, + manage: false, + configure: false, + routing: false, + accounts: false, + }); + const recovered = reducePrismHealth(failed, { type: "received", status, receivedAt: 200 }); + expect(recovered.error).toBeNull(); + expect( + resolvePrismAccess({ health: recovered, connected: true, session: admin }), + ).toMatchObject({ configure: true, routing: true, accounts: true }); + }); + + it("never treats a successful read as permission to write on a legacy server", () => { + const access = resolvePrismAccess({ + health: received(), + connected: true, + session: { authenticated: true, scopes: ["orchestration:read"] }, + }); + expect(access).toMatchObject({ + accountDetails: true, + manage: false, + configure: false, + routing: false, + accounts: false, + }); + }); + + it.each([null, { authenticated: false, scopes: admin.scopes }, { authenticated: true }])( + "keeps legacy management disabled before write scopes are known: %j", + (session) => { + expect(resolvePrismAccess({ health: received(), connected: true, session }).manage).toBe( + false, + ); + }, + ); + + it("uses identity capabilities for Prism management while keeping local configuration behind access:write", () => { + const access = resolvePrismAccess({ + health: received({ + ...status, + capabilities: { inference: true, manage: true, accountDetails: true }, + }), + connected: true, + session: { authenticated: true, scopes: ["orchestration:read"] }, + }); + expect(access).toMatchObject({ + inference: true, + manage: true, + routing: true, + accounts: true, + configure: false, + }); + }); + + it("removes account details and management immediately after the server changes identity permissions", () => { + const before = received({ + ...status, + capabilities: { inference: true, manage: true, accountDetails: true }, + }); + const after = reducePrismHealth(before, { + type: "received", + status: { + ...status, + capabilities: { inference: true, manage: false, accountDetails: false }, + }, + receivedAt: 200, + }); + expect(resolvePrismAccess({ health: after, connected: true, session: admin })).toMatchObject({ + inference: true, + accountDetails: false, + manage: false, + configure: false, + accounts: false, + routing: false, + }); + }); + + it("does not reuse permissions while the environment is disconnected", () => { + expect( + resolvePrismAccess({ health: received(), connected: false, session: admin }), + ).toMatchObject({ live: false, configure: false, accounts: false, routing: false }); + }); + + it("allows reconnecting a failed gateway only after a successful authenticated status read", () => { + expect( + resolvePrismAccess({ + health: received({ ...status, state: "failed" }), + connected: true, + session: admin, + }), + ).toMatchObject({ live: true, configure: true, accounts: false, routing: false }); + }); + + it("does not allow replica account mutations or sign-in even for an administrator", () => { + expect( + resolvePrismAccess({ + health: received({ ...status, role: "replica" }), + connected: true, + session: admin, + }), + ).toMatchObject({ configure: true, routing: true, accounts: false }); + }); + + it("removes local write access when environment authentication refresh fails", () => { + expect( + resolvePrismAccess({ + health: received(), + connected: true, + session: admin, + sessionError: true, + }).configure, + ).toBe(false); + }); +}); diff --git a/packages/client-runtime/src/fork/prismAccess.ts b/packages/client-runtime/src/fork/prismAccess.ts new file mode 100644 index 000000000000..b92c6d9ec5ff --- /dev/null +++ b/packages/client-runtime/src/fork/prismAccess.ts @@ -0,0 +1,56 @@ +import type { PrismStatus } from "@q1code/core/prismApi"; + +export interface PrismHealth { + readonly status: PrismStatus | null; + readonly receivedAt: number; + readonly error: string | null; +} + +export const INITIAL_PRISM_HEALTH: PrismHealth = { + status: null, + receivedAt: 0, + error: null, +}; + +/** A failed probe preserves the last observation without presenting it as live. */ +export function reducePrismHealth( + state: PrismHealth, + event: + | { readonly type: "received"; readonly status: PrismStatus; readonly receivedAt: number } + | { readonly type: "failed"; readonly error: string }, +): PrismHealth { + return event.type === "received" + ? { status: event.status, receivedAt: event.receivedAt, error: null } + : { ...state, error: event.error }; +} + +/** Request capabilities are authoritative; local environment settings still require access:write. */ +export function resolvePrismAccess(input: { + readonly health: PrismHealth; + readonly connected: boolean; + readonly session: { + readonly authenticated: boolean; + readonly scopes?: ReadonlyArray; + } | null; + readonly sessionError?: boolean; +}) { + const { status, error } = input.health; + const live = input.connected && status !== null && error === null; + const scopes = + input.session?.authenticated && !input.sessionError ? input.session.scopes : undefined; + const write = scopes?.includes("access:write") === true; + const capabilities = status?.capabilities; + const manage = capabilities?.manage ?? write; + const details = capabilities?.accountDetails ?? scopes?.includes("orchestration:read") === true; + const ready = live && status.state === "ready"; + return { + live, + inference: + ready && (capabilities?.inference ?? scopes?.includes("orchestration:read") === true), + accountDetails: details, + manage: live && manage, + configure: live && manage && write, + routing: ready && manage, + accounts: ready && manage && details && status.role !== "replica", + }; +} diff --git a/packages/client-runtime/src/fork/prismClient.test.ts b/packages/client-runtime/src/fork/prismClient.test.ts index 2abbccee3612..e12067ed1589 100644 --- a/packages/client-runtime/src/fork/prismClient.test.ts +++ b/packages/client-runtime/src/fork/prismClient.test.ts @@ -2,6 +2,7 @@ import { EnvironmentId } from "@t3tools/contracts"; import { describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; +import { MIC_IDENTITY_SESSION_HEADER } from "@q1code/core/micIdentity"; import { RemoteEnvironmentAuthorization } from "../authorization/service.ts"; import { ManagedRelayDpopSigner, type ManagedRelayDpopProofInput } from "../relay/managedRelay.ts"; @@ -13,6 +14,7 @@ import { import { remoteHttpClientLayer } from "../rpc/http.ts"; import { deletePrismAccount, + getPrismIdentityConfig, getPrismStatus, listPrismAccounts, patchPrismAccount, @@ -46,6 +48,66 @@ const capture = (respond: (url: string, init: RequestInit) => Response) => { }; describe("prismClient", () => { + it.effect("bootstraps sign-in through environment auth without requesting a human token", () => + Effect.gen(function* () { + const server = capture(() => + Response.json({ enabled: true, clerkPublishableKey: "pk_test_fixture" }), + ); + const config = yield* getPrismIdentityConfig({ + prepared: prepared({ _tag: "Bearer", token: "environment-token" }), + signer: Option.none(), + micScToken: () => Effect.die("Sign-in bootstrap must not request a token"), + }).pipe(Effect.provide(server.layer)); + expect(config.enabled).toBe(true); + expect(server.calls[0]?.url).toBe( + "https://environment.example.test/api/fork/prism/identity/config", + ); + const headers = new Headers(server.calls[0]?.init.headers); + expect(headers.get("authorization")).toBe("Bearer environment-token"); + expect(headers.has(MIC_IDENTITY_SESSION_HEADER)).toBe(false); + }), + ); + + it.effect("adds current mic.sc identity without replacing environment auth or cookies", () => + Effect.gen(function* () { + let tokenNumber = 0; + const server = capture(() => Response.json({ state: "ready", port: 8317, role: "primary" })); + const operation = (authorization: PreparedConnection["httpAuthorization"]) => + getPrismStatus({ + prepared: prepared(authorization), + signer: Option.none(), + micScToken: () => Effect.sync(() => `fixture-mic-token-${++tokenNumber}`), + }).pipe(Effect.provide(server.layer)); + yield* operation({ _tag: "Bearer", token: "environment-token" }); + yield* operation(null); + expect(new Headers(server.calls[0]?.init.headers).get("authorization")).toBe( + "Bearer environment-token", + ); + expect(server.calls[1]?.init.credentials).toBe("include"); + expect( + server.calls.map((call) => new Headers(call.init.headers).get(MIC_IDENTITY_SESSION_HEADER)), + ).toEqual(["fixture-mic-token-1", "fixture-mic-token-2"]); + expect(server.calls.map((call) => call.init.redirect)).toEqual(["error", "error"]); + }), + ); + + it.effect("rejects signed-out identity before a mutation reaches the environment", () => + Effect.gen(function* () { + const server = capture(() => Response.json({ ok: true })); + const failure = yield* deletePrismAccount({ + prepared: prepared(null), + signer: Option.none(), + id: "fixture.json", + micScToken: () => Effect.succeed(null), + }).pipe(Effect.provide(server.layer), Effect.flip); + expect(failure).toMatchObject({ + _tag: "MicIdentityUnauthorizedError", + reason: "sign-in-required", + }); + expect(server.calls).toHaveLength(0); + }), + ); + it.effect("reads status with the bearer credential and decodes the body", () => Effect.gen(function* () { const server = capture(() => @@ -267,4 +329,78 @@ describe("Prism relay credential renewal", () => { expect(h.rejected).toEqual([undefined, "current-token"]); }), ); + + it.effect( + "renews both request credentials while retaining the current origin and DPoP proof", + () => + Effect.gen(function* () { + let tokenNumber = 0; + const h = harness((url) => + url.startsWith("https://current.") + ? unauthorized() + : Response.json({ state: "ready", port: 8317, role: "primary" }), + ); + yield* getPrismStatus({ + ...h.input, + micScToken: () => Effect.sync(() => `fixture-mic-token-${++tokenNumber}`), + }).pipe( + Effect.provideService(RemoteEnvironmentAuthorization, h.authorization), + Effect.provide(h.layer), + ); + expect( + h.calls.map((call) => new Headers(call.init.headers).get(MIC_IDENTITY_SESSION_HEADER)), + ).toEqual(["fixture-mic-token-1", "fixture-mic-token-2"]); + expect(h.calls.map((call) => new Headers(call.init.headers).get("authorization"))).toEqual([ + "DPoP current-token", + "DPoP renewed-token", + ]); + expect(h.proofs.map((proof) => proof.url)).toEqual(h.calls.map((call) => call.url)); + }), + ); + + it.effect("does not replay a mutation when mic.sc revokes the human session", () => + Effect.gen(function* () { + const h = harness(() => + Response.json( + { _tag: "MicIdentityUnauthorizedError", reason: "revoked-session" }, + { status: 401 }, + ), + ); + const failure = yield* deletePrismAccount({ + ...h.input, + id: "fixture.json", + micScToken: () => Effect.succeed("fixture-mic-token"), + }).pipe( + Effect.provideService(RemoteEnvironmentAuthorization, h.authorization), + Effect.provide(h.layer), + Effect.flip, + ); + expect(failure).toMatchObject({ + _tag: "MicIdentityUnauthorizedError", + reason: "revoked-session", + }); + expect(h.calls).toHaveLength(1); + expect(h.rejected).toEqual([undefined]); + }), + ); + + it.effect("does not expose a mic.sc token in transport failures", () => + Effect.gen(function* () { + const h = harness(() => { + throw new Error("fixture-mic-token"); + }); + const failure = yield* getPrismStatus({ + ...h.input, + micScToken: () => Effect.succeed("fixture-mic-token"), + }).pipe( + Effect.provideService(RemoteEnvironmentAuthorization, h.authorization), + Effect.provide(h.layer), + Effect.flip, + ); + expect(failure).toMatchObject({ _tag: "MicIdentityUnavailableError", reason: "transport" }); + expect(failure).not.toHaveProperty("cause"); + expect(String(failure)).not.toContain("fixture-mic-token"); + expect(h.calls).toHaveLength(1); + }), + ); }); diff --git a/packages/client-runtime/src/fork/prismClient.ts b/packages/client-runtime/src/fork/prismClient.ts index 641f0ebbee70..ba4b2ba6057b 100644 --- a/packages/client-runtime/src/fork/prismClient.ts +++ b/packages/client-runtime/src/fork/prismClient.ts @@ -52,9 +52,15 @@ import { type PrismUsage, } from "@q1code/core/prismApi"; import type { PrismRoutingStrategy } from "@q1code/core/config"; +import type { MicIdentityAccess, MicIdentityPublicConfig } from "@q1code/core/micIdentityApi"; +import { + MIC_IDENTITY_SESSION_HEADER, + MicIdentityUnavailableError, + type MicIdentityClientError, +} from "@q1code/core/micIdentity"; import * as Effect from "effect/Effect"; -import type * as Option from "effect/Option"; -import type { HttpClient, HttpMethod } from "effect/unstable/http"; +import * as Option from "effect/Option"; +import { FetchHttpClient, Headers, type HttpClient, type HttpMethod } from "effect/unstable/http"; import * as HttpApiClient from "effect/unstable/httpapi/HttpApiClient"; import { RemoteEnvironmentAuthorization } from "../authorization/service.ts"; @@ -62,6 +68,7 @@ import type { PreparedConnection } from "../connection/model.ts"; import type { ManagedRelayDpopSigner } from "../relay/managedRelay.ts"; import type { RemoteEnvironmentRequestError } from "../rpc/http.ts"; import { executeAuthenticatedEnvironmentHttpRequest } from "../state/environmentHttpAuth.ts"; +import { resolveMicIdentityToken, type MicIdentityTokenSource } from "./micIdentityClient.ts"; export type { PrismAccount, @@ -82,6 +89,8 @@ export interface PrismClientInput { readonly signer: Option.Option; readonly remoteAuthorization?: Option.Option; readonly timeoutMs?: number; + /** Additional human authorization; environment bearer/DPoP/cookies remain required. */ + readonly micScToken?: MicIdentityTokenSource; } type DeclaredError = @@ -89,7 +98,8 @@ type DeclaredError = | PrismUpstreamError | PrismNotFoundError | PrismConfigError - | PrismSyncFailedError; + | PrismSyncFailedError + | MicIdentityClientError; export type PrismClientError = DeclaredError | RemoteEnvironmentRequestError; @@ -102,6 +112,9 @@ const DECLARED_TAGS = new Set([ "PrismNotFoundError", "PrismConfigError", "PrismSyncFailedError", + "MicIdentityUnauthorizedError", + "MicIdentityForbiddenError", + "MicIdentityUnavailableError", ]); const isDeclaredError = (error: unknown): error is DeclaredError => @@ -128,13 +141,18 @@ const call = ( endpoint: string & keyof Api["prism"], request: ( client: Api, - headers: { readonly authorization?: string; readonly dpop?: string }, + headers: { + readonly authorization?: string; + readonly dpop?: string; + readonly "x-mic-sc-session"?: string; + }, ) => Effect.Effect, params?: Record, ): Effect.Effect => Effect.gen(function* () { const remoteAuthorization = input.remoteAuthorization ?? (yield* Effect.serviceOption(RemoteEnvironmentAuthorization)); + const redactedHeaders = yield* Headers.CurrentRedactedNames; // Upstream may refresh the relay origin without replacing the prepared socket. // Build the fork client from the same origin used for each request's proof. let currentBaseUrl = input.prepared.httpBaseUrl; @@ -157,17 +175,51 @@ const call = ( }, request: ({ headers }) => Effect.gen(function* () { + const micToken = + input.micScToken === undefined + ? undefined + : yield* resolveMicIdentityToken(input.micScToken); const client = yield* HttpApiClient.make(PrismHttpApi, { baseUrl: currentBaseUrl }); - return yield* request(client, headers).pipe( - Effect.map((value): Outcome => ({ _tag: "ok", value })), - Effect.catch((error) => - isDeclaredError(error) - ? Effect.succeed>({ _tag: "declared", error }) - : Effect.fail(error), - ), + const operation = request( + client, + micToken === undefined + ? headers + : { ...headers, [MIC_IDENTITY_SESSION_HEADER]: micToken }, + ); + const requestInit = Option.getOrElse( + yield* Effect.serviceOption(FetchHttpClient.RequestInit), + () => ({}), ); - }), - }); + return yield* micToken === undefined + ? operation + : operation.pipe( + Effect.provideService(FetchHttpClient.RequestInit, { + ...requestInit, + redirect: "error", + cache: "no-store", + }), + ); + }).pipe( + Effect.map((value): Outcome => ({ _tag: "ok", value })), + Effect.catch((error) => + isDeclaredError(error) + ? Effect.succeed>({ _tag: "declared", error }) + : Effect.fail(error), + ), + ), + }).pipe( + Effect.provideService(Headers.CurrentRedactedNames, [ + ...redactedHeaders, + MIC_IDENTITY_SESSION_HEADER, + ]), + // Effect transport errors retain request headers. Do not put a human + // session token into a UI error, log, or serialized failure cause. + Effect.mapError((error) => + input.micScToken === undefined + ? error + : new MicIdentityUnavailableError({ reason: "transport" }), + ), + ); if (outcome._tag === "ok") return outcome.value; return yield* outcome.error; }); @@ -177,6 +229,23 @@ export const getPrismStatus = ( ): Effect.Effect => call(input, "GET", "status", (client, headers) => client.prism.status({ headers })); +/** Sign-in bootstrap still requires environment access, but not a human session. */ +export const getPrismIdentityConfig = ( + input: PrismClientInput, +): Effect.Effect => { + const { micScToken: _micScToken, ...environmentInput } = input; + return call(environmentInput, "GET", "identityConfig", (client, headers) => + client.prism.identityConfig({ headers }), + ); +}; + +export const getPrismIdentityAccess = ( + input: PrismClientInput, +): Effect.Effect => + call(input, "GET", "identityAccess", (client, headers) => + client.prism.identityAccess({ headers }), + ); + /** Turn the Limits-view publication of Prism's accounts on or off; answers with the status (`usageSource` reflects the new value). */ export const setPrismUsageSource = ( input: PrismClientInput & { readonly enabled: boolean }, diff --git a/packages/fork-core/package.json b/packages/fork-core/package.json index c2b091f387d6..fa509a2bbc60 100644 --- a/packages/fork-core/package.json +++ b/packages/fork-core/package.json @@ -19,9 +19,21 @@ "types": "./src/prism.ts", "import": "./src/prism.ts" }, + "./micIdentity": { + "types": "./src/micIdentity.ts", + "import": "./src/micIdentity.ts" + }, + "./micPrismApi": { + "types": "./src/micPrismApi.ts", + "import": "./src/micPrismApi.ts" + }, "./prismApi": { "types": "./src/prismApi.ts", "import": "./src/prismApi.ts" + }, + "./micIdentityApi": { + "types": "./src/micIdentityApi.ts", + "import": "./src/micIdentityApi.ts" } }, "scripts": { diff --git a/packages/fork-core/src/micIdentity.ts b/packages/fork-core/src/micIdentity.ts new file mode 100644 index 000000000000..896f57819db7 --- /dev/null +++ b/packages/fork-core/src/micIdentity.ts @@ -0,0 +1,276 @@ +/** + * mic.sc identity/discovery v1 contract, coordinated with mic.sc PR140. + * Capabilities are supplied by the identity authority; clients never derive + * grants from Clerk claims or treat a cached discovery record as authorization. + */ +import * as DateTime from "effect/DateTime"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as HttpServerRespondable from "effect/unstable/http/HttpServerRespondable"; +import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"; + +export const MIC_IDENTITY_SESSION_HEADER = "x-mic-sc-session"; +export const MIC_IDENTITY_CLERK_TEMPLATE = "convex"; +export const MIC_IDENTITY_API_PATHS = { + session: "/v1/identity", + prismService: "/v1/prism/discovery", +} as const; + +const Identifier = Schema.String.check(Schema.isPattern(/^[A-Za-z0-9_.:~-]{1,256}$/)); +const AuthorizationRevision = Schema.String.check(Schema.isNonEmpty(), Schema.isMaxLength(16_384)); +const Revision = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)); +const EpochMillis = Schema.Int.check( + Schema.isBetween({ minimum: 0, maximum: 8_640_000_000_000_000 }), +); +const Permissions = Schema.Array( + Schema.String.check(Schema.isNonEmpty(), Schema.isMaxLength(128)), +).check(Schema.isMaxLength(64)); +const Timestamp = Schema.String.check( + Schema.isPattern(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$/), + Schema.makeFilter((value) => Option.isSome(DateTime.make(value))), +); + +/** Service endpoints allow TLS or disposable loopback development servers. */ +export const MicIdentityServiceUrl = Schema.String.check( + Schema.makeFilter((value) => { + try { + const url = new URL(value); + return ( + (url.protocol === "https:" || + (url.protocol === "http:" && + ["127.0.0.1", "localhost", "[::1]"].includes(url.hostname))) && + url.username === "" && + url.password === "" && + url.search === "" && + url.hash === "" + ); + } catch { + return false; + } + }), +); + +export const MicPrismCapability = Schema.Literals(["inference", "manage", "accountDetails"]); +export type MicPrismCapability = typeof MicPrismCapability.Type; + +export const MIC_PRISM_PERMISSIONS = [ + "prism:inference", + "prism:accounts:read", + "prism:accounts:write", + "prism:routing:read", + "prism:routing:write", + "prism:settings:read", + "prism:settings:write", + "prism:instances:manage", +] as const; +export const MicPrismPermission = Schema.Literals(MIC_PRISM_PERMISSIONS); +export type MicPrismPermission = typeof MicPrismPermission.Type; + +const ServiceOrigin = MicIdentityServiceUrl.check( + Schema.makeFilter((value) => { + try { + return new URL(value).pathname === "/"; + } catch { + return false; + } + }), +); + +/** Actual authority response; no session binding or role claims are inferred. */ +export const MicIdentityWire = Schema.Struct({ + contractVersion: Schema.Literal(1), + subject: Identifier, + role: Schema.Literals(["global_admin", "member"]), + permissions: Permissions, + authorizationExpiresAt: EpochMillis, + authorizationRevision: AuthorizationRevision, +}); +export type MicIdentityWire = typeof MicIdentityWire.Type; + +export const MicPrismDiscoveryWire = Schema.Struct({ + contractVersion: Schema.Literal(1), + selectionRevision: Revision, + service: Schema.NullOr( + Schema.Struct({ + serviceInstanceId: Identifier, + displayName: Schema.String.check(Schema.isNonEmpty(), Schema.isMaxLength(256)), + apiOrigin: ServiceOrigin, + inferenceOrigin: ServiceOrigin, + pairingRevision: Schema.Int.check(Schema.isGreaterThanOrEqualTo(1)), + protocolVersion: Schema.Literal(1), + publicKey: Schema.String.check(Schema.isPattern(/^[A-Za-z0-9_-]{40,256}$/)), + status: Schema.Literal("paired"), + }), + ), +}); +export type MicPrismDiscoveryWire = typeof MicPrismDiscoveryWire.Type; + +export const MicIdentityCapabilities = Schema.Struct({ + inference: Schema.Boolean, + manage: Schema.Boolean, + accountDetails: Schema.Boolean, +}); +export type MicIdentityCapabilities = typeof MicIdentityCapabilities.Type; + +export const MicIdentitySession = Schema.Struct({ + version: Schema.Literal(1), + subject: Identifier, + /** Older local adapters may supply this; the identity authority does not. */ + sessionId: Schema.optionalKey(Identifier), + state: Schema.Literals(["active", "disabled", "revoked"]), + globalAdmin: Schema.Boolean, + capabilities: MicIdentityCapabilities, + permissions: Permissions, + authorizationRevision: AuthorizationRevision, + authorizationExpiresAt: EpochMillis, + expiresAt: Timestamp, +}); +export type MicIdentitySession = typeof MicIdentitySession.Type; + +export const MicPrismService = Schema.Struct({ + id: Identifier, + label: Schema.String.check(Schema.isNonEmpty()), + apiUrl: MicIdentityServiceUrl, + inferenceUrl: MicIdentityServiceUrl, + revision: Identifier, + pairingRevision: Revision, + protocolVersion: Schema.Literal(1), + publicKey: Schema.String, + status: Schema.Literals(["paired", "revoked"]), +}); +export type MicPrismService = typeof MicPrismService.Type; + +export const MicPrismDiscovery = Schema.Struct({ + version: Schema.Literal(1), + selectionRevision: Revision, + service: Schema.NullOr(MicPrismService), +}); +export type MicPrismDiscovery = typeof MicPrismDiscovery.Type; + +/** Exact operation grants remain distinct even when the UI shows an admin summary. */ +export function hasMicPrismPermission( + identity: Pick, + permission: MicPrismPermission, +): boolean { + return identity.permissions.includes(permission); +} + +export function normalizeMicIdentity(identity: MicIdentityWire): MicIdentitySession { + const permissions = [...new Set(identity.permissions)]; + return { + version: 1, + subject: identity.subject, + state: "active", + globalAdmin: identity.role === "global_admin", + permissions, + capabilities: { + inference: permissions.includes("prism:inference"), + // Presentation summary only. Every operation still checks its exact grant. + manage: MIC_PRISM_PERMISSIONS.filter((permission) => permission !== "prism:inference").every( + (permission) => permissions.includes(permission), + ), + accountDetails: permissions.includes("prism:accounts:read"), + }, + authorizationRevision: identity.authorizationRevision, + authorizationExpiresAt: identity.authorizationExpiresAt, + expiresAt: DateTime.formatIso(DateTime.makeUnsafe(identity.authorizationExpiresAt)), + }; +} + +export function normalizeMicPrismDiscovery(discovery: MicPrismDiscoveryWire): MicPrismDiscovery { + const service = discovery.service; + return { + version: 1, + selectionRevision: discovery.selectionRevision, + service: + service === null + ? null + : { + id: service.serviceInstanceId, + label: service.displayName, + apiUrl: service.apiOrigin, + inferenceUrl: service.inferenceOrigin, + revision: String(service.pairingRevision), + pairingRevision: service.pairingRevision, + protocolVersion: service.protocolVersion, + publicKey: service.publicKey, + status: service.status, + }, + }; +} + +export class MicIdentityUnauthorizedError extends Schema.TaggedErrorClass()( + "MicIdentityUnauthorizedError", + { + reason: Schema.Literals([ + "sign-in-required", + "invalid-session", + "expired-session", + "revoked-session", + ]), + }, + { httpApiStatus: 401 }, +) { + [HttpServerRespondable.symbol]() { + return HttpServerResponse.schemaJson(MicIdentityUnauthorizedError)(this, { status: 401 }); + } + + override get message(): string { + return "Sign in to mic.sc to continue."; + } +} + +export class MicIdentityForbiddenError extends Schema.TaggedErrorClass()( + "MicIdentityForbiddenError", + { capability: Schema.Union([MicPrismCapability, MicPrismPermission]) }, + { httpApiStatus: 403 }, +) { + [HttpServerRespondable.symbol]() { + return HttpServerResponse.schemaJson(MicIdentityForbiddenError)(this, { status: 403 }); + } + + override get message(): string { + return "Your mic.sc account does not have permission for this Prism operation."; + } +} + +export class MicIdentityUnavailableError extends Schema.TaggedErrorClass()( + "MicIdentityUnavailableError", + { + reason: Schema.Literals([ + "configuration", + "transport", + "invalid-response", + "unpaired-service", + "revoked-service", + "unsupported-operation", + ]), + }, + { httpApiStatus: 503 }, +) { + [HttpServerRespondable.symbol]() { + return HttpServerResponse.schemaJson(MicIdentityUnavailableError)(this, { status: 503 }); + } + + override get message(): string { + switch (this.reason) { + case "configuration": + return "mic.sc sign-in is not configured for this environment."; + case "unpaired-service": + return "No Prism service is paired with this account."; + case "revoked-service": + return "Access to the paired Prism service has been revoked."; + case "unsupported-operation": + return "This Prism service does not support that operation yet."; + default: + return "mic.sc could not verify Prism access. Try again when it is available."; + } + } +} + +export const MicIdentityError = Schema.Union([ + MicIdentityUnauthorizedError, + MicIdentityForbiddenError, + MicIdentityUnavailableError, +]); +export type MicIdentityClientError = typeof MicIdentityError.Type; diff --git a/packages/fork-core/src/micIdentityApi.ts b/packages/fork-core/src/micIdentityApi.ts new file mode 100644 index 000000000000..f3993a6b3ba0 --- /dev/null +++ b/packages/fork-core/src/micIdentityApi.ts @@ -0,0 +1,15 @@ +import * as Schema from "effect/Schema"; +import { MicIdentitySession, MicPrismDiscovery } from "./micIdentity.ts"; + +export const MicIdentityPublicConfig = Schema.Struct({ + enabled: Schema.Boolean, + clerkPublishableKey: Schema.optionalKey(Schema.String), + authorityUrl: Schema.optionalKey(Schema.String), +}); +export type MicIdentityPublicConfig = typeof MicIdentityPublicConfig.Type; + +export const MicIdentityAccess = Schema.Struct({ + session: MicIdentitySession, + discovery: MicPrismDiscovery, +}); +export type MicIdentityAccess = typeof MicIdentityAccess.Type; diff --git a/packages/fork-core/src/micPrismApi.ts b/packages/fork-core/src/micPrismApi.ts new file mode 100644 index 000000000000..c4a2b40daf20 --- /dev/null +++ b/packages/fork-core/src/micPrismApi.ts @@ -0,0 +1,20 @@ +/** Named adapters currently implemented by the independently authorized Prism gateway. */ +import * as Schema from "effect/Schema"; +import { PrismRoutingStrategy } from "./config.ts"; + +export const MIC_PRISM_API_PATHS = { + status: "/prism/v1/status", + routing: "/prism/v1/routing", +} as const; + +export const MicPrismGatewayStatus = Schema.Struct({ + serviceInstanceId: Schema.String.check(Schema.isNonEmpty()), + pairingRevision: Schema.Int.check(Schema.isGreaterThanOrEqualTo(1)), + authorization: Schema.Literal("current"), + // The initial gateway does not probe engine readiness or model eligibility. + engineHealth: Schema.Literal("unknown"), +}); +export type MicPrismGatewayStatus = typeof MicPrismGatewayStatus.Type; + +export const MicPrismGatewayRouting = Schema.Struct({ strategy: PrismRoutingStrategy }); +export type MicPrismGatewayRouting = typeof MicPrismGatewayRouting.Type; diff --git a/packages/fork-core/src/prismApi.test.ts b/packages/fork-core/src/prismApi.test.ts index 403e8797c3d9..06baf88e6839 100644 --- a/packages/fork-core/src/prismApi.test.ts +++ b/packages/fork-core/src/prismApi.test.ts @@ -32,6 +32,8 @@ describe("prism api contract", () => { "deleteAccount", "getRouting", "getUsage", + "identityAccess", + "identityConfig", "listAccounts", "loginCallback", "loginStatus", diff --git a/packages/fork-core/src/prismApi.ts b/packages/fork-core/src/prismApi.ts index 682e759c5762..9486980456bd 100644 --- a/packages/fork-core/src/prismApi.ts +++ b/packages/fork-core/src/prismApi.ts @@ -16,6 +16,12 @@ import * as HttpApiEndpoint from "effect/unstable/httpapi/HttpApiEndpoint"; import * as HttpApiGroup from "effect/unstable/httpapi/HttpApiGroup"; import { PrismMode, PrismRoutingStrategy } from "./config.ts"; +import { + MicIdentityForbiddenError, + MicIdentityUnauthorizedError, + MicIdentityUnavailableError, +} from "./micIdentity.ts"; +import { MicIdentityAccess, MicIdentityPublicConfig } from "./micIdentityApi.ts"; export const PRISM_API_PREFIX = "/api/fork/prism"; @@ -28,6 +34,8 @@ export const PRISM_USAGE_SOURCE_ID = "prism"; export const PRISM_USAGE_SOURCE_LABEL = "Prism"; export const PRISM_API_PATHS = { + identityConfig: `${PRISM_API_PREFIX}/identity/config`, + identityAccess: `${PRISM_API_PREFIX}/identity/access`, status: `${PRISM_API_PREFIX}/status`, restart: `${PRISM_API_PREFIX}/restart`, usageSource: `${PRISM_API_PREFIX}/usage-source`, @@ -54,6 +62,14 @@ const IsoTimestamp = Schema.String; export const PrismStatus = Schema.Struct({ state: PrismState, + /** Request-specific permissions; older servers rely on environment session scopes. */ + capabilities: Schema.optionalKey( + Schema.Struct({ + inference: Schema.Boolean, + manage: Schema.Boolean, + accountDetails: Schema.Boolean, + }), + ), port: Schema.Number, version: Schema.optionalKey(Schema.String), role: PrismRole, @@ -347,16 +363,36 @@ export class PrismSyncFailedError extends Schema.TaggedErrorClass Date: Sat, 5 Sep 2026 00:28:18 -0700 Subject: [PATCH 4/7] feat(prism): integrate mic.sc sign-in and permission-aware clients Add hosted onboarding without environment grants, fresh identity and paired-service access, scoped routing controls, and explicit stale or unsupported states across the clients. Fork-Feature: mic-identity Upstream: no --- .../src/fork/prism/PrismIdentitySection.tsx | 346 +++++++++++++++++ .../src/fork/prism/PrismSettingsRow.tsx | 13 +- .../src/fork/prism/PrismSettingsScreen.tsx | 217 +++++++---- .../src/fork/prism/micIdentity.logic.test.ts | 90 +++++ .../src/fork/prism/micIdentity.logic.ts | 33 ++ .../src/fork/prism/micIdentityContext.ts | 4 + .../fork/prism/prismSettings.logic.test.ts | 4 +- .../src/fork/prism/prismSettings.logic.ts | 8 +- apps/mobile/src/fork/prism/usePrismApi.ts | 18 +- .../App/Cloud/T3ConnectCapability.swift | 15 + apps/swift-ios/App/NativeFeatureClient.swift | 22 +- apps/swift-ios/Core/HTTP.swift | 29 +- apps/swift-ios/Core/PrismWireModels.swift | 56 +++ apps/swift-ios/Core/T3Client.swift | 8 +- apps/swift-ios/Features/Prism/PrismView.swift | 216 ++++++++--- .../Features/Shared/FeatureClient.swift | 8 + .../NativeContractExpansionTests.swift | 61 +++ .../fork/mic-identity/BrowserMicIdentity.tsx | 14 + .../fork/mic-identity/ElectronMicIdentity.tsx | 15 + .../mic-identity/MicIdentityNavigation.tsx | 45 +++ .../fork/mic-identity/MicIdentityPanel.tsx | 210 +++++++++++ .../fork/mic-identity/MicIdentitySession.tsx | 36 ++ .../mic-identity/micIdentitySession.test.ts | 178 +++++++++ .../fork/mic-identity/micIdentitySession.ts | 37 ++ .../fork/mic-identity/publicConfig.test.ts | 80 ++++ .../web/src/fork/mic-identity/publicConfig.ts | 49 +++ .../fork/mic-identity/useMicIdentityConfig.ts | 60 +++ apps/web/src/fork/prism/PrismAddAccount.tsx | 25 +- apps/web/src/fork/prism/PrismNavigation.tsx | 35 +- .../web/src/fork/prism/PrismSettingsPanel.tsx | 348 ++++++++++-------- .../web/src/fork/prism/PrismStatusSection.tsx | 90 +++-- apps/web/src/fork/prism/usePrismApi.ts | 65 +++- fork/FEATURES.md | 8 +- fork/docs/mic-identity.md | 57 +++ fork/docs/prism.md | 11 +- 35 files changed, 2140 insertions(+), 371 deletions(-) create mode 100644 apps/mobile/src/fork/prism/PrismIdentitySection.tsx create mode 100644 apps/mobile/src/fork/prism/micIdentity.logic.test.ts create mode 100644 apps/mobile/src/fork/prism/micIdentity.logic.ts create mode 100644 apps/mobile/src/fork/prism/micIdentityContext.ts create mode 100644 apps/web/src/fork/mic-identity/BrowserMicIdentity.tsx create mode 100644 apps/web/src/fork/mic-identity/ElectronMicIdentity.tsx create mode 100644 apps/web/src/fork/mic-identity/MicIdentityNavigation.tsx create mode 100644 apps/web/src/fork/mic-identity/MicIdentityPanel.tsx create mode 100644 apps/web/src/fork/mic-identity/MicIdentitySession.tsx create mode 100644 apps/web/src/fork/mic-identity/micIdentitySession.test.ts create mode 100644 apps/web/src/fork/mic-identity/micIdentitySession.ts create mode 100644 apps/web/src/fork/mic-identity/publicConfig.test.ts create mode 100644 apps/web/src/fork/mic-identity/publicConfig.ts create mode 100644 apps/web/src/fork/mic-identity/useMicIdentityConfig.ts create mode 100644 fork/docs/mic-identity.md diff --git a/apps/mobile/src/fork/prism/PrismIdentitySection.tsx b/apps/mobile/src/fork/prism/PrismIdentitySection.tsx new file mode 100644 index 000000000000..12ba82fdc14e --- /dev/null +++ b/apps/mobile/src/fork/prism/PrismIdentitySection.tsx @@ -0,0 +1,346 @@ +import { ClerkProvider, useAuth } from "@clerk/expo"; +import { AuthView } from "@clerk/expo/native"; +import type { MicIdentityAccess, MicIdentityPublicConfig } from "@q1code/core/micIdentityApi"; +import type { PrismRoutingStrategy } from "@q1code/core/config"; +import { + getMicIdentityAccess, + getMicPrismStatus, + getMicPrismRouting, + setMicPrismRouting, +} from "@t3tools/client-runtime/fork"; +import type { EnvironmentId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as SecureStore from "expo-secure-store"; +import { + Fragment, + type ReactNode, + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from "react"; +import { Modal, Pressable, View } from "react-native"; + +import { AppText as Text } from "../../components/AppText"; +import { resolveCloudPublicConfig } from "../../features/cloud/publicConfig"; +import { SettingsSection } from "../../features/settings/components/SettingsSection"; +import { runtime } from "../../lib/runtime"; +import { freshMicMobileToken, resolveMicMobileIdentityMode } from "./micIdentity.logic"; +import { MicPrismTokenContext } from "./micIdentityContext"; +import { describePrismError, PRISM_ROUTING_OPTIONS } from "./prismSettings.logic"; +import { usePrismApi } from "./usePrismApi"; + +const micTokenCache = { + getToken: (key: string) => SecureStore.getItemAsync(`q1code.mic-sc.${key}`), + saveToken: (key: string, value: string) => + SecureStore.setItemAsync(`q1code.mic-sc.${key}`, value), + clearToken: (key: string) => SecureStore.deleteItemAsync(`q1code.mic-sc.${key}`), +}; + +export function PrismIdentityBoundary(props: { + readonly environmentId: EnvironmentId; + readonly enabled: boolean; + readonly allowLocalProvider: boolean; + readonly children: ReactNode; +}) { + return props.enabled ? : props.children; +} + +function ConfiguredBoundary(props: Parameters[0]) { + const api = usePrismApi(props.environmentId); + const [config, setConfig] = useState(null); + const [error, setError] = useState(null); + 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)); + }); + return () => { + cancelled = true; + }; + }, [api]); + + if (config === null) { + return ( + <> + + {props.children} + + ); + } + const cloud = resolveCloudPublicConfig(); + const mode = resolveMicMobileIdentityMode( + config, + cloud.relay.url ? cloud.clerk.publishableKey : null, + props.allowLocalProvider, + ); + if (mode === "off") return props.children; + if (mode === "unconfigured") + return ( + <> + + {props.children} + + ); + if (mode === "incompatible") + return ( + <> + + {props.children} + + ); + const content = ( + + {props.children} + + ); + return mode === "shared" ? ( + content + ) : ( + + {content} + + ); +} + +function SignedIdentity(props: { + readonly config: MicIdentityPublicConfig; + readonly environmentId: EnvironmentId; + readonly children: ReactNode; +}) { + const { isLoaded, isSignedIn, userId, sessionId, getToken, signOut } = useAuth(); + const [authOpen, setAuthOpen] = useState(false); + const [signOutError, setSignOutError] = useState(null); + const identityKey = isSignedIn && userId && sessionId ? `${userId}:${sessionId}` : "signed-out"; + const currentIdentity = useRef(identityKey); + useLayoutEffect(() => { + currentIdentity.current = identityKey; + return () => { + currentIdentity.current = "unmounted"; + }; + }, [identityKey]); + const isCurrent = useCallback( + () => identityKey !== "signed-out" && currentIdentity.current === identityKey, + [identityKey], + ); + const source = useCallback( + () => freshMicMobileToken(getToken, isCurrent)(), + [getToken, isCurrent], + ); + return ( + + + + + mic.sc gives you access to the shared Prism service. Environment connections keep their + own permissions. + + {isSignedIn ? ( + { + currentIdentity.current = "signed-out"; + setSignOutError(null); + void signOut().catch(() => + setSignOutError( + "Could not finish signing out. Retry when the account service is available.", + ), + ); + }} + /> + ) : ( + setAuthOpen(true)} + /> + )} + {signOutError ? ( + {signOutError} + ) : null} + + + setAuthOpen(false)} + > + + setAuthOpen(false)} + onHostBack={() => setAuthOpen(false)} + /> + + + {isSignedIn ? ( + + ) : null} + {props.children} + + ); +} + +function MicService(props: { + readonly config: MicIdentityPublicConfig; + readonly source: ReturnType; + readonly isCurrent: () => boolean; +}) { + const [access, setAccess] = useState(null); + const [gateway, setGateway] = useState + > | null>(null); + const [strategy, setStrategy] = useState(null); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + const refreshGeneration = useRef(0); + const mounted = useRef(true); + useEffect(() => { + mounted.current = true; + return () => { + mounted.current = false; + }; + }, []); + const input = useMemo( + () => ({ + baseUrl: props.config.authorityUrl!, + getToken: props.source, + isCurrent: props.isCurrent, + }), + [props.config.authorityUrl, props.source, props.isCurrent], + ); + const refresh = useCallback(async () => { + const generation = ++refreshGeneration.current; + const current = () => + mounted.current && generation === refreshGeneration.current && input.isCurrent(); + const result = await runtime.runPromise(getMicIdentityAccess(input).pipe(Effect.result)); + if (!current()) return; + if (result._tag === "Failure") { + setError(describePrismError(result.failure)); + return; + } + setAccess(result.success); + const status = await runtime.runPromise(getMicPrismStatus(input).pipe(Effect.result)); + if (!current()) return; + if (status._tag === "Failure") { + setError(describePrismError(status.failure)); + return; + } + setGateway(status.success); + setError(null); + if (result.success.session.permissions.includes("prism:routing:read")) { + const routing = await runtime.runPromise(getMicPrismRouting(input).pipe(Effect.result)); + if (!current()) return; + if (routing._tag === "Success") setStrategy(routing.success.strategy); + else setError(describePrismError(routing.failure)); + } else setStrategy(null); + }, [input]); + useEffect(() => { + void refresh(); + }, [refresh]); + const canRoute = + error === null && + gateway !== null && + access !== null && + access.session.permissions.includes("prism:routing:write"); + useEffect(() => { + if (!access) return; + const expires = setTimeout( + () => setError("Prism access needs to be refreshed."), + Math.max(0, access.session.authorizationExpiresAt - Date.now()), + ); + return () => clearTimeout(expires); + }, [access]); + const changeRouting = async (next: PrismRoutingStrategy) => { + if (!canRoute || busy) return; + setBusy(true); + const result = await runtime.runPromise( + setMicPrismRouting({ ...input, strategy: next }).pipe(Effect.result), + ); + if (!mounted.current) return; + setBusy(false); + if (result._tag === "Failure") { + setError(describePrismError(result.failure)); + return; + } + setStrategy(result.success.strategy); + }; + return ( + + + {access ? ( + <> + {access.session.subject} + + {access.discovery.service?.label ?? "No paired service"} + + + {gateway && !error + ? "Access verified. Engine health is not reported by this gateway." + : "Service health has not been verified."} + + + ) : ( + Checking Prism access… + )} + {error ? ( + + {access ? "Last known service. " : ""} + {error} + + ) : null} + void refresh()} /> + {access?.session.permissions.includes("prism:routing:read") ? ( + + {PRISM_ROUTING_OPTIONS.map((option) => ( + void changeRouting(option.value)} + /> + ))} + + ) : null} + + + ); +} + +function IdentityNotice(props: { readonly message: string }) { + return ( + + {props.message} + + ); +} + +function IdentityButton(props: { + readonly label: string; + readonly disabled?: boolean; + readonly onPress: () => void; +}) { + return ( + + + {props.label} + + + ); +} diff --git a/apps/mobile/src/fork/prism/PrismSettingsRow.tsx b/apps/mobile/src/fork/prism/PrismSettingsRow.tsx index 7ba2c3ac5250..5909bbe4e68f 100644 --- a/apps/mobile/src/fork/prism/PrismSettingsRow.tsx +++ b/apps/mobile/src/fork/prism/PrismSettingsRow.tsx @@ -71,7 +71,14 @@ function OverviewLoader(props: { useEffect(() => { if (!api) return; let cancelled = false; - void Promise.all([api.status(), api.listAccounts()]).then(([status, accounts]) => { + void (async () => { + const status = await api.status(); + const accounts = + status._tag === "ok" && + status.value.state === "ready" && + status.value.capabilities?.accountDetails !== false + ? await api.listAccounts() + : null; if (cancelled) return; onOverview( environmentId, @@ -79,11 +86,11 @@ function OverviewLoader(props: { ? { _tag: "loaded", state: status.value.state, - accountCount: accounts._tag === "ok" ? accounts.value.length : null, + accountCount: accounts?._tag === "ok" ? accounts.value.length : null, } : { _tag: "error" }, ); - }); + })(); return () => { cancelled = true; }; diff --git a/apps/mobile/src/fork/prism/PrismSettingsScreen.tsx b/apps/mobile/src/fork/prism/PrismSettingsScreen.tsx index 2c29e427752d..32908459213c 100644 --- a/apps/mobile/src/fork/prism/PrismSettingsScreen.tsx +++ b/apps/mobile/src/fork/prism/PrismSettingsScreen.tsx @@ -6,6 +6,15 @@ * everywhere; it then explains how to turn Prism on. */ import type { PrismAccount, PrismLoginProvider, PrismStatus } from "@q1code/core/prismApi"; +import { useAtomValue } from "@effect/atom-react"; +import { + INITIAL_PRISM_HEALTH, + readForkFlag, + reducePrismHealth, + resolvePrismAccess, +} from "@t3tools/client-runtime/fork"; +import * as Option from "effect/Option"; +import { AsyncResult } from "effect/unstable/reactivity"; import type { PrismRoutingStrategy } from "@q1code/core/config"; import { useIsFocused, useNavigation } from "@react-navigation/native"; import { createNativeStackScreen } from "@react-navigation/native-stack"; @@ -14,6 +23,7 @@ import { type ComponentProps, useCallback, useEffect, + useLayoutEffect, useMemo, useReducer, useRef, @@ -44,6 +54,7 @@ import { relativeTime } from "../../lib/time"; import { NativeStackScreenOptions } from "../../native/StackHeader"; import { useServerConfigs } from "../../state/entities"; import { useEnvironments } from "../../state/environments"; +import { environmentSession } from "../../state/session"; import { PRISM_LOGIN_POLL_MS, PRISM_LOGIN_PROVIDERS, @@ -70,6 +81,7 @@ import { shouldPollPrismStatus, } from "./prismSettings.logic"; import { type PrismApi, usePrismApi } from "./usePrismApi"; +import { PrismIdentityBoundary } from "./PrismIdentitySection"; type Reloader = () => Promise; @@ -139,12 +151,22 @@ export function PrismSettingsScreen() { ) : ( targets.map((target) => ( - 1 ? target.label : null} - registerReloader={registerReloader} - /> + enabled={readForkFlag( + configs.get(target.environmentId)?.environment.capabilities, + "mic-identity", + )} + allowLocalProvider={targets.length === 1} + > + 1 ? target.label : null} + registerReloader={registerReloader} + /> + )) )} @@ -176,6 +198,13 @@ function EnvironmentPanel(props: { }) { const { environmentId, registerReloader } = props; const api = usePrismApi(environmentId); + const { environments } = useEnvironments(); + const connected = + api !== null && + environments.some( + (environment) => + environment.environmentId === environmentId && environment.connection.phase === "connected", + ); const mounted = useRef(true); useEffect( () => () => { @@ -184,8 +213,20 @@ function EnvironmentPanel(props: { [], ); - const [status, setStatus] = useState(null); - const [statusError, setStatusError] = useState(null); + const [health, dispatchHealth] = useReducer(reducePrismHealth, INITIAL_PRISM_HEALTH); + const { status, error: statusError } = health; + const session = useAtomValue(environmentSession.sessionStateAtom(environmentId)); + const sessionData = Option.getOrNull(AsyncResult.value(session)); + const access = resolvePrismAccess({ + health, + connected, + session: sessionData, + sessionError: session._tag === "Failure", + }); + const accessRef = useRef(access); + useLayoutEffect(() => { + accessRef.current = access; + }, [access]); const [restart, setRestart] = useState({ running: false, note: null }); const [accounts, dispatchAccounts] = useReducer(reducePrismAccounts, INITIAL_ACCOUNTS_STATE); const [routing, setRouting] = useState(null); @@ -202,13 +243,14 @@ function EnvironmentPanel(props: { const result = await api.status(); if (!mounted.current) return; if (result._tag === "ok") { - setStatus(result.value); + dispatchHealth({ type: "received", status: result.value, receivedAt: Date.now() }); dispatchUsageSource({ type: "status", status: result.value }); - setStatusError(null); + return result.value; } else { - setStatusError(describePrismError(result.error)); + dispatchHealth({ type: "failed", error: describePrismError(result.error) }); + return null; } - }, [api]); + }, [api, dispatchHealth]); const loadAccounts = useCallback(async () => { if (!api) return; @@ -234,7 +276,10 @@ function EnvironmentPanel(props: { }, [api]); const loadAll = useCallback(async () => { - await Promise.all([loadStatus(), loadAccounts(), loadRouting()]); + const latest = await loadStatus(); + if (latest?.state === "ready" && latest.capabilities?.accountDetails !== false) { + await Promise.all([loadAccounts(), loadRouting()]); + } }, [loadAccounts, loadRouting, loadStatus]); useEffect(() => { @@ -249,6 +294,7 @@ function EnvironmentPanel(props: { useStatusPolling(api !== null && !restart.running, loadStatus); const confirmRestart = () => { + if (!accessRef.current.configure) return; Alert.alert( "Restart the proxy?", "Provider CLIs lose their connection until it is ready again.", @@ -260,7 +306,7 @@ function EnvironmentPanel(props: { }; const runRestart = async () => { - if (!api) return; + if (!api || !accessRef.current.configure) return; setRestart({ running: true, note: null }); const result = await api.restart(); if (!mounted.current) return; @@ -268,7 +314,7 @@ function EnvironmentPanel(props: { setRestart({ running: false, note: describePrismError(result.error) }); return; } - setStatus(result.value); + dispatchHealth({ type: "received", status: result.value, receivedAt: Date.now() }); const startedAt = Date.now(); let step = nextRestartStep({ state: result.value.state, elapsedMs: 0 }); while (step === "poll") { @@ -276,7 +322,13 @@ function EnvironmentPanel(props: { if (!mounted.current) return; const polled = await api.status(); if (!mounted.current) return; - if (polled._tag === "ok") setStatus(polled.value); + if (polled._tag === "ok") { + dispatchHealth({ type: "received", status: polled.value, receivedAt: Date.now() }); + } else { + dispatchHealth({ type: "failed", error: describePrismError(polled.error) }); + setRestart({ running: false, note: describePrismError(polled.error) }); + return; + } step = nextRestartStep({ state: polled._tag === "ok" ? polled.value.state : "starting", elapsedMs: Date.now() - startedAt, @@ -290,12 +342,12 @@ function EnvironmentPanel(props: { }; const toggleUsageSource = (enabled: boolean) => { - if (!api) return; + if (!api || !accessRef.current.configure || usageSource.rollback !== null) return; dispatchUsageSource({ type: "toggle", enabled }); void api.setUsageSource(enabled).then((result) => { if (!mounted.current) return; if (result._tag === "ok") { - setStatus(result.value); + dispatchHealth({ type: "received", status: result.value, receivedAt: Date.now() }); dispatchUsageSource({ type: "saved", status: result.value }); } else { dispatchUsageSource({ type: "saveFailed", error: describePrismError(result.error) }); @@ -304,7 +356,7 @@ function EnvironmentPanel(props: { }; const toggleAccount = (account: PrismAccount, enabled: boolean) => { - if (!api) return; + if (!api || !accessRef.current.accounts || accounts.pending[account.id]) return; dispatchAccounts({ type: "toggle", id: account.id, disabled: !enabled }); void api.patchAccount(account.id, { disabled: !enabled }).then((result) => { if (!mounted.current) return; @@ -317,6 +369,7 @@ function EnvironmentPanel(props: { }; const confirmRemove = (account: PrismAccount) => { + if (!accessRef.current.accounts) return; Alert.alert( `Remove ${account.email ?? account.label}?`, "The auth file is deleted from the proxy and the removal syncs to the other environments.", @@ -326,7 +379,7 @@ function EnvironmentPanel(props: { text: "Remove", style: "destructive", onPress: () => { - if (!api) return; + if (!api || !accessRef.current.accounts) return; dispatchAccounts({ type: "remove", id: account.id }); void api.deleteAccount(account.id).then((result) => { if (!mounted.current) return; @@ -347,7 +400,7 @@ function EnvironmentPanel(props: { }; const selectRouting = (strategy: PrismRoutingStrategy) => { - if (!api || routingBusy || strategy === routing) return; + if (!api || !accessRef.current.routing || routingBusy || strategy === routing) return; const previous = routing; setRouting(strategy); setRoutingError(null); @@ -364,44 +417,50 @@ function EnvironmentPanel(props: { }); }; - if (!api) { - return ( - - - Connecting… - - - ); - } - return ( - void loadAccounts()} - /> - void loadAccounts()} - /> - + {access.accountDetails ? ( + void loadAccounts()} + /> + ) : ( + + + Pooled account details require administrative access. + + + )} + {access.accountDetails && api !== null ? ( + <> + void loadAccounts()} + /> + + + ) : null} ); } @@ -420,7 +479,7 @@ function PanelFrame(props: { readonly label: string | null; readonly children: R } /** Status polls only while this screen is on top and the app is in the foreground. */ -function useStatusPolling(enabled: boolean, poll: () => Promise) { +function useStatusPolling(enabled: boolean, poll: () => Promise) { const focused = useIsFocused(); const [appState, setAppState] = useState(AppState.currentState ?? "active"); useEffect(() => { @@ -439,6 +498,7 @@ function StatusSection(props: { readonly error: string | null; readonly restart: RestartState; readonly usageSource: PrismUsageSourceState; + readonly writable: boolean; readonly onRestart: () => void; readonly onUsageSourceChange: (enabled: boolean) => void; }) { @@ -454,13 +514,19 @@ function StatusSection(props: { ) : ( - + {props.error ? ( + + ) : ( + + )} - + {status.capabilities?.manage !== false ? ( + + ) : null} {describePrismStatus(status, relativeTime).map((line) => ( @@ -470,9 +536,17 @@ function StatusSection(props: { ))} - {props.error ? {props.error} : null} + {props.error ? ( + {`Last known state: ${status.state}. ${props.error}`} + ) : null} {props.restart.note ? {props.restart.note} : null} - + {status.capabilities?.manage !== false ? ( + + ) : null} )} @@ -481,6 +555,7 @@ function StatusSection(props: { /** Whether Prism publishes its pooled accounts to Usage → Limits; flips at once and rolls back on failure. */ function UsageSourceRow(props: { + readonly writable: boolean; readonly state: PrismUsageSourceState; readonly onChange: (enabled: boolean) => void; }) { @@ -492,7 +567,7 @@ function UsageSourceRow(props: { {PRISM_USAGE_SOURCE_LABEL} @@ -503,6 +578,7 @@ function UsageSourceRow(props: { } function AccountsSection(props: { + readonly writable: boolean; readonly state: ReturnType; readonly onToggle: (account: PrismAccount, enabled: boolean) => void; readonly onRemove: (account: PrismAccount) => void; @@ -536,14 +612,14 @@ function AccountsSection(props: { key={account.id} account={account} first={index === 0 && !state.error} - pending={account.id in state.pending} + pending={!props.writable || account.id in state.pending} error={state.rowErrors[account.id] ?? null} onToggle={(enabled) => props.onToggle(account, enabled)} onRemove={() => props.onRemove(account)} /> )) )} - {state.accounts !== null && state.accounts.length > 0 ? ( + {props.writable && state.accounts !== null && state.accounts.length > 0 ? ( Long-press an account to remove it. @@ -592,6 +668,7 @@ function AccountRow(props: { } function AddAccountSection(props: { + readonly writable: boolean; readonly api: PrismApi; readonly login: ReturnType; readonly dispatch: (event: Parameters[1]) => void; @@ -602,7 +679,7 @@ function AddAccountSection(props: { const pendingSession = pendingPrismLoginSession(login); useEffect(() => { - if (!pendingSession) return; + if (!pendingSession || !props.writable) return; let cancelled = false; const tick = async () => { const result = await api.loginStatus(pendingSession); @@ -615,9 +692,10 @@ function AddAccountSection(props: { cancelled = true; clearInterval(interval); }; - }, [api, dispatch, onCompleted, pendingSession]); + }, [api, dispatch, onCompleted, pendingSession, props.writable]); const start = (provider: PrismLoginProvider) => { + if (!props.writable) return; dispatch({ type: "start", provider }); setRedirectDraft(""); void api.startLogin(provider).then((result) => { @@ -631,7 +709,7 @@ function AddAccountSection(props: { }; const submitRedirect = () => { - if (login._tag !== "pending") return; + if (!props.writable || login._tag !== "pending" || login.submittingRedirect) return; const redirectUrl = redirectDraft.trim(); if (redirectUrl.length === 0) return; const { sessionId } = login; @@ -647,6 +725,7 @@ function AddAccountSection(props: { }; const cancel = () => { + if (!props.writable) return; if (login._tag === "pending") void api.cancelLogin(login.sessionId); dispatch({ type: "cancel" }); }; @@ -662,6 +741,7 @@ function AddAccountSection(props: { start(provider.value)} /> ))} @@ -693,7 +773,7 @@ function AddAccountSection(props: { label="Open browser" onPress={() => void Linking.openURL(login.authUrl).catch(() => undefined)} /> - + If the browser cannot reach the server, paste the URL it redirected to: @@ -710,11 +790,13 @@ function AddAccountSection(props: { onChangeText={setRedirectDraft} onSubmitEditing={submitRedirect} returnKeyType="send" - editable={!login.submittingRedirect} + editable={props.writable && !login.submittingRedirect} /> @@ -741,6 +823,7 @@ function AddAccountSection(props: { } function RoutingSection(props: { + readonly writable: boolean; readonly strategy: PrismRoutingStrategy | null; readonly error: string | null; readonly busy: boolean; @@ -755,7 +838,7 @@ function RoutingSection(props: { key={option.value} label={option.label} selected={option.value === props.strategy} - disabled={props.busy || props.strategy === null} + disabled={!props.writable || props.busy || props.strategy === null} onPress={() => props.onSelect(option.value)} /> ))} diff --git a/apps/mobile/src/fork/prism/micIdentity.logic.test.ts b/apps/mobile/src/fork/prism/micIdentity.logic.test.ts new file mode 100644 index 000000000000..99037a775827 --- /dev/null +++ b/apps/mobile/src/fork/prism/micIdentity.logic.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; + +import { freshMicMobileToken, resolveMicMobileIdentityMode } from "./micIdentity.logic"; + +const config = { + enabled: true, + clerkPublishableKey: "mic-key", + authorityUrl: "https://identity.example.test", +}; + +describe("mobile mic.sc identity boundary", () => { + it("leaves an off feature inert, even with a different existing identity", () => { + expect(resolveMicMobileIdentityMode({ enabled: false }, "t3-key", true)).toBe("off"); + }); + + it("reuses only the exact mic.sc account service configured by the authenticated environment", () => { + expect(resolveMicMobileIdentityMode(config, "mic-key", false)).toBe("shared"); + expect(resolveMicMobileIdentityMode(config, "t3-key", true)).toBe("incompatible"); + }); + + it("permits a local provider only when it cannot replace another Clerk singleton", () => { + expect(resolveMicMobileIdentityMode(config, null, true)).toBe("local"); + expect(resolveMicMobileIdentityMode(config, null, false)).toBe("incompatible"); + expect(resolveMicMobileIdentityMode({ enabled: true }, null, true)).toBe("unconfigured"); + }); + + it.effect("mints a fresh Convex JWT for each operation", () => + Effect.gen(function* () { + const options: Array<{ template: string; skipCache: boolean }> = []; + const source = freshMicMobileToken( + async (input) => { + options.push(input); + return `token-${options.length}`; + }, + () => true, + ); + expect(yield* source()).toBe("token-1"); + expect(yield* source()).toBe("token-2"); + expect(options).toEqual([ + { template: "convex", skipCache: true }, + { template: "convex", skipCache: true }, + ]); + }), + ); + + it.effect("does not ask Clerk for a previous account's token after sign-out", () => + Effect.gen(function* () { + let reads = 0; + const source = freshMicMobileToken( + async () => { + reads += 1; + return "token"; + }, + () => false, + ); + expect(yield* source()).toBeNull(); + expect(reads).toBe(0); + }), + ); + + it.effect("discards a token minted while an account switch was in progress", () => + Effect.gen(function* () { + let current = true; + const source = freshMicMobileToken( + async () => { + current = false; + return "previous-account-token"; + }, + () => current, + ); + expect(yield* source()).toBeNull(); + }), + ); + + it.effect("reports sign-in failure without carrying SDK errors or credentials", () => + Effect.gen(function* () { + const source = freshMicMobileToken( + async () => { + throw new Error("private-sdk-detail"); + }, + () => true, + ); + const result = yield* source().pipe(Effect.result); + expect(result._tag).toBe("Failure"); + if (result._tag === "Failure") + expect(result.failure.message).toBe("Sign in to mic.sc to continue."); + }), + ); +}); diff --git a/apps/mobile/src/fork/prism/micIdentity.logic.ts b/apps/mobile/src/fork/prism/micIdentity.logic.ts new file mode 100644 index 000000000000..a45cb84bab8c --- /dev/null +++ b/apps/mobile/src/fork/prism/micIdentity.logic.ts @@ -0,0 +1,33 @@ +import { MicIdentityUnauthorizedError } from "@q1code/core/micIdentity"; +import type { MicIdentityPublicConfig } from "@q1code/core/micIdentityApi"; +import type { MicIdentityTokenSource } from "@t3tools/client-runtime/fork"; +import * as Effect from "effect/Effect"; + +/** Clerk's native singleton cannot serve two issuers at once. */ +export function resolveMicMobileIdentityMode( + config: MicIdentityPublicConfig, + existingProviderKey: string | null, + allowLocalProvider: boolean, +): "off" | "unconfigured" | "shared" | "local" | "incompatible" { + if (!config.enabled) return "off"; + if (!config.clerkPublishableKey || !config.authorityUrl) return "unconfigured"; + if (existingProviderKey === config.clerkPublishableKey) return "shared"; + if (existingProviderKey !== null || !allowLocalProvider) return "incompatible"; + return "local"; +} + +/** Mint on every call, and discard a token if its account changed while minting. */ +export function freshMicMobileToken( + read: (options: { template: string; skipCache: boolean }) => Promise, + isCurrent: () => boolean, +): MicIdentityTokenSource { + return () => + Effect.tryPromise({ + try: async () => { + if (!isCurrent()) return null; + const token = await read({ template: "convex", skipCache: true }); + return isCurrent() ? token : null; + }, + catch: () => new MicIdentityUnauthorizedError({ reason: "sign-in-required" }), + }); +} diff --git a/apps/mobile/src/fork/prism/micIdentityContext.ts b/apps/mobile/src/fork/prism/micIdentityContext.ts new file mode 100644 index 000000000000..a21fdf3c44a6 --- /dev/null +++ b/apps/mobile/src/fork/prism/micIdentityContext.ts @@ -0,0 +1,4 @@ +import type { MicIdentityTokenSource } from "@t3tools/client-runtime/fork"; +import { createContext } from "react"; + +export const MicPrismTokenContext = createContext(undefined); diff --git a/apps/mobile/src/fork/prism/prismSettings.logic.test.ts b/apps/mobile/src/fork/prism/prismSettings.logic.test.ts index 1034b94ff52d..32c6eccb6acd 100644 --- a/apps/mobile/src/fork/prism/prismSettings.logic.test.ts +++ b/apps/mobile/src/fork/prism/prismSettings.logic.test.ts @@ -31,7 +31,7 @@ const environment = (id: string, phase: string) => ({ }); describe("selectPrismEnvironments", () => { - it("keeps only connected environments whose flag is on", () => { + it("keeps known Prism environments visible through reconnection", () => { const flags: Record = { a: true, b: false, c: true, d: true }; const selected = selectPrismEnvironments( [ @@ -43,7 +43,7 @@ describe("selectPrismEnvironments", () => { ], (id) => (flags[id] === undefined ? null : { forkFlags: { prism: flags[id] } }), ); - expect(selected.map((entry) => entry.environmentId)).toEqual(["a", "d"]); + expect(selected.map((entry) => entry.environmentId)).toEqual(["a", "c", "d"]); expect(selected[0]?.label).toBe("env a"); }); diff --git a/apps/mobile/src/fork/prism/prismSettings.logic.ts b/apps/mobile/src/fork/prism/prismSettings.logic.ts index 2ac044d928d4..3a7e9b0d7bdc 100644 --- a/apps/mobile/src/fork/prism/prismSettings.logic.ts +++ b/apps/mobile/src/fork/prism/prismSettings.logic.ts @@ -28,7 +28,7 @@ export interface PrismEnvironmentRef { readonly label: string; } -/** Connected environments whose `prism` flag is on, in catalog order. */ +/** Keep known Prism environments visible when disconnected, so their last state stays inspectable. */ export function selectPrismEnvironments( environments: ReadonlyArray<{ readonly environmentId: EnvironmentId; @@ -40,11 +40,7 @@ export function selectPrismEnvironments( ) => Pick | null | undefined, ): ReadonlyArray { return environments - .filter( - (environment) => - environment.connection.phase === "connected" && - readForkFlag(capabilitiesOf(environment.environmentId), "prism"), - ) + .filter((environment) => readForkFlag(capabilitiesOf(environment.environmentId), "prism")) .map((environment) => ({ environmentId: environment.environmentId, label: environment.label, diff --git a/apps/mobile/src/fork/prism/usePrismApi.ts b/apps/mobile/src/fork/prism/usePrismApi.ts index f1f705a56e27..9ca7681dd21c 100644 --- a/apps/mobile/src/fork/prism/usePrismApi.ts +++ b/apps/mobile/src/fork/prism/usePrismApi.ts @@ -16,6 +16,7 @@ import { getPrismLoginStatus, getPrismRouting, getPrismStatus, + getPrismIdentityConfig, listPrismAccounts, patchPrismAccount, restartPrism, @@ -28,11 +29,12 @@ import type { EnvironmentId } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import type { HttpClient } from "effect/unstable/http"; -import { useMemo } from "react"; +import { useContext, useMemo } from "react"; import { runtime } from "../../lib/runtime"; import { usePreparedConnection } from "../../state/session"; import type { PrismCallError } from "./prismSettings.logic"; +import { MicPrismTokenContext } from "./micIdentityContext"; export type PrismResult = | { readonly _tag: "ok"; readonly value: A } @@ -42,13 +44,16 @@ type Call = ( input: PrismClientInput, ) => Effect.Effect; -export function bindPrismCalls(prepared: PrismClientInput["prepared"]) { +export function bindPrismCalls( + prepared: PrismClientInput["prepared"], + micScToken?: PrismClientInput["micScToken"], +) { const run = (call: Call): Promise> => runtime .runPromise( Effect.gen(function* () { const signer = yield* Effect.serviceOption(ManagedRelay.ManagedRelayDpopSigner); - return yield* call({ prepared, signer }); + return yield* call({ prepared, signer, ...(micScToken ? { micScToken } : {}) }); }).pipe( Effect.match({ onFailure: (error): PrismResult => ({ _tag: "error", error }), @@ -59,6 +64,7 @@ export function bindPrismCalls(prepared: PrismClientInput["prepared"]) { .catch((): PrismResult => ({ _tag: "error", error: { _tag: "UnknownError" } })); return { + identityConfig: () => run(getPrismIdentityConfig), status: () => run(getPrismStatus), restart: () => run(restartPrism), listAccounts: () => run(listPrismAccounts), @@ -85,5 +91,9 @@ export type PrismApi = ReturnType; /** `null` until the environment has a prepared connection. */ export function usePrismApi(environmentId: EnvironmentId): PrismApi | null { const prepared = Option.getOrNull(usePreparedConnection(environmentId)); - return useMemo(() => (prepared ? bindPrismCalls(prepared) : null), [prepared]); + const micScToken = useContext(MicPrismTokenContext); + return useMemo( + () => (prepared ? bindPrismCalls(prepared, micScToken) : null), + [prepared, micScToken], + ); } diff --git a/apps/swift-ios/App/Cloud/T3ConnectCapability.swift b/apps/swift-ios/App/Cloud/T3ConnectCapability.swift index 353df8937720..441e9ef6fa3d 100644 --- a/apps/swift-ios/App/Cloud/T3ConnectCapability.swift +++ b/apps/swift-ios/App/Cloud/T3ConnectCapability.swift @@ -157,6 +157,21 @@ public final class T3ConnectController: T3ConnectDeviceManaging { var clerk: Clerk? { auth?.client } + /// mic.sc credentials never substitute for a relay or environment credential. + func micPrismToken(expectedPublishableKey: String) async throws -> String { + guard resolution.configuration?.clerkPublishableKey == expectedPublishableKey, + !isLocalAuthorizationInvalidated, !isSignOutInProgress, + let clerk, let session = clerk.session else { + throw FeatureCapabilityUnavailable("Sign in with the configured mic.sc account service") + } + let token = try await session.getToken(.init(template: "convex", skipCache: true)) + guard clerk.session?.id == session.id, !isLocalAuthorizationInvalidated, + !isSignOutInProgress, let token, !token.isEmpty else { + throw FeatureCapabilityUnavailable("mic.sc sign-in") + } + return token + } + public func refresh() async { guard let auth, let relay else { return } guard !isLocalAuthorizationInvalidated else { return } diff --git a/apps/swift-ios/App/NativeFeatureClient.swift b/apps/swift-ios/App/NativeFeatureClient.swift index 5ee47a5d1068..761dd2e60371 100644 --- a/apps/swift-ios/App/NativeFeatureClient.swift +++ b/apps/swift-ios/App/NativeFeatureClient.swift @@ -420,7 +420,27 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging, func prism(_ input: PrismRequest, environmentID: String) async throws -> PrismResponse { let client = try await projectCreationClient(environmentID: environmentID) - return try await client.prism(input) + let configuration = try await prismIdentityConfiguration(environmentID: environmentID) + guard configuration.enabled else { return try await client.prism(input) } + guard let key = configuration.clerkPublishableKey, + key == t3ConnectController.resolution.configuration?.clerkPublishableKey else { + throw FeatureCapabilityUnavailable("This build is not configured for this mic.sc sign-in service") + } + let controller = t3ConnectController + return try await client.prism(input, micScToken: { + try await controller.micPrismToken(expectedPublishableKey: key) + }) + } + + func prismIdentityConfiguration(environmentID: String) async throws -> MicPrismIdentityConfiguration { + guard serverConfigsByEnvironmentID[environmentID]?.environment?.capabilities.forkFlags?["mic-identity"] == true else { return .disabled } + let client = try await projectCreationClient(environmentID: environmentID) + return try await client.prismIdentityConfiguration() + } + + func prismSession(environmentID: String) async throws -> AuthSessionState { + let client = try await projectCreationClient(environmentID: environmentID) + return try await client.authSession() } func usageSummaries(_ input: UsageSummaryInput) async throws -> [FeatureEnvironmentUsage] { diff --git a/apps/swift-ios/Core/HTTP.swift b/apps/swift-ios/Core/HTTP.swift index f85ad8de0d6e..b35d6093644c 100644 --- a/apps/swift-ios/Core/HTTP.swift +++ b/apps/swift-ios/Core/HTTP.swift @@ -58,7 +58,10 @@ public struct URLSessionHTTPTransport: HTTPTransport { // URLSession transparently decodes gzip responses before returning // their body. Applying the policy here is a final guard for requests // constructed outside EnvironmentAPI. - let (data, response) = try await session.data(for: HTTPRequestPolicy.prepare(request)) + let (data, response) = try await session.data( + for: HTTPRequestPolicy.prepare(request), + delegate: request.value(forHTTPHeaderField: "x-mic-sc-session") == nil ? nil : PrismRequestRedirectPolicy() + ) guard let httpResponse = response as? HTTPURLResponse else { throw HTTPError.invalidResponse } @@ -79,6 +82,13 @@ public struct URLSessionHTTPTransport: HTTPTransport { } } +/// A mic.sc token is bound to the requested environment and must never follow its redirect. +private final class PrismRequestRedirectPolicy: NSObject, URLSessionTaskDelegate, @unchecked Sendable { + func urlSession(_ session: URLSession, task: URLSessionTask, willPerformHTTPRedirection response: HTTPURLResponse, newRequest request: URLRequest, completionHandler: @escaping @Sendable (URLRequest?) -> Void) { + completionHandler(nil) + } +} + /// Shared wire-level defaults for HTTP requests. /// /// Foundation's URL loading system transparently decompresses gzip response @@ -376,7 +386,11 @@ public actor EnvironmentAPI { ) } - public func prism(_ input: PrismRequest, environment: Environment) async throws -> PrismResponse { + public func prismIdentityConfiguration(environment: Environment) async throws -> MicPrismIdentityConfiguration { + try await authorized(environment: environment, path: "/api/fork/prism/identity/config", method: "GET", as: MicPrismIdentityConfiguration.self) + } + + public func prism(_ input: PrismRequest, environment: Environment, micScToken: MicPrismTokenSource? = nil) async throws -> PrismResponse { guard input.path.hasPrefix("/"), !input.path.contains(".."), !input.path.contains("?"), !input.path.contains("#") else { throw HTTPError.invalidResponse @@ -387,6 +401,7 @@ public actor EnvironmentAPI { path: "/api/fork/prism" + input.path, method: input.method, body: body, + micScToken: micScToken, as: PrismResponse.self ) } @@ -398,6 +413,7 @@ public actor EnvironmentAPI { method: String, body: Data? = nil, timeoutInterval: TimeInterval? = nil, + micScToken: MicPrismTokenSource? = nil, as type: Result.Type ) async throws -> Result { guard let credential = try await credentials.credential(for: environment.id) else { @@ -423,6 +439,9 @@ public actor EnvironmentAPI { "Bearer \(credential.accessToken)", forHTTPHeaderField: "Authorization" ) + if let micScToken { + request.setValue(try await micScToken(), forHTTPHeaderField: "x-mic-sc-session") + } return try await send(request, as: type) case .managedDPoP: @@ -459,6 +478,9 @@ public actor EnvironmentAPI { if let timeoutInterval { request.timeoutInterval = timeoutInterval } + if let micScToken { + request.setValue(try await micScToken(), forHTTPHeaderField: "x-mic-sc-session") + } do { return try await send(request, as: type) } catch let error as HTTPError where error.isRejectedAuthorization { @@ -489,6 +511,9 @@ public actor EnvironmentAPI { if let timeoutInterval { retry.timeoutInterval = timeoutInterval } + if let micScToken { + retry.setValue(try await micScToken(), forHTTPHeaderField: "x-mic-sc-session") + } return try await send(retry, as: type) } } diff --git a/apps/swift-ios/Core/PrismWireModels.swift b/apps/swift-ios/Core/PrismWireModels.swift index bf345c46beb2..0a540c2f17c2 100644 --- a/apps/swift-ios/Core/PrismWireModels.swift +++ b/apps/swift-ios/Core/PrismWireModels.swift @@ -23,6 +23,7 @@ public struct PrismAccountLifecycle: Decodable, Sendable { /// Additive responses keep carried clients compatible with older gateways. public struct PrismResponse: Decodable, Sendable { + public let capabilities: PrismCapabilities? public let state: String? public let role: String? public let version: String? @@ -35,6 +36,61 @@ public struct PrismResponse: Decodable, Sendable { public let userCode: String? public let status: String? public let strategy: String? + public let usageSource: Bool? + public let session: MicPrismIdentitySession? + public let discovery: MicPrismDiscovery? +} + +public struct MicPrismIdentityConfiguration: Decodable, Sendable { + public let enabled: Bool + public let clerkPublishableKey: String? + public let authorityUrl: String? + + public static let disabled = MicPrismIdentityConfiguration(enabled: false, clerkPublishableKey: nil, authorityUrl: nil) +} + +public struct MicPrismIdentitySession: Decodable, Sendable { + public let subject: String + public let permissions: [String] + public let authorizationExpiresAt: Double +} + +public struct MicPrismDiscovery: Decodable, Sendable { + public let service: MicPrismDiscoveredService? +} + +public struct MicPrismDiscoveredService: Decodable, Sendable { + public let id: String + public let label: String + public let apiUrl: String + public let pairingRevision: Int +} + +public typealias MicPrismTokenSource = @Sendable () async throws -> String + +public struct PrismCapabilities: Decodable, Sendable { + public let inference: Bool + public let manage: Bool + public let accountDetails: Bool +} + +/// Shared native decisions for management controls and their action handlers. +public struct PrismAccess: Sendable { + public let accountDetails: Bool + public let accounts: Bool + public let routing: Bool + public let configure: Bool + + public init(status: PrismResponse?, stale: Bool, connected: Bool, session: AuthSessionState?) { + let scopes = session?.authenticated == true ? session?.scopes ?? [] : [] + let write = scopes.contains("access:write") + let manage = status?.capabilities?.manage ?? write + let live = status != nil && !stale && connected + accountDetails = status?.capabilities?.accountDetails ?? scopes.contains("orchestration:read") + accounts = live && status?.state == "ready" && manage && accountDetails && status?.role != "replica" + routing = live && status?.state == "ready" && manage + configure = live && manage && write + } } public struct PrismRequest: Sendable { diff --git a/apps/swift-ios/Core/T3Client.swift b/apps/swift-ios/Core/T3Client.swift index e331b114c3e4..07e9217dacec 100644 --- a/apps/swift-ios/Core/T3Client.swift +++ b/apps/swift-ios/Core/T3Client.swift @@ -180,8 +180,12 @@ public actor T3Client { return config } - public func prism(_ input: PrismRequest) async throws -> PrismResponse { - try await api.prism(input, environment: environment) + public func prismIdentityConfiguration() async throws -> MicPrismIdentityConfiguration { + try await api.prismIdentityConfiguration(environment: environment) + } + + public func prism(_ input: PrismRequest, micScToken: MicPrismTokenSource? = nil) async throws -> PrismResponse { + try await api.prism(input, environment: environment, micScToken: micScToken) } public func usageSummary(_ input: UsageSummaryInput) async throws -> UsageSummary { diff --git a/apps/swift-ios/Features/Prism/PrismView.swift b/apps/swift-ios/Features/Prism/PrismView.swift index 60da6b9aa680..b8238d7b324a 100644 --- a/apps/swift-ios/Features/Prism/PrismView.swift +++ b/apps/swift-ios/Features/Prism/PrismView.swift @@ -1,4 +1,6 @@ import SwiftUI +import ClerkKit +import ClerkKitUI public struct PrismView: View { private let client: any FeatureClient @@ -6,6 +8,10 @@ public struct PrismView: View { @Environment(\.scenePhase) private var scenePhase @State private var environmentID = "" @State private var status: PrismResponse? + @State private var session: AuthSessionState? + @State private var loadedEnvironmentID = "" + @State private var stale = false + @State private var strategy = "" @State private var accounts: [PrismAccount] = [] @State private var errorMessage: String? @State private var pending = false @@ -14,74 +20,149 @@ public struct PrismView: View { @State private var callback = "" @State private var loginCheck = 0 @State private var removing: PrismAccount? + @State private var confirmingRestart = false + @State private var identityConfiguration = MicPrismIdentityConfiguration.disabled + @State private var identity: PrismResponse? + @State private var showingIdentitySignIn = false + @State private var loadGeneration = 0 public init(client: any FeatureClient, environments: [FeatureEnvironment]) { self.client = client self.environments = environments.filter { $0.isEnabled && $0.prismEnabled == true } } - private var writable: Bool { status?.state == "ready" && status?.role != "replica" && !pending } + private var connected: Bool { + guard let environment = environments.first(where: { $0.id == environmentID }) else { return false } + return environment.connectionState == nil || environment.connectionState == .connected + } + + private var currentStatus: PrismResponse? { loadedEnvironmentID == environmentID ? status : nil } + private var access: PrismAccess { PrismAccess(status: currentStatus, stale: stale, connected: connected, session: loadedEnvironmentID == environmentID ? session : nil) } + private var writable: Bool { access.accounts && !pending } + private var identityController: T3ConnectController? { + guard let controller = (client as? any T3ConnectCapable)?.t3ConnectController, + let expectedKey = identityConfiguration.clerkPublishableKey, + controller.resolution.configuration?.clerkPublishableKey == expectedKey else { return nil } + return controller + } public var body: some View { Form { + if identityConfiguration.enabled { + Section("mic.sc account") { + if let controller = identityController, controller.clerk != nil { + if let knownIdentity = identity { + if let subject = knownIdentity.session?.subject { Text(subject) } + if let service = knownIdentity.discovery?.service { LabeledContent("Paired Prism", value: service.label) } + Button("Sign out of mic.sc") { + identity = nil; status = nil; accounts = []; stale = true + Task { await (client as? any T3ConnectCapable)?.signOutT3Connect() } + } + } else { + Button("Sign in with mic.sc") { showingIdentitySignIn = true } + } + Text("Prism access keeps the permissions of your environment connections separate.") + .foregroundStyle(.secondary) + } else { + Text("This build uses another sign-in service. Use a build configured for this mic.sc account service, or open q1code in a browser.") + .foregroundStyle(.secondary) + } + } + } Section { Picker("Environment", selection: $environmentID) { ForEach(environments) { environment in Text(environment.name).tag(environment.id) } } .disabled(login != nil || pending) - LabeledContent("Gateway", value: status?.state ?? "Checking…") - if let role = status?.role { LabeledContent("Pool role", value: role) } - if let version = status?.version { LabeledContent("Engine", value: version) } - if status?.role == "replica" { + LabeledContent("Gateway", value: stale || !connected ? "Offline" : currentStatus?.state ?? "Checking…") + if (stale || !connected), let state = currentStatus?.state { + Text("Last known state: \(state). Management is unavailable until the connection recovers.") + .foregroundStyle(.secondary) + } + if let role = currentStatus?.role { LabeledContent("Pool role", value: role) } + if let version = currentStatus?.version { LabeledContent("Engine", value: version) } + if currentStatus?.role == "replica" { Text("Manage accounts on the primary environment. This gateway receives serving credentials and cannot refresh them.") } if let errorMessage { Text(errorMessage).foregroundStyle(.red) } - if status?.lastSyncError != nil { Text("Account sync needs attention on this environment.").foregroundStyle(.red) } + if currentStatus?.lastSyncError != nil { Text("Account sync needs attention on this environment.").foregroundStyle(.red) } } header: { Text("Prism") } - Section("Accounts") { - if accounts.isEmpty { Text("No accounts available. Sign in on the primary environment to add one.") } - ForEach(accounts) { account in - VStack(alignment: .leading, spacing: 6) { - Text(account.email ?? account.label).font(.headline) - Text(account.provider).font(.caption).foregroundStyle(.secondary) - if account.lifecycle?.requiresLogin == true { - Label("Sign-in required", systemImage: "exclamationmark.circle").foregroundStyle(.red) - } else if account.lifecycle?.unavailable == true { - Text("Unavailable").foregroundStyle(.secondary) + if access.accountDetails { + Section("Gateway settings") { + Toggle("Show pooled accounts on Usage → Limits", isOn: Binding(get: { currentStatus?.usageSource ?? true }, set: { enabled in + Task { await change(PrismRequest("/usage-source", method: "PUT", body: ["enabled": .bool(enabled)])) } + })).disabled(!access.configure || pending) + Button("Restart") { confirmingRestart = true }.disabled(!access.configure || pending) + Picker("Routing strategy", selection: Binding(get: { strategy }, set: { value in + Task { await change(PrismRequest("/routing", method: "PUT", body: ["strategy": .string(value)])) } + })) { + Text("Unknown").tag("") + Text("Round robin").tag("round-robin") + Text("Weighted round robin").tag("weighted-round-robin") + Text("Fill first").tag("fill-first") + }.disabled(!access.routing || pending || strategy.isEmpty) + } + + Section("Accounts") { + if accounts.isEmpty { Text("No accounts available. Sign in on the primary environment to add one.") } + ForEach(accounts) { account in + VStack(alignment: .leading, spacing: 6) { + Text(account.email ?? account.label).font(.headline) + Text(account.provider).font(.caption).foregroundStyle(.secondary) + if account.lifecycle?.requiresLogin == true { + Label("Sign-in required", systemImage: "exclamationmark.circle").foregroundStyle(.red) + } else if account.lifecycle?.unavailable == true { + Text("Unavailable").foregroundStyle(.secondary) + } + Text(account.lifecycle?.expiresAt.map { "Token expiry: \($0)" } ?? "Token expiry unknown") + .font(.caption).foregroundStyle(.secondary) + Toggle("Enabled", isOn: Binding(get: { !account.disabled }, set: { enabled in + Task { await change(PrismRequest("/accounts/" + PrismRequest.component(account.id), method: "PATCH", body: ["disabled": .bool(!enabled)])) } + })).disabled(!writable) + Button("Remove account", role: .destructive) { removing = account }.disabled(!writable) } - Text(account.lifecycle?.expiresAt.map { "Token expiry: \($0)" } ?? "Token expiry unknown") - .font(.caption).foregroundStyle(.secondary) - Toggle("Enabled", isOn: Binding(get: { !account.disabled }, set: { enabled in - Task { await change(PrismRequest("/accounts/" + PrismRequest.component(account.id), method: "PATCH", body: ["disabled": .bool(!enabled)])) } - })).disabled(!writable) - Button("Remove account", role: .destructive) { removing = account }.disabled(!writable) } } - } - Section("Add account") { - if let login { - if let rawURL = login.authUrl, let url = URL(string: rawURL) { - Link("Continue sign-in", destination: url) - } - if let code = login.userCode { Text(code).font(.body.monospaced()).textSelection(.enabled) } - if login.flow != "device" { - TextField("Completed callback URL", text: $callback) - .textInputAutocapitalization(.never).autocorrectionDisabled() - Button("Submit callback") { Task { await submitCallback() } } - .disabled(callback.isEmpty || pending) + Section("Add account") { + if let login { + if let rawURL = login.authUrl, let url = URL(string: rawURL) { + Link("Continue sign-in", destination: url) + } + if let code = login.userCode { Text(code).font(.body.monospaced()).textSelection(.enabled) } + if login.flow != "device" { + TextField("Completed callback URL", text: $callback) + .textInputAutocapitalization(.never).autocorrectionDisabled() + .disabled(!writable) + Button("Submit callback") { Task { await submitCallback() } } + .disabled(callback.isEmpty || !writable) + } + Button("Check sign-in") { loginCheck += 1 }.disabled(!writable) + Button("Cancel sign-in", role: .cancel) { Task { await cancelLogin() } }.disabled(!writable) + } else { + Button("Sign in to Claude") { Task { await beginLogin("anthropic") } }.disabled(!writable) + Button("Sign in to ChatGPT / Codex") { Task { await beginLogin("codex") } }.disabled(!writable) + Button("Sign in to Grok") { Task { await beginLogin("xai") } }.disabled(!writable) } - Button("Check sign-in") { loginCheck += 1 }.disabled(pending) - Button("Cancel sign-in", role: .cancel) { Task { await cancelLogin() } }.disabled(pending) - } else { - Button("Sign in to Claude") { Task { await beginLogin("anthropic") } }.disabled(!writable) - Button("Sign in to ChatGPT / Codex") { Task { await beginLogin("codex") } }.disabled(!writable) - Button("Sign in to Grok") { Task { await beginLogin("xai") } }.disabled(!writable) + } + } else { + Section("Prism access") { + Text("Pooled account details and management require administrative access.") } } } .navigationTitle("Prism") + .onChange(of: environmentID) { _, _ in + loadGeneration += 1 + identityConfiguration = .disabled; identity = nil; session = nil + status = nil; accounts = []; strategy = ""; stale = false + } + .onReceive(NotificationCenter.default.publisher(for: .t3ConnectSessionChanged)) { _ in + loadGeneration += 1 + identity = nil; status = nil; accounts = []; stale = true + Task { await load() } + } .refreshable { await load() } .task { environmentID = environments.first(where: \.isActive)?.id ?? environments.first?.id ?? "" @@ -93,8 +174,8 @@ public struct PrismView: View { do { try await Task.sleep(for: .seconds(10)) } catch { return } } while !Task.isCancelled } - .task(id: (login?.sessionId ?? "") + String(loginCheck)) { - guard let id = login?.sessionId else { return } + .task(id: (login?.sessionId ?? "") + String(loginCheck) + String(access.accounts) + String(describing: scenePhase)) { + guard access.accounts, scenePhase == .active, let id = login?.sessionId else { return } while !Task.isCancelled { do { let result = try await client.prism(PrismRequest("/accounts/login/" + PrismRequest.component(id)), environmentID: loginEnvironmentID) @@ -116,26 +197,59 @@ public struct PrismView: View { Task { await change(PrismRequest("/accounts/" + PrismRequest.component(account.id), method: "DELETE")) } } } + .confirmationDialog("Restart Prism? Requests will fail until it is ready again.", isPresented: $confirmingRestart) { + Button("Restart", role: .destructive) { Task { await change(PrismRequest("/restart", method: "POST")) } } + } + .sheet(isPresented: $showingIdentitySignIn, onDismiss: { Task { await load() } }) { + if let clerk = identityController?.clerk { + AuthView(mode: .signInOrUp).environment(clerk) + } + } } @MainActor private func load() async { + loadGeneration += 1 + let generation = loadGeneration let selected = environmentID guard !selected.isEmpty else { return } do { + let config = try await client.prismIdentityConfiguration(environmentID: selected) + guard selected == environmentID, generation == loadGeneration, !Task.isCancelled else { return } + identityConfiguration = config + if config.enabled { + let nextIdentity = try await client.prism(PrismRequest("/identity/access"), environmentID: selected) + guard selected == environmentID, generation == loadGeneration, !Task.isCancelled else { return } + identity = nextIdentity + } else { identity = nil } let nextStatus = try await client.prism(PrismRequest("/status"), environmentID: selected) - let nextAccounts = nextStatus.state == "ready" - ? try await client.prism(PrismRequest("/accounts"), environmentID: selected).accounts ?? [] : [] - guard selected == environmentID, !Task.isCancelled else { return } - status = nextStatus; accounts = nextAccounts; errorMessage = nil + let nextSession = try? await client.prismSession(environmentID: selected) + guard selected == environmentID, generation == loadGeneration, !Task.isCancelled else { return } + if loadedEnvironmentID != selected { accounts = []; strategy = "" } + loadedEnvironmentID = selected + status = nextStatus; session = nextSession; stale = false; errorMessage = nil + } catch is CancellationError { } + catch { + guard selected == environmentID, generation == loadGeneration else { return } + stale = true; errorMessage = "Prism is unavailable. Check the connection and whether Prism is enabled on this environment." + return + } + guard selected == environmentID, generation == loadGeneration, access.accountDetails, currentStatus?.state == "ready", !Task.isCancelled else { return } + do { + let nextAccounts = try await client.prism(PrismRequest("/accounts"), environmentID: selected).accounts ?? [] + let nextRouting = try await client.prism(PrismRequest("/routing"), environmentID: selected).strategy ?? "" + guard selected == environmentID, generation == loadGeneration, !Task.isCancelled else { return } + accounts = nextAccounts; strategy = nextRouting } catch is CancellationError { } catch { - guard selected == environmentID else { return } - status = nil; accounts = []; errorMessage = "Prism is unavailable. Check the connection and whether Prism is enabled on this environment." + guard selected == environmentID, generation == loadGeneration else { return } + errorMessage = "Could not load Prism management details. Check your account permissions." } } @MainActor private func change(_ request: PrismRequest) async { - guard !pending else { return } + let allowed = request.path == "/restart" || request.path == "/usage-source" + ? access.configure : request.path == "/routing" ? access.routing : access.accounts + guard allowed, !pending else { return } pending = true defer { pending = false } do { _ = try await client.prism(request, environmentID: environmentID); await load() } @@ -152,7 +266,7 @@ public struct PrismView: View { } @MainActor private func submitCallback() async { - guard let id = login?.sessionId else { return } + guard writable, let id = login?.sessionId else { return } pending = true defer { pending = false } do { @@ -162,7 +276,7 @@ public struct PrismView: View { } @MainActor private func cancelLogin() async { - guard let id = login?.sessionId else { return } + guard writable, let id = login?.sessionId else { return } pending = true defer { pending = false } do { diff --git a/apps/swift-ios/Features/Shared/FeatureClient.swift b/apps/swift-ios/Features/Shared/FeatureClient.swift index bfe1a3bf89f1..91c1db878c7b 100644 --- a/apps/swift-ios/Features/Shared/FeatureClient.swift +++ b/apps/swift-ios/Features/Shared/FeatureClient.swift @@ -106,6 +106,8 @@ public protocol FeatureClient: AnyObject { ) async throws -> FeatureAutomaticSettlementSettings func prism(_ input: PrismRequest, environmentID: String) async throws -> PrismResponse + func prismSession(environmentID: String) async throws -> AuthSessionState + func prismIdentityConfiguration(environmentID: String) async throws -> MicPrismIdentityConfiguration func usageSummaries(_ input: UsageSummaryInput) async throws -> [FeatureEnvironmentUsage] func pullRequestLists(_ input: PullRequestListInput) async throws -> [FeaturePullRequestEnvironmentList] @@ -255,6 +257,12 @@ public extension FeatureClient { func prism(_ input: PrismRequest, environmentID: String) async throws -> PrismResponse { throw FeatureCapabilityUnavailable("Prism") } + func prismSession(environmentID: String) async throws -> AuthSessionState { + throw FeatureCapabilityUnavailable("Prism session") + } + func prismIdentityConfiguration(environmentID: String) async throws -> MicPrismIdentityConfiguration { + .disabled + } func usageSummaries(_ input: UsageSummaryInput) async throws -> [FeatureEnvironmentUsage] { [] } diff --git a/apps/swift-ios/Tests/CoreTests/NativeContractExpansionTests.swift b/apps/swift-ios/Tests/CoreTests/NativeContractExpansionTests.swift index d2e347fe8ae2..e33cd3ae6448 100644 --- a/apps/swift-ios/Tests/CoreTests/NativeContractExpansionTests.swift +++ b/apps/swift-ios/Tests/CoreTests/NativeContractExpansionTests.swift @@ -3,6 +3,62 @@ import XCTest @MainActor final class NativeContractExpansionTests: XCTestCase { + func testPrismRenewsMicIdentityWithoutReplacingEnvironmentAuthorization() async throws { + let environment = Environment(id: "prism-env", label: "Prism", httpBaseURL: URL(string: "https://prism.example")!, webSocketBaseURL: URL(string: "wss://prism.example")!) + let credentials = InMemoryCredentialStore(credentials: [environment.id: EnvironmentCredential(accessToken: "environment-credential", scopes: ["orchestration:read"])]) + let transport = AccessHTTPTransport() + let api = EnvironmentAPI(transport: transport, credentials: credentials) + let source = PrismTestTokenSource() + _ = try await api.prism(PrismRequest("/status"), environment: environment, micScToken: { await source.next() }) + _ = try await api.prism(PrismRequest("/status"), environment: environment, micScToken: { await source.next() }) + _ = try await api.prism(PrismRequest("/status"), environment: environment) + let requests = await transport.requests + XCTAssertEqual(requests.map { $0.value(forHTTPHeaderField: "Authorization") }, Array(repeating: "Bearer environment-credential", count: 3)) + XCTAssertEqual(requests.map { $0.value(forHTTPHeaderField: "x-mic-sc-session") }, ["mic-token-1", "mic-token-2", nil]) + } + + func testPrismRejectsBeforeSendingWhenMicIdentityRenewalFails() async throws { + let environment = Environment(id: "prism-env", label: "Prism", httpBaseURL: URL(string: "https://prism.example")!, webSocketBaseURL: URL(string: "wss://prism.example")!) + let credentials = InMemoryCredentialStore(credentials: [environment.id: EnvironmentCredential(accessToken: "environment-credential", scopes: ["orchestration:read"])]) + let transport = AccessHTTPTransport() + let api = EnvironmentAPI(transport: transport, credentials: credentials) + do { + _ = try await api.prism(PrismRequest("/status"), environment: environment, micScToken: { throw HTTPError.missingCredential }) + XCTFail("Expected failed mic.sc renewal to stop the request") + } catch { } + let requests = await transport.requests + XCTAssertTrue(requests.isEmpty) + } + func testPrismScopeAndStaleStatusDecisions() throws { + let status = try JSONDecoder().decode(PrismResponse.self, from: Data(#"{"state":"ready","role":"primary"}"#.utf8)) + let session = try JSONDecoder().decode(AuthSessionState.self, from: Data(#"{"authenticated":true,"scopes":["orchestration:read","access:write"]}"#.utf8)) + let live = PrismAccess(status: status, stale: false, connected: true, session: session) + XCTAssertTrue(live.accounts) + XCTAssertTrue(live.configure) + let offline = PrismAccess(status: status, stale: true, connected: true, session: session) + XCTAssertTrue(offline.accountDetails) + XCTAssertFalse(offline.accounts) + XCTAssertFalse(offline.routing) + XCTAssertFalse(offline.configure) + let unpaired = PrismAccess(status: status, stale: false, connected: true, session: nil) + XCTAssertFalse(unpaired.accounts) + XCTAssertFalse(unpaired.configure) + } + + func testPrismIdentityCapabilitiesDoNotGrantLocalConfigurationAccess() throws { + let status = try JSONDecoder().decode(PrismResponse.self, from: Data(#"{"state":"ready","role":"primary","capabilities":{"inference":true,"manage":true,"accountDetails":true}}"#.utf8)) + let session = try JSONDecoder().decode(AuthSessionState.self, from: Data(#"{"authenticated":true,"scopes":["orchestration:read"]}"#.utf8)) + let access = PrismAccess(status: status, stale: false, connected: true, session: session) + XCTAssertTrue(access.accounts) + XCTAssertTrue(access.routing) + XCTAssertFalse(access.configure) + let revoked = try JSONDecoder().decode(PrismResponse.self, from: Data(#"{"state":"ready","role":"primary","capabilities":{"inference":true,"manage":false,"accountDetails":false}}"#.utf8)) + let restricted = PrismAccess(status: revoked, stale: false, connected: true, session: session) + XCTAssertFalse(restricted.accountDetails) + XCTAssertFalse(restricted.accounts) + XCTAssertFalse(restricted.routing) + } + func testAdministrativeClientSessionContractsAndRequests() async throws { let environment = Environment( id: "environment-1", @@ -448,6 +504,11 @@ final class NativeContractExpansionTests: XCTestCase { } } +private actor PrismTestTokenSource { + private var sequence = 0 + func next() -> String { sequence += 1; return "mic-token-\(sequence)" } +} + private actor AccessHTTPTransport: HTTPTransport { private(set) var requests: [URLRequest] = [] diff --git a/apps/web/src/fork/mic-identity/BrowserMicIdentity.tsx b/apps/web/src/fork/mic-identity/BrowserMicIdentity.tsx new file mode 100644 index 000000000000..684d9de2e7ef --- /dev/null +++ b/apps/web/src/fork/mic-identity/BrowserMicIdentity.tsx @@ -0,0 +1,14 @@ +import { ClerkProvider } from "@clerk/react"; +import { MicIdentitySession } from "./MicIdentitySession"; + +export default function BrowserMicIdentity({ + publishableKey, +}: { + readonly publishableKey: string; +}) { + return ( + + + + ); +} diff --git a/apps/web/src/fork/mic-identity/ElectronMicIdentity.tsx b/apps/web/src/fork/mic-identity/ElectronMicIdentity.tsx new file mode 100644 index 000000000000..0773f1303a69 --- /dev/null +++ b/apps/web/src/fork/mic-identity/ElectronMicIdentity.tsx @@ -0,0 +1,15 @@ +import { ClerkProvider } from "@clerk/electron/react"; +import { passkeys } from "@clerk/electron/passkeys"; +import { MicIdentitySession } from "./MicIdentitySession"; + +export default function ElectronMicIdentity({ + publishableKey, +}: { + readonly publishableKey: string; +}) { + return ( + + + + ); +} diff --git a/apps/web/src/fork/mic-identity/MicIdentityNavigation.tsx b/apps/web/src/fork/mic-identity/MicIdentityNavigation.tsx new file mode 100644 index 000000000000..e6b4b2ab6cad --- /dev/null +++ b/apps/web/src/fork/mic-identity/MicIdentityNavigation.tsx @@ -0,0 +1,45 @@ +import { lazy, Suspense } from "react"; +import { AlertCircleIcon } from "lucide-react"; +import { SidebarMenuButton, SidebarMenuItem } from "~/components/ui/sidebar"; +import { isElectron } from "~/env"; +import { hasCloudPublicConfig } from "~/cloud/publicConfig"; +import { useMicIdentityConfig } from "./useMicIdentityConfig"; + +const Identity = lazy(() => + isElectron ? import("./ElectronMicIdentity") : import("./BrowserMicIdentity"), +); +const SharedIdentity = lazy(() => + import("./MicIdentitySession").then((module) => ({ default: module.MicIdentitySession })), +); + +/** Hosted builds or a paired environment supply the mic.sc issuer/key. */ +export function MicIdentityNavigation() { + const { config, error, retry } = useMicIdentityConfig(); + const key = config?.enabled ? config.clerkPublishableKey : null; + const existingKey = hasCloudPublicConfig() + ? (import.meta.env.VITE_CLERK_PUBLISHABLE_KEY as string | undefined) + : undefined; + const incompatible = existingKey && key && existingKey !== key; + if (error || incompatible) + return ( + + + + mic.sc unavailable + + + ); + return key ? ( + + {existingKey ? : } + + ) : null; +} diff --git a/apps/web/src/fork/mic-identity/MicIdentityPanel.tsx b/apps/web/src/fork/mic-identity/MicIdentityPanel.tsx new file mode 100644 index 000000000000..d4b64912fe9c --- /dev/null +++ b/apps/web/src/fork/mic-identity/MicIdentityPanel.tsx @@ -0,0 +1,210 @@ +import { useEffect, useRef, useState, useSyncExternalStore } from "react"; +import * as Effect from "effect/Effect"; +import { + getMicIdentityAccess, + getMicPrismStatus, + getMicPrismRouting, + setMicPrismRouting, +} from "@t3tools/client-runtime/fork"; +import type { MicIdentityAccess } from "@q1code/core/micIdentityApi"; +import type { PrismRoutingStrategy } from "@q1code/core/config"; +import { Button } from "~/components/ui/button"; +import { + SettingsPageContainer, + SettingsRow, + SettingsSection, +} from "~/components/settings/settingsLayout"; +import { runtime } from "~/lib/runtime"; +import { useMicIdentityConfig } from "./useMicIdentityConfig"; +import { useDocumentVisible } from "../prism/prismUi"; +import { + micIdentityGeneration, + readMicIdentityToken, + subscribeMicIdentity, +} from "./micIdentitySession"; + +type View = { + generation: number; + access: MicIdentityAccess; + routing: PrismRoutingStrategy | null; + receivedAt: number; +}; + +/** Uses the signed-in service API directly; no environment session is issued by mic.sc identity. */ +export function MicIdentityPanel() { + const { config, error: configError, retry, revision } = useMicIdentityConfig(); + const generation = useSyncExternalStore(subscribeMicIdentity, micIdentityGeneration); + const visible = useDocumentVisible(); + const [view, setView] = useState(null); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + const operation = useRef(0); + const mutating = useRef(false); + const current = view?.generation === generation ? view : null; + + useEffect(() => { + const authorityUrl = config?.authorityUrl; + if (!visible || !authorityUrl) return; + let cancelled = false; + let loading = false; + const tick = async () => { + if (loading || mutating.current) return; + loading = true; + const ticket = ++operation.current; + const result = await runtime.runPromise( + Effect.gen(function* () { + const input = { + baseUrl: authorityUrl, + getToken: readMicIdentityToken, + isCurrent: () => micIdentityGeneration() === generation, + }; + const access = yield* getMicIdentityAccess(input); + yield* getMicPrismStatus(input); + const routing = access.session.permissions.includes("prism:routing:read") + ? (yield* getMicPrismRouting(input)).strategy + : null; + return { access, routing }; + }).pipe(Effect.result), + ); + loading = false; + if (cancelled || ticket !== operation.current) return; + if (result._tag === "Failure") { + setError(result.failure.message); + if ( + result.failure._tag === "MicIdentityUnauthorizedError" || + result.failure._tag === "MicIdentityForbiddenError" + ) + setView(null); + } else { + setError(null); + setView({ ...result.success, generation, receivedAt: Date.now() }); + } + }; + void tick(); + const timer = window.setInterval(() => void tick(), 10_000); + return () => { + cancelled = true; + operation.current++; + window.clearInterval(timer); + }; + }, [config, generation, visible, revision]); + + const changeRouting = async (strategy: PrismRoutingStrategy) => { + if ( + !current || + error || + busy || + !config?.authorityUrl || + !current.access.session.permissions.includes("prism:routing:write") + ) + return; + const ticket = ++operation.current; + const initiatingGeneration = generation; + setBusy(true); + mutating.current = true; + const result = await runtime.runPromise( + setMicPrismRouting({ + baseUrl: config.authorityUrl, + getToken: readMicIdentityToken, + isCurrent: () => micIdentityGeneration() === generation, + strategy, + }).pipe(Effect.result), + ); + mutating.current = false; + if (ticket !== operation.current || micIdentityGeneration() !== initiatingGeneration) { + setBusy(false); + return; + } + setBusy(false); + if (result._tag === "Failure") setError(result.failure.message); + else setView({ ...current, routing: result.success.strategy, receivedAt: Date.now() }); + }; + + return ( + + + + + {current ? ( + <> + + + + + + {current.routing !== null ? ( + + + + + + ) : null} + {current.access.session.capabilities.accountDetails ? ( + + + + ) : null} + + ) : null} + {error || configError ? ( + + ) : null} + + ); +} diff --git a/apps/web/src/fork/mic-identity/MicIdentitySession.tsx b/apps/web/src/fork/mic-identity/MicIdentitySession.tsx new file mode 100644 index 000000000000..2e6d62c32fc4 --- /dev/null +++ b/apps/web/src/fork/mic-identity/MicIdentitySession.tsx @@ -0,0 +1,36 @@ +import { useAuth, useClerk } from "@clerk/react"; +import { useEffect } from "react"; +import { LogInIcon, LogOutIcon } from "lucide-react"; +import { SidebarMenuButton, SidebarMenuItem } from "~/components/ui/sidebar"; +import { bindMicIdentitySession } from "./micIdentitySession"; + +export function MicIdentitySession() { + const { getToken, isLoaded, isSignedIn, userId, sessionId } = useAuth(); + const clerk = useClerk(); + useEffect( + () => + bindMicIdentitySession( + isLoaded && isSignedIn && userId && sessionId + ? () => getToken({ template: "convex", skipCache: true }) + : undefined, + ), + [getToken, isLoaded, isSignedIn, userId, sessionId], + ); + return ( + + { + if (isSignedIn) { + bindMicIdentitySession(); + void clerk.signOut(); + } else void clerk.openSignIn(); + }} + tooltip={isSignedIn ? "Sign out of mic.sc" : "Sign in to mic.sc"} + > + {isSignedIn ? : } + {isSignedIn ? "Sign out of mic.sc" : "Sign in to mic.sc"} + + + ); +} diff --git a/apps/web/src/fork/mic-identity/micIdentitySession.test.ts b/apps/web/src/fork/mic-identity/micIdentitySession.test.ts new file mode 100644 index 000000000000..c286928378b9 --- /dev/null +++ b/apps/web/src/fork/mic-identity/micIdentitySession.test.ts @@ -0,0 +1,178 @@ +import { afterEach, describe, expect, it } from "@effect/vitest"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import { getMicIdentityAccess, setMicPrismRouting } from "@t3tools/client-runtime/fork"; +import { remoteHttpClientLayer } from "@t3tools/client-runtime/rpc"; + +import { + bindMicIdentitySession, + micIdentityGeneration, + readMicIdentityToken, +} from "./micIdentitySession"; + +afterEach(() => { + bindMicIdentitySession(); +}); + +const promiseGate = () => { + let resolve!: (value: A) => void; + const promise = new Promise((complete) => { + resolve = complete; + }); + return { promise, resolve }; +}; + +const identity = { + contractVersion: 1, + subject: "user_fixture", + role: "member", + permissions: ["prism:inference", "prism:routing:write"], + authorizationExpiresAt: 4_070_908_800_000, + authorizationRevision: '1000:["member","active",["prism/routing:write"]]', +}; +const discovery = { + contractVersion: 1, + selectionRevision: 1, + service: { + serviceInstanceId: "prism-pc", + displayName: "PC Prism", + apiOrigin: "https://gateway.example.test", + inferenceOrigin: "https://gateway.example.test", + pairingRevision: 1, + protocolVersion: 1, + publicKey: "MCowBQYDK2VwAyEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + status: "paired", + }, +}; + +const currentInput = () => { + const generation = micIdentityGeneration(); + return { + baseUrl: "https://identity.example.test", + getToken: readMicIdentityToken, + isCurrent: () => micIdentityGeneration() === generation, + }; +}; + +describe("mic.sc account changes", () => { + it.effect("discards a token whose account signs out while the SDK is resolving it", () => + Effect.gen(function* () { + const started = yield* Deferred.make(); + const pending = promiseGate(); + bindMicIdentitySession(() => { + Deferred.doneUnsafe(started, Effect.void); + return pending.promise; + }); + const token = yield* readMicIdentityToken().pipe(Effect.forkChild); + yield* Deferred.await(started); + bindMicIdentitySession(); + pending.resolve("fixture-old-account-token"); + expect(yield* Fiber.join(token)).toBeNull(); + }), + ); + + it.effect("old component cleanup cannot sign out a newly bound account", () => + Effect.gen(function* () { + const oldCleanup = bindMicIdentitySession(() => Promise.resolve("fixture-old-account-token")); + bindMicIdentitySession(() => Promise.resolve("fixture-current-account-token")); + oldCleanup(); + expect(yield* readMicIdentityToken()).toBe("fixture-current-account-token"); + }), + ); + + it.effect("resolves the current SDK token for every call without retaining credentials", () => + Effect.gen(function* () { + let reads = 0; + bindMicIdentitySession(() => Promise.resolve(`fixture-token-${++reads}`)); + expect(yield* readMicIdentityToken()).toBe("fixture-token-1"); + expect(yield* readMicIdentityToken()).toBe("fixture-token-2"); + expect(reads).toBe(2); + }), + ); + + it.effect("discards in-flight admin access when another account signs in", () => + Effect.gen(function* () { + const started = yield* Deferred.make(); + const pending = promiseGate(); + const calls: string[] = []; + const fetchFn = ((request) => { + calls.push(String(request)); + Deferred.doneUnsafe(started, Effect.void); + return pending.promise; + }) satisfies typeof fetch; + bindMicIdentitySession(() => Promise.resolve("fixture-admin-token")); + const result = yield* getMicIdentityAccess(currentInput()).pipe( + Effect.provide(remoteHttpClientLayer(fetchFn)), + Effect.flip, + Effect.forkChild, + ); + yield* Deferred.await(started); + bindMicIdentitySession(() => Promise.resolve("fixture-member-token")); + pending.resolve(Response.json(identity)); + expect(yield* Fiber.join(result)).toMatchObject({ + _tag: "MicIdentityUnauthorizedError", + reason: "revoked-session", + }); + expect(calls).toEqual(["https://identity.example.test/v1/identity"]); + }), + ); + + it.effect("does not send an admin mutation after an account switch during discovery", () => + Effect.gen(function* () { + const discovering = yield* Deferred.make(); + const pending = promiseGate(); + const methods: Array = []; + const fetchFn = ((request, init) => { + methods.push(init?.method); + if (String(request).endsWith("/identity")) return Promise.resolve(Response.json(identity)); + Deferred.doneUnsafe(discovering, Effect.void); + return pending.promise; + }) satisfies typeof fetch; + bindMicIdentitySession(() => Promise.resolve("fixture-admin-token")); + const result = yield* setMicPrismRouting({ ...currentInput(), strategy: "fill-first" }).pipe( + Effect.provide(remoteHttpClientLayer(fetchFn)), + Effect.flip, + Effect.forkChild, + ); + yield* Deferred.await(discovering); + bindMicIdentitySession(() => Promise.resolve("fixture-other-admin-token")); + pending.resolve(Response.json(discovery)); + expect(yield* Fiber.join(result)).toMatchObject({ + _tag: "MicIdentityUnauthorizedError", + reason: "revoked-session", + }); + expect(methods).toEqual(["GET", "GET"]); + }), + ); + + it.effect("discards a mutation result after sign-out without replaying the write", () => + Effect.gen(function* () { + const writing = yield* Deferred.make(); + const pending = promiseGate(); + let writes = 0; + const fetchFn = ((request, init) => { + const url = String(request); + if (url.endsWith("/identity")) return Promise.resolve(Response.json(identity)); + if (url.endsWith("/discovery")) return Promise.resolve(Response.json(discovery)); + if (init?.method === "PUT") writes++; + Deferred.doneUnsafe(writing, Effect.void); + return pending.promise; + }) satisfies typeof fetch; + bindMicIdentitySession(() => Promise.resolve("fixture-admin-token")); + const result = yield* setMicPrismRouting({ ...currentInput(), strategy: "fill-first" }).pipe( + Effect.provide(remoteHttpClientLayer(fetchFn)), + Effect.flip, + Effect.forkChild, + ); + yield* Deferred.await(writing); + bindMicIdentitySession(); + pending.resolve(Response.json({ strategy: "fill-first" })); + expect(yield* Fiber.join(result)).toMatchObject({ + _tag: "MicIdentityUnauthorizedError", + reason: "revoked-session", + }); + expect(writes).toBe(1); + }), + ); +}); diff --git a/apps/web/src/fork/mic-identity/micIdentitySession.ts b/apps/web/src/fork/mic-identity/micIdentitySession.ts new file mode 100644 index 000000000000..eeeb930866a8 --- /dev/null +++ b/apps/web/src/fork/mic-identity/micIdentitySession.ts @@ -0,0 +1,37 @@ +import { MicIdentityUnauthorizedError } from "@q1code/core/micIdentity"; +import * as Effect from "effect/Effect"; + +let generation = 0; +let readToken: (() => Promise) | undefined; +const listeners = new Set<() => void>(); + +export const subscribeMicIdentity = (listener: () => void) => { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +}; +export const micIdentityGeneration = () => generation; + +/** Retain the SDK getter, never its token. An account switch invalidates in-flight reads. */ +export function bindMicIdentitySession(getToken?: () => Promise) { + readToken = getToken; + const boundGeneration = ++generation; + for (const listener of listeners) listener(); + return () => { + if (generation !== boundGeneration) return; + readToken = undefined; + generation++; + for (const listener of listeners) listener(); + }; +} + +export const readMicIdentityToken = () => + Effect.tryPromise({ + try: async () => { + const started = generation; + const token = await readToken?.(); + return generation === started ? (token ?? null) : null; + }, + catch: () => new MicIdentityUnauthorizedError({ reason: "sign-in-required" }), + }); diff --git a/apps/web/src/fork/mic-identity/publicConfig.test.ts b/apps/web/src/fork/mic-identity/publicConfig.test.ts new file mode 100644 index 000000000000..596d17c5daa7 --- /dev/null +++ b/apps/web/src/fork/mic-identity/publicConfig.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { resolveMicIdentityBuildConfig } from "./publicConfig"; + +const configured = { + hosted: true, + enabled: "1", + authorityUrl: "https://identity.example.test", + clerkPublishableKey: "pk_test_cHVibGljLWtleQ", +}; + +describe("hosted mic.sc identity bootstrap", () => { + it("stays off by default even when public configuration is present", () => { + expect(resolveMicIdentityBuildConfig({ hosted: true })).toEqual({ _tag: "disabled" }); + expect(resolveMicIdentityBuildConfig({ ...configured, enabled: "" })).toEqual({ + _tag: "disabled", + }); + expect(resolveMicIdentityBuildConfig({ ...configured, enabled: "false" })).toEqual({ + _tag: "disabled", + }); + }); + + it("does not bypass the local app's environment pairing", () => { + expect(resolveMicIdentityBuildConfig({ ...configured, hosted: false })).toEqual({ + _tag: "disabled", + }); + }); + + it("provides a complete hosted bootstrap without an environment or connection credential", () => { + expect(resolveMicIdentityBuildConfig(configured)).toEqual({ + _tag: "configured", + config: { + enabled: true, + authorityUrl: configured.authorityUrl, + clerkPublishableKey: configured.clerkPublishableKey, + }, + }); + }); + + it("normalizes explicit opt-in and surrounding whitespace", () => { + const result = resolveMicIdentityBuildConfig({ + ...configured, + enabled: " TRUE ", + authorityUrl: ` ${configured.authorityUrl}/ `, + clerkPublishableKey: ` ${configured.clerkPublishableKey} `, + }); + expect(result).toEqual(resolveMicIdentityBuildConfig(configured)); + }); + + it.each([ + undefined, + "http://identity.example.test", + "https://user:password@identity.example.test", + "https://identity.example.test?token=value", + "https://identity.example.test/#other", + "/identity", + ])("rejects unsafe or incomplete authority configuration: %s", (authorityUrl) => { + expect( + resolveMicIdentityBuildConfig({ + ...configured, + ...(authorityUrl === undefined ? { authorityUrl: "" } : { authorityUrl }), + })._tag, + ).toBe("invalid"); + }); + + it("allows a loopback authority for isolated hosted acceptance", () => { + expect( + resolveMicIdentityBuildConfig({ ...configured, authorityUrl: "http://127.0.0.1:8318" })._tag, + ).toBe("configured"); + }); + + it.each(["", "sk_test_not-public", "pk_live_