Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions packages/client-runtime/src/managedRelayState.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
38 changes: 21 additions & 17 deletions packages/client-runtime/src/managedRelayState.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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<ManagedRelaySession | null>(null).pipe(
Atom.keepAlive,
Atom.withLabel("managed-relay:session"),
Expand Down Expand Up @@ -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(
Expand All @@ -157,7 +161,7 @@ function endpointMatches(
}

function validateEnvironmentStatus(
environment: RelayClientEnvironmentRecord,
environment: RelayClientEnvironmentRecordType,
status: RelayEnvironmentStatusResponse,
): Effect.Effect<RelayEnvironmentStatusResponse, ManagedRelaySnapshotError> {
if (status.environmentId !== environment.environmentId) {
Expand Down Expand Up @@ -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));
Expand All @@ -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)));
Expand Down
101 changes: 101 additions & 0 deletions packages/shared/src/relayJwt.test.ts
Original file line number Diff line number Diff line change
@@ -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);
}),
);
});
43 changes: 37 additions & 6 deletions packages/shared/src/relayJwt.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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>()(
"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>()(
"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, "");
Expand All @@ -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 }),
});
}

Expand All @@ -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,
}),
});
}
Loading