diff --git a/packages/client-runtime/src/managedRelayState.test.ts b/packages/client-runtime/src/managedRelayState.test.ts index ce58241e79..0b12879ec9 100644 --- a/packages/client-runtime/src/managedRelayState.test.ts +++ b/packages/client-runtime/src/managedRelayState.test.ts @@ -132,6 +132,47 @@ describe("createManagedRelayQueryManager", () => { }); }); + it("uses schema encoded status keys for equivalent environment inputs", async () => { + const getEnvironmentStatus = vi.fn(() => + Effect.succeed({ + environmentId: environment.environmentId, + endpoint: environment.endpoint, + status: "online" as const, + checkedAt: "2026-06-01T00:00:00.000Z", + }), + ); + const manager = createManager({ getEnvironmentStatus }); + const reorderedEnvironment = { + linkedAt: environment.linkedAt, + endpoint: { + providerKind: environment.endpoint.providerKind, + wsBaseUrl: environment.endpoint.wsBaseUrl, + httpBaseUrl: environment.endpoint.httpBaseUrl, + }, + label: environment.label, + environmentId: environment.environmentId, + } satisfies RelayClientEnvironmentRecord; + setSession(); + + const atom = manager.environmentStatusAtom({ accountId: "account-1", environment }); + registry.get(atom); + await vi.waitFor(() => expect(getEnvironmentStatus).toHaveBeenCalledTimes(1)); + + registry.get( + manager.environmentStatusAtom({ + accountId: "account-1", + environment: reorderedEnvironment, + }), + ); + expect(getEnvironmentStatus).toHaveBeenCalledTimes(1); + + manager.refreshEnvironmentStatus(registry, { + accountId: "account-1", + environment: reorderedEnvironment, + }); + await vi.waitFor(() => expect(getEnvironmentStatus).toHaveBeenCalledTimes(2)); + }); + it("rejects status responses for a different environment", async () => { const mismatchedStatus = { environmentId: EnvironmentId.make("environment-2"), diff --git a/packages/client-runtime/src/managedRelayState.ts b/packages/client-runtime/src/managedRelayState.ts index 7d50f0fdb7..4559667c45 100644 --- a/packages/client-runtime/src/managedRelayState.ts +++ b/packages/client-runtime/src/managedRelayState.ts @@ -1,15 +1,15 @@ -import type { - RelayClientEnvironmentRecord, - RelayEnvironmentStatusResponse, -} from "@t3tools/contracts/relay"; +import type { RelayEnvironmentStatusResponse } from "@t3tools/contracts/relay"; import { + RelayClientEnvironmentRecord, RelayEnvironmentConnectScope, RelayEnvironmentStatusScope, + type RelayClientEnvironmentRecord as RelayClientEnvironmentRecordType, } from "@t3tools/contracts/relay"; import * as Cause from "effect/Cause"; import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; import { AsyncResult, Atom, type AtomRegistry } from "effect/unstable/reactivity"; import { ManagedRelayClient } from "./managedRelay.ts"; @@ -37,6 +37,16 @@ export class ManagedRelaySnapshotError extends Data.TaggedError("ManagedRelaySna readonly message: string; }> {} +const EnvironmentStatusKey = Schema.Struct({ + accountId: Schema.String, + environment: RelayClientEnvironmentRecord, +}); +type EnvironmentStatusKey = typeof EnvironmentStatusKey.Type; + +const EnvironmentStatusKeyJson = Schema.fromJsonString(EnvironmentStatusKey); +const encodeEnvironmentStatusKey = Schema.encodeSync(EnvironmentStatusKeyJson); +const decodeEnvironmentStatusKey = Schema.decodeSync(EnvironmentStatusKeyJson); + export const managedRelaySessionAtom = Atom.make(null).pipe( Atom.keepAlive, Atom.withLabel("managed-relay:session"), @@ -130,19 +140,13 @@ function requireClerkToken( function statusKey(input: { readonly accountId: string; - readonly environment: RelayClientEnvironmentRecord; + readonly environment: RelayClientEnvironmentRecordType; }): string { - return JSON.stringify(input); + return encodeEnvironmentStatusKey(input); } -function parseStatusKey(key: string): { - readonly accountId: string; - readonly environment: RelayClientEnvironmentRecord; -} { - return JSON.parse(key) as { - readonly accountId: string; - readonly environment: RelayClientEnvironmentRecord; - }; +function parseStatusKey(key: string): EnvironmentStatusKey { + return decodeEnvironmentStatusKey(key); } function endpointMatches( @@ -157,7 +161,7 @@ function endpointMatches( } function validateEnvironmentStatus( - environment: RelayClientEnvironmentRecord, + environment: RelayClientEnvironmentRecordType, status: RelayEnvironmentStatusResponse, ): Effect.Effect { if (status.environmentId !== environment.environmentId) { @@ -268,7 +272,7 @@ export function createManagedRelayQueryManager( devicesAtom, environmentStatusAtom: (input: { readonly accountId: string; - readonly environment: RelayClientEnvironmentRecord; + readonly environment: RelayClientEnvironmentRecordType; }) => environmentStatusAtom(statusKey(input)), refreshEnvironments(registry: AtomRegistry.AtomRegistry, accountId: string): void { registry.refresh(environmentsAtom(accountId)); @@ -280,7 +284,7 @@ export function createManagedRelayQueryManager( registry: AtomRegistry.AtomRegistry, input: { readonly accountId: string; - readonly environment: RelayClientEnvironmentRecord; + readonly environment: RelayClientEnvironmentRecordType; }, ): void { registry.refresh(environmentStatusAtom(statusKey(input))); diff --git a/packages/shared/src/relayJwt.test.ts b/packages/shared/src/relayJwt.test.ts new file mode 100644 index 0000000000..1cc496955f --- /dev/null +++ b/packages/shared/src/relayJwt.test.ts @@ -0,0 +1,101 @@ +import * as NodeCrypto from "node:crypto"; + +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +import { + RelayJwtSignError, + RelayJwtVerifyError, + signRelayJwt, + verifyRelayJwt, +} from "./relayJwt.ts"; + +const keyPair = NodeCrypto.generateKeyPairSync("ed25519", { + privateKeyEncoding: { format: "pem", type: "pkcs8" }, + publicKeyEncoding: { format: "pem", type: "spki" }, +}); + +const isRelayJwtSignError = Schema.is(RelayJwtSignError); +const isRelayJwtVerifyError = Schema.is(RelayJwtVerifyError); + +describe("relayJwt", () => { + it.effect("signs and verifies relay JWTs", () => + Effect.gen(function* () { + const token = yield* signRelayJwt({ + privateKey: keyPair.privateKey, + typ: "test+jwt", + payload: { + iss: "https://relay.example.test", + aud: "https://relay.example.test", + sub: "user_123", + jti: "nonce-1", + iat: 100, + exp: 200, + }, + }); + + const payload = yield* verifyRelayJwt({ + publicKey: keyPair.publicKey, + token, + typ: "test+jwt", + issuer: "https://relay.example.test", + audience: "https://relay.example.test", + nowEpochSeconds: 150, + }); + + assert.equal(payload.sub, "user_123"); + assert.equal(payload.jti, "nonce-1"); + }), + ); + + it.effect("returns a structured sign error for invalid private key material", () => + Effect.gen(function* () { + const error = yield* signRelayJwt({ + privateKey: "not a pem", + typ: "test+jwt", + payload: {}, + }).pipe(Effect.flip); + + assert.equal(error._tag, "RelayJwtSignError"); + assert.equal(error.typ, "test+jwt"); + assert.equal(error.message, "Failed to sign relay JWT (test+jwt)."); + assert.equal(isRelayJwtSignError(error), true); + }), + ); + + it.effect("returns a structured verify error with expected claim context", () => + Effect.gen(function* () { + const token = yield* signRelayJwt({ + privateKey: keyPair.privateKey, + typ: "test+jwt", + payload: { + iss: "https://relay.example.test", + aud: "https://relay.example.test", + sub: "user_123", + iat: 100, + exp: 200, + }, + }); + + const error = yield* verifyRelayJwt({ + publicKey: keyPair.publicKey, + token, + typ: "other+jwt", + issuer: "https://relay.example.test", + audience: "https://relay.example.test", + nowEpochSeconds: 150, + }).pipe(Effect.flip); + + assert.equal(error._tag, "RelayJwtVerifyError"); + if (error._tag !== "RelayJwtVerifyError") { + assert.fail(`Expected RelayJwtVerifyError, got ${error._tag}`); + } + assert.equal(error.typ, "other+jwt"); + assert.equal(error.issuer, "https://relay.example.test"); + assert.equal(error.audience, "https://relay.example.test"); + assert.equal(error.message, "Failed to verify relay JWT (other+jwt)."); + assert.equal(isRelayJwtVerifyError(error), true); + }), + ); +}); diff --git a/packages/shared/src/relayJwt.ts b/packages/shared/src/relayJwt.ts index bd00023e8f..2140a5d8ea 100644 --- a/packages/shared/src/relayJwt.ts +++ b/packages/shared/src/relayJwt.ts @@ -1,7 +1,7 @@ import { decodeJwt, importPKCS8, importSPKI, jwtVerify, SignJWT, type JWTPayload } from "jose"; -import * as Data from "effect/Data"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; export const RELAY_LINK_PROOF_TYP = "t3-env-link+jwt"; export const RELAY_MINT_REQUEST_TYP = "t3-cloud-mint+jwt"; @@ -10,9 +10,34 @@ export const RELAY_MINT_RESPONSE_TYP = "t3-env-mint+jwt"; export const RELAY_HEALTH_RESPONSE_TYP = "t3-env-health+jwt"; export const RELAY_ACTIVITY_PUBLISH_TYP = "t3-env-activity+jwt"; -export class RelayJwtError extends Data.TaggedError("RelayJwtError")<{ - readonly cause: unknown; -}> {} +export class RelayJwtSignError extends Schema.TaggedErrorClass()( + "RelayJwtSignError", + { + typ: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to sign relay JWT (${this.typ}).`; + } +} + +export class RelayJwtVerifyError extends Schema.TaggedErrorClass()( + "RelayJwtVerifyError", + { + typ: Schema.String, + issuer: Schema.String, + audience: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to verify relay JWT (${this.typ}).`; + } +} + +export const RelayJwtError = Schema.Union([RelayJwtSignError, RelayJwtVerifyError]); +export type RelayJwtError = typeof RelayJwtError.Type; export function normalizeRelayIssuer(value: string): string { return value.trim().replace(/\/+$/gu, ""); @@ -38,7 +63,7 @@ export function signRelayJwt(input: { .setProtectedHeader({ alg: "EdDSA", typ: input.typ }) .sign(key); }, - catch: (cause) => new RelayJwtError({ cause }), + catch: (cause) => new RelayJwtSignError({ typ: input.typ, cause }), }); } @@ -64,6 +89,12 @@ export function verifyRelayJwt(input: { }); return verified.payload; }, - catch: (cause) => new RelayJwtError({ cause }), + catch: (cause) => + new RelayJwtVerifyError({ + typ: input.typ, + issuer: input.issuer, + audience: input.audience, + cause, + }), }); }