History

fix(relay): use stable Clerk JWT audience

Julius Marminge committed 5 months ago

Commit bb99d261 parent 846b3ed34 files · +155 −60Browse files

Unified · Split

34 files
  1. .env.example +2 −1
  2. .github/workflows/deploy-relay.yml +1 −0
  3. .github/workflows/release.yml +8 −0
  4. apps/mobile/README.md +2 −1
  5. apps/mobile/app.config.ts +1 −0
  6. apps/mobile/src/app/settings/environments.tsx +5 −3
  7. apps/mobile/src/app/settings/index.tsx +6 −4
  8. apps/mobile/src/features/cloud/CloudAuthProvider.tsx +2 −3
  9. apps/mobile/src/features/cloud/publicConfig.test.ts +3 −1
  10. apps/mobile/src/features/cloud/publicConfig.ts +15 −2
  11. apps/web/src/cloud/managedAuth.tsx +3 −3
  12. apps/web/src/cloud/publicConfig.test.ts +4 −0
  13. apps/web/src/cloud/publicConfig.ts +13 −1
  14. apps/web/src/components/settings/ConnectionsSettings.tsx +3 −3
  15. apps/web/src/vite-env.d.ts +1 −0
  16. apps/web/vite.config.ts +2 −0
  17. docs/release.md +4 −2
  18. docs/t3-cloud-clerk.md +24 −21
  19. infra/relay/.env.example +3 −1
  20. infra/relay/README.md +2 −0
  21. infra/relay/src/Config.ts +1 −0
  22. infra/relay/src/agentActivity/ApnsDeliveries.test.ts +1 −0
  23. infra/relay/src/agentActivity/MobileRegistrations.test.ts +1 −0
  24. infra/relay/src/auth/RelayTokens.test.ts +1 −0
  25. infra/relay/src/environments/EnvironmentConnector.test.ts +1 −0
  26. infra/relay/src/environments/EnvironmentLinker.test.ts +1 −0
  27. infra/relay/src/environments/EnvironmentPublishSignatures.test.ts +1 −0
  28. infra/relay/src/environments/ManagedEndpointProvider.test.ts +1 −0
  29. infra/relay/src/http/Api.test.ts +6 −1
  30. infra/relay/src/http/Api.ts +4 −5
  31. infra/relay/src/worker.ts +2 −0
  32. packages/shared/src/relayAuth.ts +6 −6
  33. scripts/lib/public-config.test.ts +11 −2
  34. scripts/lib/public-config.ts +14 −0
.env.example+2−1
@@ -3,8 +3,9 @@ · 2 unmodified lines
3# Release builds inject their public values at build time. Do not add server-side3# Release builds inject their public values at build time. Do not add server-side
4# secrets to this file.4# secrets to this file.
5 5
6# Get this from the Clerk Dashboard under API keys.6# Get these from the Clerk Dashboard under API keys and JWT templates.
7# T3CODE_CLERK_PUBLISHABLE_KEY=pk_test_...7# T3CODE_CLERK_PUBLISHABLE_KEY=pk_test_...
8# T3CODE_CLERK_JWT_TEMPLATE=t3-relay
8 9
9# Get this from your relay deployment. `infra/relay` deploys update it automatically.10# Get this from your relay deployment. `infra/relay` deploys update it automatically.
10# T3CODE_RELAY_URL=https://relay.example.com11# T3CODE_RELAY_URL=https://relay.example.com
.github/workflows/deploy-relay.yml+1−0
@@ -32,6 +32,7 @@ jobs: · 31 unmodified lines
32 RELAY_DOMAIN: ${{ vars.RELAY_DOMAIN }}32 RELAY_DOMAIN: ${{ vars.RELAY_DOMAIN }}
33 RELAY_ZONE_NAME: ${{ vars.RELAY_ZONE_NAME }}33 RELAY_ZONE_NAME: ${{ vars.RELAY_ZONE_NAME }}
34 CLERK_PUBLISHABLE_KEY: ${{ vars.CLERK_PUBLISHABLE_KEY }}34 CLERK_PUBLISHABLE_KEY: ${{ vars.CLERK_PUBLISHABLE_KEY }}
35 CLERK_JWT_AUDIENCE: ${{ vars.CLERK_JWT_AUDIENCE }}
35 CLERK_SECRET_KEY: ${{ secrets.CLERK_SECRET_KEY }}36 CLERK_SECRET_KEY: ${{ secrets.CLERK_SECRET_KEY }}
36 APNS_ENVIRONMENT: ${{ vars.APNS_ENVIRONMENT }}37 APNS_ENVIRONMENT: ${{ vars.APNS_ENVIRONMENT }}
37 APNS_TEAM_ID: ${{ vars.APNS_TEAM_ID }}38 APNS_TEAM_ID: ${{ vars.APNS_TEAM_ID }}
.github/workflows/release.yml+8−0
@@ -176,10 +176,12 @@ jobs: · 175 unmodified lines
176 name: production176 name: production
177 outputs:177 outputs:
178 clerk_publishable_key: ${{ steps.public_config.outputs.clerk_publishable_key }}178 clerk_publishable_key: ${{ steps.public_config.outputs.clerk_publishable_key }}
179 clerk_jwt_template: ${{ steps.public_config.outputs.clerk_jwt_template }}
179 relay_url: ${{ steps.public_config.outputs.relay_url }}180 relay_url: ${{ steps.public_config.outputs.relay_url }}
180 env:181 env:
181 RELAY_DOMAIN: ${{ vars.RELAY_DOMAIN }}182 RELAY_DOMAIN: ${{ vars.RELAY_DOMAIN }}
182 CLERK_PUBLISHABLE_KEY: ${{ vars.CLERK_PUBLISHABLE_KEY }}183 CLERK_PUBLISHABLE_KEY: ${{ vars.CLERK_PUBLISHABLE_KEY }}
184 CLERK_JWT_TEMPLATE: ${{ vars.CLERK_JWT_TEMPLATE }}
183 steps:185 steps:
184 - id: public_config186 - id: public_config
185 name: Resolve production relay public config187 name: Resolve production relay public config
@@ -190,6 +192,7 @@ jobs: · 4 unmodified lines
190 required=(192 required=(
191 RELAY_DOMAIN193 RELAY_DOMAIN
192 CLERK_PUBLISHABLE_KEY194 CLERK_PUBLISHABLE_KEY
195 CLERK_JWT_TEMPLATE
193 )196 )
194 missing=()197 missing=()
195 for name in "${required[@]}"; do198 for name in "${required[@]}"; do
@@ -203,6 +206,7 @@ jobs: · 7 unmodified lines
203 fi206 fi
204 207
205 echo "clerk_publishable_key=$CLERK_PUBLISHABLE_KEY" >> "$GITHUB_OUTPUT"208 echo "clerk_publishable_key=$CLERK_PUBLISHABLE_KEY" >> "$GITHUB_OUTPUT"
209 echo "clerk_jwt_template=$CLERK_JWT_TEMPLATE" >> "$GITHUB_OUTPUT"
206 echo "relay_url=https://$RELAY_DOMAIN" >> "$GITHUB_OUTPUT"210 echo "relay_url=https://$RELAY_DOMAIN" >> "$GITHUB_OUTPUT"
207 211
208 build:212 build:
@@ -213,6 +217,7 @@ jobs: · 4 unmodified lines
213 timeout-minutes: 30217 timeout-minutes: 30
214 env:218 env:
215 T3CODE_CLERK_PUBLISHABLE_KEY: ${{ needs.relay_public_config.outputs.clerk_publishable_key }}219 T3CODE_CLERK_PUBLISHABLE_KEY: ${{ needs.relay_public_config.outputs.clerk_publishable_key }}
220 T3CODE_CLERK_JWT_TEMPLATE: ${{ needs.relay_public_config.outputs.clerk_jwt_template }}
216 T3CODE_RELAY_URL: ${{ needs.relay_public_config.outputs.relay_url }}221 T3CODE_RELAY_URL: ${{ needs.relay_public_config.outputs.relay_url }}
217 strategy:222 strategy:
218 fail-fast: false223 fail-fast: false
@@ -473,6 +478,7 @@ jobs: · 254 unmodified lines
473 id-token: write478 id-token: write
474 env:479 env:
475 T3CODE_CLERK_PUBLISHABLE_KEY: ${{ needs.relay_public_config.outputs.clerk_publishable_key }}480 T3CODE_CLERK_PUBLISHABLE_KEY: ${{ needs.relay_public_config.outputs.clerk_publishable_key }}
481 T3CODE_CLERK_JWT_TEMPLATE: ${{ needs.relay_public_config.outputs.clerk_jwt_template }}
476 T3CODE_RELAY_URL: ${{ needs.relay_public_config.outputs.relay_url }}482 T3CODE_RELAY_URL: ${{ needs.relay_public_config.outputs.relay_url }}
477 steps:483 steps:
478 - name: Checkout484 - name: Checkout
@@ -628,6 +634,7 @@ jobs: · 149 unmodified lines
628 timeout-minutes: 10634 timeout-minutes: 10
629 env:635 env:
630 T3CODE_CLERK_PUBLISHABLE_KEY: ${{ needs.relay_public_config.outputs.clerk_publishable_key }}636 T3CODE_CLERK_PUBLISHABLE_KEY: ${{ needs.relay_public_config.outputs.clerk_publishable_key }}
637 T3CODE_CLERK_JWT_TEMPLATE: ${{ needs.relay_public_config.outputs.clerk_jwt_template }}
631 T3CODE_RELAY_URL: ${{ needs.relay_public_config.outputs.relay_url }}638 T3CODE_RELAY_URL: ${{ needs.relay_public_config.outputs.relay_url }}
632 VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}639 VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
633 VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}640 VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
@@ -696,6 +703,7 @@ jobs: · 62 unmodified lines
696 "${vercel_scope_args[@]}" \703 "${vercel_scope_args[@]}" \
697 --build-env "APP_VERSION=${{ needs.preflight.outputs.version }}" \704 --build-env "APP_VERSION=${{ needs.preflight.outputs.version }}" \
698 --build-env "T3CODE_CLERK_PUBLISHABLE_KEY=${T3CODE_CLERK_PUBLISHABLE_KEY:-}" \705 --build-env "T3CODE_CLERK_PUBLISHABLE_KEY=${T3CODE_CLERK_PUBLISHABLE_KEY:-}" \
706 --build-env "T3CODE_CLERK_JWT_TEMPLATE=${T3CODE_CLERK_JWT_TEMPLATE:-}" \
699 --build-env "T3CODE_RELAY_URL=${T3CODE_RELAY_URL:-}" \707 --build-env "T3CODE_RELAY_URL=${T3CODE_RELAY_URL:-}" \
700 --build-env "VITE_HOSTED_APP_URL=$router_url" \708 --build-env "VITE_HOSTED_APP_URL=$router_url" \
701 --build-env "VITE_HOSTED_APP_CHANNEL=$channel_name"709 --build-env "VITE_HOSTED_APP_CHANNEL=$channel_name"
apps/mobile/README.md+2−1
@@ -67,7 +67,8 @@ The native lint task runs SwiftLint for Swift plus ktlint and detekt for Kotlin. · 66 unmodified lines
67 67
68CI uses Expo fingerprinting with the `preview:dev` profile to reuse an existing compatible build when possible, or start a new internal EAS build when native runtime inputs change. Production and default local builds continue to use the `appVersion` runtime policy.68CI uses Expo fingerprinting with the `preview:dev` profile to reuse an existing compatible build when possible, or start a new internal EAS build when native runtime inputs change. Production and default local builds continue to use the `appVersion` runtime policy.
69 69
70For preview or production EAS environments, set `T3CODE_CLERK_PUBLISHABLE_KEY` and `T3CODE_RELAY_URL`70For preview or production EAS environments, set `T3CODE_CLERK_PUBLISHABLE_KEY`,
71`T3CODE_CLERK_JWT_TEMPLATE`, and `T3CODE_RELAY_URL`
71as EAS environment variables. Expo config maps the canonical values into the mobile build.72as EAS environment variables. Expo config maps the canonical values into the mobile build.
72 73
73Create a PR preview dev-client build manually:74Create a PR preview dev-client build manually:
apps/mobile/app.config.ts+1−0
@@ -159,6 +159,7 @@ const config: ExpoConfig = { · 158 unmodified lines
159 },159 },
160 clerk: {160 clerk: {
161 publishableKey: repoEnv.EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY ?? null,161 publishableKey: repoEnv.EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY ?? null,
162 jwtTemplate: repoEnv.EXPO_PUBLIC_CLERK_JWT_TEMPLATE ?? null,
162 },163 },
163 eas: {164 eas: {
164 projectId: "d763fcb8-d37c-41ea-a773-b54a0ab4a454",165 projectId: "d763fcb8-d37c-41ea-a773-b54a0ab4a454",
apps/mobile/src/app/settings/environments.tsx+5−3
@@ -3,7 +3,6 @@ import { Link, Stack } from "expo-router"; · 2 unmodified lines
3import { SymbolView } from "expo-symbols";3import { SymbolView } from "expo-symbols";
4import type { EnvironmentId } from "@t3tools/contracts";4import type { EnvironmentId } from "@t3tools/contracts";
5import type { RelayClientEnvironmentRecord } from "@t3tools/contracts/relay";5import type { RelayClientEnvironmentRecord } from "@t3tools/contracts/relay";
6import { RELAY_CLERK_TOKEN_OPTIONS } from "@t3tools/shared/relayAuth";
7import * as Effect from "effect/Effect";6import * as Effect from "effect/Effect";
8import { useCallback, useMemo, useState } from "react";7import { useCallback, useMemo, useState } from "react";
9import { ActivityIndicator, Alert, Pressable, ScrollView, View } from "react-native";8import { ActivityIndicator, Alert, Pressable, ScrollView, View } from "react-native";
@@ -11,7 +10,10 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; · 1 unmodified line
11 10
12import { AppText as Text } from "../../components/AppText";11import { AppText as Text } from "../../components/AppText";
13import { connectCloudEnvironment } from "../../features/cloud/linkEnvironment";12import { connectCloudEnvironment } from "../../features/cloud/linkEnvironment";
14import { hasCloudPublicConfig } from "../../features/cloud/publicConfig";13import {
14 hasCloudPublicConfig,
15 resolveRelayClerkTokenOptions,
16} from "../../features/cloud/publicConfig";
15import {17import {
16 useManagedRelayEnvironments,18 useManagedRelayEnvironments,
17 useManagedRelayEnvironmentStatus,19 useManagedRelayEnvironmentStatus,
@@ -142,7 +144,7 @@ function ConfiguredCloudEnvironmentRows() { · 124 unmodified lines
142 async (environment: RelayClientEnvironmentRecord) => {144 async (environment: RelayClientEnvironmentRecord) => {
143 setConnectingCloudEnvironmentId(environment.environmentId);145 setConnectingCloudEnvironmentId(environment.environmentId);
144 try {146 try {
145 const token = await getToken(RELAY_CLERK_TOKEN_OPTIONS);147 const token = await getToken(resolveRelayClerkTokenOptions());
146 if (!token) {148 if (!token) {
147 throw new Error("Sign in to T3 Cloud before connecting.");149 throw new Error("Sign in to T3 Cloud before connecting.");
148 }150 }
apps/mobile/src/app/settings/index.tsx+6−4
@@ -1,6 +1,5 @@
1import { useAuth, useUser, useUserProfileModal } from "@clerk/expo";1import { useAuth, useUser, useUserProfileModal } from "@clerk/expo";
2import * as Notifications from "expo-notifications";2import * as Notifications from "expo-notifications";
3import { RELAY_CLERK_TOKEN_OPTIONS } from "@t3tools/shared/relayAuth";
4import { Link, Stack, useRouter } from "expo-router";3import { Link, Stack, useRouter } from "expo-router";
5import { SymbolView } from "expo-symbols";4import { SymbolView } from "expo-symbols";
6import * as Effect from "effect/Effect";5import * as Effect from "effect/Effect";
@@ -14,7 +13,10 @@ import { setLiveActivityUpdatesEnabled } from "../../features/agent-awareness/li · 7 unmodified lines
14import { requestAgentNotificationPermission } from "../../features/agent-awareness/notificationPermissions";13import { requestAgentNotificationPermission } from "../../features/agent-awareness/notificationPermissions";
15import { refreshAgentAwarenessRegistration } from "../../features/agent-awareness/remoteRegistration";14import { refreshAgentAwarenessRegistration } from "../../features/agent-awareness/remoteRegistration";
16import { refreshManagedRelayEnvironments } from "../../features/cloud/managedRelayState";15import { refreshManagedRelayEnvironments } from "../../features/cloud/managedRelayState";
17import { hasCloudPublicConfig } from "../../features/cloud/publicConfig";16import {
17 hasCloudPublicConfig,
18 resolveRelayClerkTokenOptions,
19} from "../../features/cloud/publicConfig";
18import { mobileRuntime } from "../../lib/runtime";20import { mobileRuntime } from "../../lib/runtime";
19import { loadPreferences } from "../../lib/storage";21import { loadPreferences } from "../../lib/storage";
20import { useThemeColor } from "../../lib/useThemeColor";22import { useThemeColor } from "../../lib/useThemeColor";
@@ -176,7 +178,7 @@ function ConfiguredSettingsRouteScreen() { · 155 unmodified lines
176 178
177 setLiveActivityStatus("linking");179 setLiveActivityStatus("linking");
178 try {180 try {
179 const token = await getToken(RELAY_CLERK_TOKEN_OPTIONS);181 const token = await getToken(resolveRelayClerkTokenOptions());
180 if (!token) {182 if (!token) {
181 promptSignIn();183 promptSignIn();
182 setLiveActivityStatus("signed-out");184 setLiveActivityStatus("signed-out");
@@ -232,7 +234,7 @@ function ConfiguredSettingsRouteScreen() { · 49 unmodified lines
232 setLiveActivityStatus("disabled");234 setLiveActivityStatus("disabled");
233 void (async () => {235 void (async () => {
234 try {236 try {
235 const token = isSignedIn ? await getToken(RELAY_CLERK_TOKEN_OPTIONS) : null;237 const token = isSignedIn ? await getToken(resolveRelayClerkTokenOptions()) : null;
236 await mobileRuntime.runPromise(238 await mobileRuntime.runPromise(
237 setLiveActivityUpdatesEnabled({239 setLiveActivityUpdatesEnabled({
238 enabled: false,240 enabled: false,
apps/mobile/src/features/cloud/CloudAuthProvider.tsx+2−3
@@ -2,7 +2,6 @@ import { ClerkProvider, useAuth } from "@clerk/expo"; · 1 unmodified line
2import { tokenCache } from "@clerk/expo/token-cache";2import { tokenCache } from "@clerk/expo/token-cache";
3import { createManagedRelaySession, setManagedRelaySession } from "@t3tools/client-runtime";3import { createManagedRelaySession, setManagedRelaySession } from "@t3tools/client-runtime";
4import { type ReactNode, useEffect, useRef } from "react";4import { type ReactNode, useEffect, useRef } from "react";
5import { RELAY_CLERK_TOKEN_OPTIONS } from "@t3tools/shared/relayAuth";
6 5
7import { mobileRuntime } from "../../lib/runtime";6import { mobileRuntime } from "../../lib/runtime";
8import { appAtomRegistry } from "../../state/atom-registry";7import { appAtomRegistry } from "../../state/atom-registry";
@@ -11,7 +10,7 @@ import { · 2 unmodified lines
11 unregisterAgentAwarenessDeviceForCurrentUser,10 unregisterAgentAwarenessDeviceForCurrentUser,
12} from "../agent-awareness/remoteRegistration";11} from "../agent-awareness/remoteRegistration";
13import { refreshActiveLiveActivityRemoteRegistration } from "../agent-awareness/liveActivityController";12import { refreshActiveLiveActivityRemoteRegistration } from "../agent-awareness/liveActivityController";
14import { resolveCloudPublicConfig } from "./publicConfig";13import { resolveCloudPublicConfig, resolveRelayClerkTokenOptions } from "./publicConfig";
15 14
16function CloudAuthBridge(props: { readonly children: ReactNode }) {15function CloudAuthBridge(props: { readonly children: ReactNode }) {
17 const { getToken, isLoaded, isSignedIn, userId } = useAuth({ treatPendingAsSignedOut: false });16 const { getToken, isLoaded, isSignedIn, userId } = useAuth({ treatPendingAsSignedOut: false });
@@ -43,7 +42,7 @@ function CloudAuthBridge(props: { readonly children: ReactNode }) { · 25 unmodified lines
43 .runPromise(unregisterAgentAwarenessDeviceForCurrentUser(previous.provider))42 .runPromise(unregisterAgentAwarenessDeviceForCurrentUser(previous.provider))
44 .catch(() => undefined);43 .catch(() => undefined);
45 }44 }
46 const tokenProvider = () => getToken(RELAY_CLERK_TOKEN_OPTIONS);45 const tokenProvider = () => getToken(resolveRelayClerkTokenOptions());
47 previousTokenProviderRef.current = { userId, provider: tokenProvider };46 previousTokenProviderRef.current = { userId, provider: tokenProvider };
48 setAgentAwarenessRelayTokenProvider(tokenProvider, userId);47 setAgentAwarenessRelayTokenProvider(tokenProvider, userId);
49 setManagedRelaySession(48 setManagedRelaySession(
apps/mobile/src/features/cloud/publicConfig.test.ts+3−1
@@ -14,6 +14,7 @@ describe("resolveCloudPublicConfig", () => { · 13 unmodified lines
14 it("returns no cloud configuration for an unconfigured build", () => {14 it("returns no cloud configuration for an unconfigured build", () => {
15 expect(resolveCloudPublicConfig({})).toEqual({15 expect(resolveCloudPublicConfig({})).toEqual({
16 clerkPublishableKey: null,16 clerkPublishableKey: null,
17 clerkJwtTemplate: null,
17 relayUrl: null,18 relayUrl: null,
18 });19 });
19 });20 });
@@ -21,11 +22,12 @@ describe("resolveCloudPublicConfig", () => { · 1 unmodified line
21 it("normalizes statically injected cloud configuration", () => {22 it("normalizes statically injected cloud configuration", () => {
22 expect(23 expect(
23 resolveCloudPublicConfig({24 resolveCloudPublicConfig({
24 clerk: { publishableKey: " pk_test_example " },25 clerk: { publishableKey: " pk_test_example ", jwtTemplate: " t3-relay " },
25 relay: { url: " https://relay.example.test/// " },26 relay: { url: " https://relay.example.test/// " },
26 }),27 }),
27 ).toEqual({28 ).toEqual({
28 clerkPublishableKey: "pk_test_example",29 clerkPublishableKey: "pk_test_example",
30 clerkJwtTemplate: "t3-relay",
29 relayUrl: "https://relay.example.test",31 relayUrl: "https://relay.example.test",
30 });32 });
31 });33 });
apps/mobile/src/features/cloud/publicConfig.ts+15−2
@@ -1,9 +1,11 @@
1import Constants from "expo-constants";1import Constants from "expo-constants";
2import { relayClerkTokenOptions } from "@t3tools/shared/relayAuth";
2 3
3type ExpoExtra = Readonly<Record<string, unknown>> | undefined;4type ExpoExtra = Readonly<Record<string, unknown>> | undefined;
4 5
5export interface CloudPublicConfig {6export interface CloudPublicConfig {
6 readonly clerkPublishableKey: string | null;7 readonly clerkPublishableKey: string | null;
8 readonly clerkJwtTemplate: string | null;
7 readonly relayUrl: string | null;9 readonly relayUrl: string | null;
8}10}
9 11
@@ -12,16 +14,27 @@ function trimNonEmpty(value: unknown): string | null { · 2 unmodified lines
12}14}
13 15
14export function resolveCloudPublicConfig(extra: ExpoExtra = Constants.expoConfig?.extra) {16export function resolveCloudPublicConfig(extra: ExpoExtra = Constants.expoConfig?.extra) {
15 const clerk = extra?.clerk as { readonly publishableKey?: unknown } | undefined;17 const clerk = extra?.clerk as
18 | { readonly publishableKey?: unknown; readonly jwtTemplate?: unknown }
19 | undefined;
16 const relay = extra?.relay as { readonly url?: unknown } | undefined;20 const relay = extra?.relay as { readonly url?: unknown } | undefined;
17 21
18 return {22 return {
19 clerkPublishableKey: trimNonEmpty(clerk?.publishableKey),23 clerkPublishableKey: trimNonEmpty(clerk?.publishableKey),
24 clerkJwtTemplate: trimNonEmpty(clerk?.jwtTemplate),
20 relayUrl: trimNonEmpty(relay?.url)?.replace(/\/+$/u, "") ?? null,25 relayUrl: trimNonEmpty(relay?.url)?.replace(/\/+$/u, "") ?? null,
21 } satisfies CloudPublicConfig;26 } satisfies CloudPublicConfig;
22}27}
23 28
24export function hasCloudPublicConfig(): boolean {29export function hasCloudPublicConfig(): boolean {
25 const config = resolveCloudPublicConfig();30 const config = resolveCloudPublicConfig();
26 return Boolean(config.clerkPublishableKey && config.relayUrl);31 return Boolean(config.clerkPublishableKey && config.clerkJwtTemplate && config.relayUrl);
32}
33
34export function resolveRelayClerkTokenOptions() {
35 const { clerkJwtTemplate } = resolveCloudPublicConfig();
36 if (!clerkJwtTemplate) {
37 throw new Error("T3CODE_CLERK_JWT_TEMPLATE is not configured.");
38 }
39 return relayClerkTokenOptions(clerkJwtTemplate);
27}40}
apps/web/src/cloud/managedAuth.tsx+3−3
@@ -1,9 +1,9 @@
1import { useAuth } from "@clerk/react";1import { useAuth } from "@clerk/react";
2import { createManagedRelaySession, setManagedRelaySession } from "@t3tools/client-runtime";2import { createManagedRelaySession, setManagedRelaySession } from "@t3tools/client-runtime";
3import { RELAY_CLERK_TOKEN_OPTIONS } from "@t3tools/shared/relayAuth";
4import { useEffect, type ReactNode } from "react";3import { useEffect, type ReactNode } from "react";
5 4
6import { appAtomRegistry } from "../rpc/atomRegistry";5import { appAtomRegistry } from "../rpc/atomRegistry";
6import { resolveRelayClerkTokenOptions } from "./publicConfig";
7 7
8let relayTokenProvider: (() => Promise<string | null>) | null = null;8let relayTokenProvider: (() => Promise<string | null>) | null = null;
9 9
@@ -15,13 +15,13 @@ export function ManagedRelayAuthProvider({ children }: { readonly children: Reac · 5 unmodified lines
15 const { getToken, isSignedIn, userId } = useAuth();15 const { getToken, isSignedIn, userId } = useAuth();
16 16
17 useEffect(() => {17 useEffect(() => {
18 relayTokenProvider = isSignedIn ? () => getToken(RELAY_CLERK_TOKEN_OPTIONS) : null;18 relayTokenProvider = isSignedIn ? () => getToken(resolveRelayClerkTokenOptions()) : null;
19 setManagedRelaySession(19 setManagedRelaySession(
20 appAtomRegistry,20 appAtomRegistry,
21 isSignedIn && userId21 isSignedIn && userId
22 ? createManagedRelaySession({22 ? createManagedRelaySession({
23 accountId: userId,23 accountId: userId,
24 readClerkToken: () => getToken(RELAY_CLERK_TOKEN_OPTIONS),24 readClerkToken: () => getToken(resolveRelayClerkTokenOptions()),
25 })25 })
26 : null,26 : null,
27 );27 );
apps/web/src/cloud/publicConfig.test.ts+4−0
@@ -9,12 +9,16 @@ afterEach(() => { · 8 unmodified lines
9describe("hasCloudPublicConfig", () => {9describe("hasCloudPublicConfig", () => {
10 it("requires both public cloud values", () => {10 it("requires both public cloud values", () => {
11 vi.stubEnv("VITE_CLERK_PUBLISHABLE_KEY", "");11 vi.stubEnv("VITE_CLERK_PUBLISHABLE_KEY", "");
12 vi.stubEnv("VITE_CLERK_JWT_TEMPLATE", "");
12 vi.stubEnv("VITE_T3CODE_RELAY_URL", "");13 vi.stubEnv("VITE_T3CODE_RELAY_URL", "");
13 expect(hasCloudPublicConfig()).toBe(false);14 expect(hasCloudPublicConfig()).toBe(false);
14 15
15 vi.stubEnv("VITE_CLERK_PUBLISHABLE_KEY", "pk_test_example");16 vi.stubEnv("VITE_CLERK_PUBLISHABLE_KEY", "pk_test_example");
16 expect(hasCloudPublicConfig()).toBe(false);17 expect(hasCloudPublicConfig()).toBe(false);
17 18
19 vi.stubEnv("VITE_CLERK_JWT_TEMPLATE", "t3-relay");
20 expect(hasCloudPublicConfig()).toBe(false);
21
18 vi.stubEnv("VITE_T3CODE_RELAY_URL", "https://relay.example.test");22 vi.stubEnv("VITE_T3CODE_RELAY_URL", "https://relay.example.test");
19 expect(hasCloudPublicConfig()).toBe(true);23 expect(hasCloudPublicConfig()).toBe(true);
20 });24 });
apps/web/src/cloud/publicConfig.ts+13−1
@@ -1,5 +1,8 @@
1import { relayClerkTokenOptions } from "@t3tools/shared/relayAuth";
2
1export interface CloudPublicConfig {3export interface CloudPublicConfig {
2 readonly clerkPublishableKey: string | null;4 readonly clerkPublishableKey: string | null;
5 readonly clerkJwtTemplate: string | null;
3 readonly relayUrl: string | null;6 readonly relayUrl: string | null;
4}7}
5 8
@@ -12,6 +15,7 @@ export function resolveCloudPublicConfig(): CloudPublicConfig { · 6 unmodified lines
12 clerkPublishableKey: trimNonEmpty(15 clerkPublishableKey: trimNonEmpty(
13 import.meta.env.VITE_CLERK_PUBLISHABLE_KEY as string | undefined,16 import.meta.env.VITE_CLERK_PUBLISHABLE_KEY as string | undefined,
14 ),17 ),
18 clerkJwtTemplate: trimNonEmpty(import.meta.env.VITE_CLERK_JWT_TEMPLATE as string | undefined),
15 relayUrl:19 relayUrl:
16 trimNonEmpty(import.meta.env.VITE_T3CODE_RELAY_URL as string | undefined)?.replace(20 trimNonEmpty(import.meta.env.VITE_T3CODE_RELAY_URL as string | undefined)?.replace(
17 /\/+$/u,21 /\/+$/u,
@@ -22,5 +26,13 @@ export function resolveCloudPublicConfig(): CloudPublicConfig { · 4 unmodified lines
22 26
23export function hasCloudPublicConfig(): boolean {27export function hasCloudPublicConfig(): boolean {
24 const config = resolveCloudPublicConfig();28 const config = resolveCloudPublicConfig();
25 return Boolean(config.clerkPublishableKey && config.relayUrl);29 return Boolean(config.clerkPublishableKey && config.clerkJwtTemplate && config.relayUrl);
30}
31
32export function resolveRelayClerkTokenOptions() {
33 const { clerkJwtTemplate } = resolveCloudPublicConfig();
34 if (!clerkJwtTemplate) {
35 throw new Error("T3CODE_CLERK_JWT_TEMPLATE is not configured.");
36 }
37 return relayClerkTokenOptions(clerkJwtTemplate);
26}38}
apps/web/src/components/settings/ConnectionsSettings.tsx+3−3
@@ -30,7 +30,6 @@ import { · 29 unmodified lines
30 type EnvironmentId,30 type EnvironmentId,
31} from "@t3tools/contracts";31} from "@t3tools/contracts";
32import { WsRpcClient } from "@t3tools/client-runtime";32import { WsRpcClient } from "@t3tools/client-runtime";
33import { RELAY_CLERK_TOKEN_OPTIONS } from "@t3tools/shared/relayAuth";
34import type { RelayClientEnvironmentRecord } from "@t3tools/contracts/relay";33import type { RelayClientEnvironmentRecord } from "@t3tools/contracts/relay";
35import * as DateTime from "effect/DateTime";34import * as DateTime from "effect/DateTime";
36 35
@@ -38,6 +37,7 @@ import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; · 1 unmodified line
38import { cn } from "../../lib/utils";37import { cn } from "../../lib/utils";
39import { formatElapsedDurationLabel, formatExpiresInLabel } from "../../timestampFormat";38import { formatElapsedDurationLabel, formatExpiresInLabel } from "../../timestampFormat";
40import { resolveDesktopPairingUrl, resolveHostedPairingUrl } from "./pairingUrls";39import { resolveDesktopPairingUrl, resolveHostedPairingUrl } from "./pairingUrls";
40import { resolveRelayClerkTokenOptions } from "../../cloud/publicConfig";
41import {41import {
42 SettingsPageContainer,42 SettingsPageContainer,
43 SettingsRow,43 SettingsRow,
@@ -1640,7 +1640,7 @@ function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: b · 1596 unmodified lines
1640 setIsUpdating(true);1640 setIsUpdating(true);
1641 setOperationError(null);1641 setOperationError(null);
1642 try {1642 try {
1643 const clerkToken = await getToken(RELAY_CLERK_TOKEN_OPTIONS);1643 const clerkToken = await getToken(resolveRelayClerkTokenOptions());
1644 if (enabled) {1644 if (enabled) {
1645 if (!clerkToken) {1645 if (!clerkToken) {
1646 throw new Error("Sign in from T3 Cloud settings before linking this environment.");1646 throw new Error("Sign in from T3 Cloud settings before linking this environment.");
@@ -1753,7 +1753,7 @@ function ConfiguredCloudRemoteEnvironmentRows({ · 106 unmodified lines
1753 const connectEnvironment = async (environment: RelayClientEnvironmentRecord) => {1753 const connectEnvironment = async (environment: RelayClientEnvironmentRecord) => {
1754 setConnectingEnvironmentId(environment.environmentId);1754 setConnectingEnvironmentId(environment.environmentId);
1755 try {1755 try {
1756 const clerkToken = await getToken(RELAY_CLERK_TOKEN_OPTIONS);1756 const clerkToken = await getToken(resolveRelayClerkTokenOptions());
1757 if (!clerkToken) {1757 if (!clerkToken) {
1758 throw new Error("Sign in from T3 Cloud settings before connecting this environment.");1758 throw new Error("Sign in from T3 Cloud settings before connecting this environment.");
1759 }1759 }
apps/web/src/vite-env.d.ts+1−0
@@ -8,6 +8,7 @@ interface ImportMetaEnv { · 7 unmodified lines
8 readonly VITE_HOSTED_APP_URL: string;8 readonly VITE_HOSTED_APP_URL: string;
9 readonly VITE_HOSTED_APP_CHANNEL: string;9 readonly VITE_HOSTED_APP_CHANNEL: string;
10 readonly VITE_CLERK_PUBLISHABLE_KEY: string;10 readonly VITE_CLERK_PUBLISHABLE_KEY: string;
11 readonly VITE_CLERK_JWT_TEMPLATE: string;
11 readonly APP_VERSION: string;12 readonly APP_VERSION: string;
12}13}
13 14
apps/web/vite.config.ts+2−0
@@ -18,6 +18,7 @@ const host = process.env.HOST?.trim() || "localhost"; · 17 unmodified lines
18const configuredWsUrl = process.env.VITE_WS_URL?.trim();18const configuredWsUrl = process.env.VITE_WS_URL?.trim();
19const configuredRelayUrl = repoEnv.VITE_T3CODE_RELAY_URL?.trim() || "";19const configuredRelayUrl = repoEnv.VITE_T3CODE_RELAY_URL?.trim() || "";
20const configuredClerkPublishableKey = repoEnv.VITE_CLERK_PUBLISHABLE_KEY?.trim() || "";20const configuredClerkPublishableKey = repoEnv.VITE_CLERK_PUBLISHABLE_KEY?.trim() || "";
21const configuredClerkJwtTemplate = repoEnv.VITE_CLERK_JWT_TEMPLATE?.trim() || "";
21const configuredHostedAppChannel = process.env.VITE_HOSTED_APP_CHANNEL?.trim() || "";22const configuredHostedAppChannel = process.env.VITE_HOSTED_APP_CHANNEL?.trim() || "";
22const configuredAppVersion = process.env.APP_VERSION?.trim() || pkg.version;23const configuredAppVersion = process.env.APP_VERSION?.trim() || pkg.version;
23const configuredHostedAppUrl = (() => {24const configuredHostedAppUrl = (() => {
@@ -132,6 +133,7 @@ export default defineConfig(() => { · 108 unmodified lines
132 "import.meta.env.VITE_WS_URL": JSON.stringify(configuredWsUrl ?? ""),133 "import.meta.env.VITE_WS_URL": JSON.stringify(configuredWsUrl ?? ""),
133 "import.meta.env.VITE_T3CODE_RELAY_URL": JSON.stringify(configuredRelayUrl),134 "import.meta.env.VITE_T3CODE_RELAY_URL": JSON.stringify(configuredRelayUrl),
134 "import.meta.env.VITE_CLERK_PUBLISHABLE_KEY": JSON.stringify(configuredClerkPublishableKey),135 "import.meta.env.VITE_CLERK_PUBLISHABLE_KEY": JSON.stringify(configuredClerkPublishableKey),
136 "import.meta.env.VITE_CLERK_JWT_TEMPLATE": JSON.stringify(configuredClerkJwtTemplate),
135 "import.meta.env.VITE_HOSTED_APP_URL": JSON.stringify(configuredHostedAppUrl ?? ""),137 "import.meta.env.VITE_HOSTED_APP_URL": JSON.stringify(configuredHostedAppUrl ?? ""),
136 "import.meta.env.VITE_HOSTED_APP_CHANNEL": JSON.stringify(configuredHostedAppChannel),138 "import.meta.env.VITE_HOSTED_APP_CHANNEL": JSON.stringify(configuredHostedAppChannel),
137 "import.meta.env.APP_VERSION": JSON.stringify(configuredAppVersion),139 "import.meta.env.APP_VERSION": JSON.stringify(configuredAppVersion),
docs/release.md+4−2
@@ -10,7 +10,7 @@ This document covers the unified release workflow for stable and nightly desktop · 9 unmodified lines
10 - scheduled nightly check every three hours10 - scheduled nightly check every three hours
11 - manual `workflow_dispatch` for either channel11 - manual `workflow_dispatch` for either channel
12- Runs quality gates first: lint, typecheck, test.12- Runs quality gates first: lint, typecheck, test.
13- Reads the shared production T3 Cloud relay URL and Clerk publishable key before packaging clients.13- Reads the shared production T3 Cloud relay URL and Clerk client configuration before packaging clients.
14- Builds four artifacts in parallel for both channels:14- Builds four artifacts in parallel for both channels:
15 - macOS `arm64` DMG15 - macOS `arm64` DMG
16 - macOS `x64` DMG16 - macOS `x64` DMG
@@ -38,7 +38,7 @@ release channels. · 21 unmodified lines
38 38
39`.github/workflows/deploy-relay.yml` deploys Alchemy stage `prod` on every push to `main`. It also39`.github/workflows/deploy-relay.yml` deploys Alchemy stage `prod` on every push to `main`. It also
40supports manual dispatch for retries. The release workflow reads the relay URL and Clerk40supports manual dispatch for retries. The release workflow reads the relay URL and Clerk
41publishable key from the existing `production` GitHub Actions environment before building41client configuration from the existing `production` GitHub Actions environment before building
42desktop, CLI, or hosted web artifacts.42desktop, CLI, or hosted web artifacts.
43 43
44Required repository variables shared by relay deployments:44Required repository variables shared by relay deployments:
@@ -59,6 +59,8 @@ Required `production` environment variables: · 14 unmodified lines
59- `RELAY_DOMAIN`59- `RELAY_DOMAIN`
60- `RELAY_ZONE_NAME`60- `RELAY_ZONE_NAME`
61- `CLERK_PUBLISHABLE_KEY`61- `CLERK_PUBLISHABLE_KEY`
62- `CLERK_JWT_AUDIENCE`
63- `CLERK_JWT_TEMPLATE`
62- `CLERK_CLI_OAUTH_CLIENT_ID`64- `CLERK_CLI_OAUTH_CLIENT_ID`
63- `APNS_ENVIRONMENT`65- `APNS_ENVIRONMENT`
64- `APNS_TEAM_ID`66- `APNS_TEAM_ID`
docs/t3-cloud-clerk.md+24−21
@@ -1,8 +1,8 @@
1# T3 Cloud Clerk Setup1# T3 Cloud Clerk Setup
2 2
3T3 Cloud uses one Clerk application for web, desktop, and mobile authentication. The relay accepts3T3 Cloud uses one Clerk application for web, desktop, and mobile authentication. The relay accepts
4Clerk JWTs only when they are generated from the `t3-relay` template with the relay URL as the4Clerk JWTs only when they are generated from the `t3-relay` template with the shared
5audience.5`t3-code-relay` audience.
6 6
7## Application Keys7## Application Keys
8 8
@@ -11,13 +11,13 @@ or `.env.local` file: · 2 unmodified lines
11 11
12```dotenv12```dotenv
13T3CODE_CLERK_PUBLISHABLE_KEY=<publishable key>13T3CODE_CLERK_PUBLISHABLE_KEY=<publishable key>
14T3CODE_CLERK_JWT_TEMPLATE=<JWT template name>
14T3CODE_RELAY_URL=https://relay.example.com15T3CODE_RELAY_URL=https://relay.example.com
15```16```
16 17
17The shared client loader projects these canonical values into the framework-specific18The shared client loader projects these canonical values into framework-specific `VITE_*` and
18`VITE_CLERK_PUBLISHABLE_KEY`, `VITE_T3CODE_RELAY_URL`, and19`EXPO_PUBLIC_*` aliases. Existing aliases remain accepted as overrides for compatibility, but new
19`EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY` aliases. Existing aliases remain accepted as overrides for20client configuration should use the canonical names.
20compatibility, but new client configuration should use the canonical names.
21 21
22Configuration precedence is:22Configuration precedence is:
23 23
@@ -25,17 +25,18 @@ Configuration precedence is: · 1 unmodified line
252. Repository-root `.env.local`.252. Repository-root `.env.local`.
263. Repository-root `.env`.263. Repository-root `.env`.
27 27
28The Clerk publishable key and relay URL are public identifiers, not secrets. Web, desktop, and28The Clerk publishable key, JWT template name, and relay URL are public identifiers, not secrets.
29mobile builds statically inject them during their build step. A built artifact does not need an29Web, desktop, and mobile builds statically inject them during their build step. A built artifact
30environment file at runtime. CI release builds should set `T3CODE_CLERK_PUBLISHABLE_KEY` and30does not need an environment file at runtime. CI release builds should set
31`T3CODE_RELAY_URL` before building. EAS preview and production builds should define the same31`T3CODE_CLERK_PUBLISHABLE_KEY`, `T3CODE_CLERK_JWT_TEMPLATE`, and `T3CODE_RELAY_URL` before building.
32client-facing values in their EAS environment.32EAS preview and production builds should define the same client-facing values in their EAS
33environment.
33 34
34When either public value is absent, cloud UI is omitted.35When any client-facing public value is absent, cloud UI is omitted.
35 36
36For a hosted relay deployment, copy `infra/relay/.env.example` to `infra/relay/.env`. The relay37For a hosted relay deployment, copy `infra/relay/.env.example` to `infra/relay/.env`. The relay
37deployment reads `RELAY_DOMAIN`, `RELAY_ZONE_NAME`, and `CLERK_PUBLISHABLE_KEY` through Effect38deployment reads `RELAY_DOMAIN`, `RELAY_ZONE_NAME`, `CLERK_PUBLISHABLE_KEY`, and
38`Config`. There are no checked-in deployment defaults.39`CLERK_JWT_AUDIENCE` through Effect `Config`. There are no checked-in deployment defaults.
39`bun --cwd infra/relay run deploy` invokes Alchemy from the relay directory, so Alchemy loads40`bun --cwd infra/relay run deploy` invokes Alchemy from the relay directory, so Alchemy loads
40`infra/relay/.env`. After a successful deployment, the wrapper updates the repository-root `.env`41`infra/relay/.env`. After a successful deployment, the wrapper updates the repository-root `.env`
41with the HTTPS relay URL derived from `RELAY_DOMAIN`. The relay still requires42with the HTTPS relay URL derived from `RELAY_DOMAIN`. The relay still requires
@@ -50,14 +51,16 @@ preview or developer stage. · 8 unmodified lines
50 51
51In **Clerk Dashboard > JWT templates**, create a template with:52In **Clerk Dashboard > JWT templates**, create a template with:
52 53
53| Setting | Value |54| Setting | Value |
54| ------- | ---------------------------------------- |55| ------- | ---------------------------- |
55| Name | `t3-relay` |56| Name | `t3-relay` |
56| Claims | `{ "aud": "https://relay.example.com" }` |57| Claims | `{ "aud": "t3-code-relay" }` |
57 58
58The `aud` value must be the deployed relay public URL, with no trailing slash. It must match the59Set `T3CODE_CLERK_JWT_TEMPLATE=t3-relay` in the repository-root `.env`, and set
59client-facing `T3CODE_RELAY_URL` and the HTTPS URL derived from the deployment's `RELAY_DOMAIN`. If60`CLERK_JWT_AUDIENCE=t3-code-relay` in `infra/relay/.env`. Define `CLERK_JWT_TEMPLATE` and
60the relay domain changes, update both values and the JWT template.61`CLERK_JWT_AUDIENCE` in the production relay deployment environment as well. The stable `aud` value
62is shared by production and non-production relay stages. The client-facing `T3CODE_RELAY_URL` still
63selects the concrete relay deployment, but changing that URL does not require a JWT template change.
61 64
62## Desktop OAuth Redirect Allowlist65## Desktop OAuth Redirect Allowlist
63 66
infra/relay/.env.example+3−1
@@ -8,9 +8,11 @@ RELAY_ZONE_NAME=example.com · 7 unmodified lines
8# RELAY_DOMAIN=relay.example.com8# RELAY_DOMAIN=relay.example.com
9 9
10# Required: Clerk10# Required: Clerk
11# Get both values from the Clerk Dashboard under API keys.11# Get the keys from the Clerk Dashboard under API keys. Set the JWT audience to
12# the `aud` claim configured on the `t3-relay` JWT template.
12CLERK_PUBLISHABLE_KEY=pk_test_...13CLERK_PUBLISHABLE_KEY=pk_test_...
13CLERK_SECRET_KEY=sk_test_...14CLERK_SECRET_KEY=sk_test_...
15CLERK_JWT_AUDIENCE=t3-code-relay
14 16
15# Required: Apple Push Notification service17# Required: Apple Push Notification service
16# Get these values from your Apple Developer account. Use `sandbox` for18# Get these values from your Apple Developer account. Use `sandbox` for
infra/relay/README.md+2−0
@@ -133,6 +133,8 @@ The `production` GitHub environment must define these Actions variables: · 132 unmodified lines
133- `RELAY_ZONE_NAME`133- `RELAY_ZONE_NAME`
134- `RELAY_DOMAIN` if overriding the derived production relay domain134- `RELAY_DOMAIN` if overriding the derived production relay domain
135- `CLERK_PUBLISHABLE_KEY`135- `CLERK_PUBLISHABLE_KEY`
136- `CLERK_JWT_AUDIENCE`
137- `CLERK_JWT_TEMPLATE`
136- `APNS_ENVIRONMENT`138- `APNS_ENVIRONMENT`
137- `APNS_TEAM_ID`139- `APNS_TEAM_ID`
138- `APNS_KEY_ID`140- `APNS_KEY_ID`
infra/relay/src/Config.ts+1−0
@@ -18,6 +18,7 @@ export interface RelayConfigurationShape { · 17 unmodified lines
18 readonly apns: ApnsCredentials;18 readonly apns: ApnsCredentials;
19 readonly clerkSecretKey: Redacted.Redacted<string>;19 readonly clerkSecretKey: Redacted.Redacted<string>;
20 readonly clerkPublishableKey: string;20 readonly clerkPublishableKey: string;
21 readonly clerkJwtAudience: string;
21 readonly apnsDeliveryJobSigningSecret: Redacted.Redacted<string>;22 readonly apnsDeliveryJobSigningSecret: Redacted.Redacted<string>;
22 readonly cloudMintPrivateKey: Redacted.Redacted<string>;23 readonly cloudMintPrivateKey: Redacted.Redacted<string>;
23 readonly cloudMintPublicKey: string;24 readonly cloudMintPublicKey: string;
infra/relay/src/agentActivity/ApnsDeliveries.test.ts+1−0
@@ -39,6 +39,7 @@ const config = RelayConfiguration.RelayConfiguration.of({ · 38 unmodified lines
39 apnsDeliveryJobSigningSecret: Redacted.make("job-signing-secret"),39 apnsDeliveryJobSigningSecret: Redacted.make("job-signing-secret"),
40 clerkSecretKey: Redacted.make("clerk-secret"),40 clerkSecretKey: Redacted.make("clerk-secret"),
41 clerkPublishableKey: "pk_test_test",41 clerkPublishableKey: "pk_test_test",
42 clerkJwtAudience: "t3-code-relay",
42 cloudMintPrivateKey: Redacted.make("cloud-private-key"),43 cloudMintPrivateKey: Redacted.make("cloud-private-key"),
43 cloudMintPublicKey: "cloud-public-key",44 cloudMintPublicKey: "cloud-public-key",
44 managedEndpointBaseDomain: undefined,45 managedEndpointBaseDomain: undefined,
infra/relay/src/agentActivity/MobileRegistrations.test.ts+1−0
@@ -129,6 +129,7 @@ const config = RelayConfiguration.RelayConfiguration.of({ · 128 unmodified lines
129 },129 },
130 clerkSecretKey: Redacted.make("clerk-secret"),130 clerkSecretKey: Redacted.make("clerk-secret"),
131 clerkPublishableKey: "pk_test_test",131 clerkPublishableKey: "pk_test_test",
132 clerkJwtAudience: "t3-code-relay",
132 apnsDeliveryJobSigningSecret: Redacted.make("apns-job-secret"),133 apnsDeliveryJobSigningSecret: Redacted.make("apns-job-secret"),
133 cloudMintPrivateKey: Redacted.make("cloud-private-key"),134 cloudMintPrivateKey: Redacted.make("cloud-private-key"),
134 cloudMintPublicKey: "cloud-public-key",135 cloudMintPublicKey: "cloud-public-key",
infra/relay/src/auth/RelayTokens.test.ts+1−0
@@ -26,6 +26,7 @@ const config = RelayConfiguration.RelayConfiguration.of({ · 25 unmodified lines
26 apnsDeliveryJobSigningSecret: Redacted.make("job-secret"),26 apnsDeliveryJobSigningSecret: Redacted.make("job-secret"),
27 clerkSecretKey: Redacted.make("clerk-secret"),27 clerkSecretKey: Redacted.make("clerk-secret"),
28 clerkPublishableKey: "pk_test_test",28 clerkPublishableKey: "pk_test_test",
29 clerkJwtAudience: "t3-code-relay",
29 cloudMintPrivateKey: Redacted.make(keyPair.privateKey),30 cloudMintPrivateKey: Redacted.make(keyPair.privateKey),
30 cloudMintPublicKey: keyPair.publicKey,31 cloudMintPublicKey: keyPair.publicKey,
31 managedEndpointBaseDomain: undefined,32 managedEndpointBaseDomain: undefined,
infra/relay/src/environments/EnvironmentConnector.test.ts+1−0
@@ -66,6 +66,7 @@ const settings = RelayConfiguration.RelayConfiguration.of({ · 65 unmodified lines
66 apnsDeliveryJobSigningSecret: Redacted.make("job-secret"),66 apnsDeliveryJobSigningSecret: Redacted.make("job-secret"),
67 clerkSecretKey: Redacted.make("clerk-secret"),67 clerkSecretKey: Redacted.make("clerk-secret"),
68 clerkPublishableKey: "pk_test_test",68 clerkPublishableKey: "pk_test_test",
69 clerkJwtAudience: "t3-code-relay",
69 cloudMintPrivateKey: Redacted.make(cloudKeyPair.privateKey),70 cloudMintPrivateKey: Redacted.make(cloudKeyPair.privateKey),
70 cloudMintPublicKey: cloudKeyPair.publicKey,71 cloudMintPublicKey: cloudKeyPair.publicKey,
71 managedEndpointBaseDomain: undefined,72 managedEndpointBaseDomain: undefined,
infra/relay/src/environments/EnvironmentLinker.test.ts+1−0
@@ -39,6 +39,7 @@ const config = RelayConfiguration.RelayConfiguration.of({ · 38 unmodified lines
39 apnsDeliveryJobSigningSecret: Redacted.make("job-secret"),39 apnsDeliveryJobSigningSecret: Redacted.make("job-secret"),
40 clerkSecretKey: Redacted.make("clerk-secret"),40 clerkSecretKey: Redacted.make("clerk-secret"),
41 clerkPublishableKey: "pk_test_test",41 clerkPublishableKey: "pk_test_test",
42 clerkJwtAudience: "t3-code-relay",
42 cloudMintPrivateKey: Redacted.make(relayKeyPair.privateKey),43 cloudMintPrivateKey: Redacted.make(relayKeyPair.privateKey),
43 cloudMintPublicKey: relayKeyPair.publicKey,44 cloudMintPublicKey: relayKeyPair.publicKey,
44 managedEndpointBaseDomain: undefined,45 managedEndpointBaseDomain: undefined,
infra/relay/src/environments/EnvironmentPublishSignatures.test.ts+1−0
@@ -34,6 +34,7 @@ const config = RelayConfiguration.RelayConfiguration.of({ · 33 unmodified lines
34 apnsDeliveryJobSigningSecret: Redacted.make("job-secret"),34 apnsDeliveryJobSigningSecret: Redacted.make("job-secret"),
35 clerkSecretKey: Redacted.make("clerk-secret"),35 clerkSecretKey: Redacted.make("clerk-secret"),
36 clerkPublishableKey: "pk_test_test",36 clerkPublishableKey: "pk_test_test",
37 clerkJwtAudience: "t3-code-relay",
37 cloudMintPrivateKey: Redacted.make(keyPair.privateKey),38 cloudMintPrivateKey: Redacted.make(keyPair.privateKey),
38 cloudMintPublicKey: keyPair.publicKey,39 cloudMintPublicKey: keyPair.publicKey,
39 managedEndpointBaseDomain: undefined,40 managedEndpointBaseDomain: undefined,
infra/relay/src/environments/ManagedEndpointProvider.test.ts+1−0
@@ -21,6 +21,7 @@ const config = RelayConfiguration.RelayConfiguration.of({ · 20 unmodified lines
21 apnsDeliveryJobSigningSecret: Redacted.make("job-secret"),21 apnsDeliveryJobSigningSecret: Redacted.make("job-secret"),
22 clerkSecretKey: Redacted.make("clerk-secret"),22 clerkSecretKey: Redacted.make("clerk-secret"),
23 clerkPublishableKey: "pk_test_test",23 clerkPublishableKey: "pk_test_test",
24 clerkJwtAudience: "t3-code-relay",
24 cloudMintPrivateKey: Redacted.make("cloud-private-key"),25 cloudMintPrivateKey: Redacted.make("cloud-private-key"),
25 cloudMintPublicKey: "cloud-public-key",26 cloudMintPublicKey: "cloud-public-key",
26 managedEndpointBaseDomain: "t3code.test",27 managedEndpointBaseDomain: "t3code.test",
infra/relay/src/http/Api.test.ts+6−1
@@ -40,6 +40,7 @@ const relaySettings: RelayConfiguration.RelayConfigurationShape = { · 39 unmodified lines
40 },40 },
41 clerkSecretKey: Redacted.make("clerk-secret-key"),41 clerkSecretKey: Redacted.make("clerk-secret-key"),
42 clerkPublishableKey: "pk_test_test",42 clerkPublishableKey: "pk_test_test",
43 clerkJwtAudience: "t3-code-relay",
43 apnsDeliveryJobSigningSecret: Redacted.make("apns-delivery-secret"),44 apnsDeliveryJobSigningSecret: Redacted.make("apns-delivery-secret"),
44 cloudMintPrivateKey: Redacted.make("cloud-mint-private-key"),45 cloudMintPrivateKey: Redacted.make("cloud-mint-private-key"),
45 cloudMintPublicKey: "cloud-mint-public-key",46 cloudMintPublicKey: "cloud-mint-public-key",
@@ -52,13 +53,17 @@ describe("relay client authentication", () => { · 6 unmodified lines
52 Effect.gen(function* () {53 Effect.gen(function* () {
53 vi.mocked(verifyToken).mockResolvedValue({54 vi.mocked(verifyToken).mockResolvedValue({
54 sub: "user_session",55 sub: "user_session",
55 aud: relaySettings.relayIssuer,56 aud: relaySettings.clerkJwtAudience,
56 } as never);57 } as never);
57 58
58 expect(yield* verifyRelayClientBearerToken(relaySettings, "session-token")).toEqual({59 expect(yield* verifyRelayClientBearerToken(relaySettings, "session-token")).toEqual({
59 sub: "user_session",60 sub: "user_session",
60 mode: "clerk_session_bearer",61 mode: "clerk_session_bearer",
61 });62 });
63 expect(verifyToken).toHaveBeenCalledWith("session-token", {
64 secretKey: "clerk-secret-key",
65 audience: relaySettings.clerkJwtAudience,
66 });
62 expect(createClerkClient).not.toHaveBeenCalled();67 expect(createClerkClient).not.toHaveBeenCalled();
63 }).pipe(68 }).pipe(
64 Effect.ensuring(69 Effect.ensuring(
infra/relay/src/http/Api.ts+4−5
@@ -578,7 +578,7 @@ export const tokenApi = HttpApiBuilder.group( · 577 unmodified lines
578 const verified = yield* verifyClerkBearerToken(config, args.payload.subject_token).pipe(578 const verified = yield* verifyClerkBearerToken(config, args.payload.subject_token).pipe(
579 Effect.catch(() => relayAuthInvalidError("invalid_bearer")),579 Effect.catch(() => relayAuthInvalidError("invalid_bearer")),
580 );580 );
581 if (!verified.sub || !hasExpectedClerkAudience(verified.aud, config.relayIssuer)) {581 if (!verified.sub || !hasExpectedClerkAudience(verified.aud, config.clerkJwtAudience)) {
582 return yield* relayAuthInvalidError("invalid_bearer");582 return yield* relayAuthInvalidError("invalid_bearer");
583 }583 }
584 const proofKeyThumbprint = yield* requireDpopProof().pipe(584 const proofKeyThumbprint = yield* requireDpopProof().pipe(
@@ -960,8 +960,7 @@ function clerkVerificationFailureReason(cause: unknown): string { · 375 unmodified lines
960 return "unknown";960 return "unknown";
961}961}
962 962
963function hasExpectedClerkAudience(audience: unknown, relayIssuer: string): boolean {963function hasExpectedClerkAudience(audience: unknown, expectedAudience: string): boolean {
964 const expectedAudience = normalizeRelayIssuer(relayIssuer);
965 return typeof audience === "string"964 return typeof audience === "string"
966 ? audience === expectedAudience965 ? audience === expectedAudience
967 : Array.isArray(audience) &&966 : Array.isArray(audience) &&
@@ -973,7 +972,7 @@ function verifyClerkBearerToken(config: RelayConfiguration.RelayConfigurationSha · 5 unmodified lines
973 try: () =>972 try: () =>
974 verifyToken(token, {973 verifyToken(token, {
975 secretKey: Redacted.value(config.clerkSecretKey),974 secretKey: Redacted.value(config.clerkSecretKey),
976 audience: normalizeRelayIssuer(config.relayIssuer),975 audience: config.clerkJwtAudience,
977 }),976 }),
978 catch: (cause) => new ClerkTokenVerificationFailed({ cause }),977 catch: (cause) => new ClerkTokenVerificationFailed({ cause }),
979 }).pipe(978 }).pipe(
@@ -1015,7 +1014,7 @@ export function verifyRelayClientBearerToken( · 35 unmodified lines
1015) {1014) {
1016 return verifyClerkBearerToken(config, token).pipe(1015 return verifyClerkBearerToken(config, token).pipe(
1017 Effect.flatMap((verified) =>1016 Effect.flatMap((verified) =>
1018 verified.sub && hasExpectedClerkAudience(verified.aud, config.relayIssuer)1017 verified.sub && hasExpectedClerkAudience(verified.aud, config.clerkJwtAudience)
1019 ? Effect.succeed({ sub: verified.sub, mode: "clerk_session_bearer" as const })1018 ? Effect.succeed({ sub: verified.sub, mode: "clerk_session_bearer" as const })
1020 : Effect.fail(new ClerkTokenVerificationFailed({ cause: "missing_relay_audience" })),1019 : Effect.fail(new ClerkTokenVerificationFailed({ cause: "missing_relay_audience" })),
1021 ),1020 ),
infra/relay/src/worker.ts+2−0
@@ -130,6 +130,7 @@ export default class Api extends Cloudflare.Worker<Api>()( · 129 unmodified lines
130 130
131 const clerkSecretKey = yield* Config.redacted("CLERK_SECRET_KEY");131 const clerkSecretKey = yield* Config.redacted("CLERK_SECRET_KEY");
132 const clerkPublishableKey = yield* Config.string("CLERK_PUBLISHABLE_KEY");132 const clerkPublishableKey = yield* Config.string("CLERK_PUBLISHABLE_KEY");
133 const clerkJwtAudience = yield* Config.string("CLERK_JWT_AUDIENCE");
133 134
134 const cloudMintPrivateKey = yield* cloudMintKeyPair.privateKey;135 const cloudMintPrivateKey = yield* cloudMintKeyPair.privateKey;
135 const cloudMintPublicKey = yield* cloudMintKeyPair.publicKey;136 const cloudMintPublicKey = yield* cloudMintKeyPair.publicKey;
@@ -158,6 +159,7 @@ export default class Api extends Cloudflare.Worker<Api>()( · 22 unmodified lines
158 apnsDeliveryJobSigningSecret: yield* apnsDeliveryJobSigningSecret,159 apnsDeliveryJobSigningSecret: yield* apnsDeliveryJobSigningSecret,
159 clerkSecretKey,160 clerkSecretKey,
160 clerkPublishableKey,161 clerkPublishableKey,
162 clerkJwtAudience,
161 cloudMintPrivateKey: yield* cloudMintPrivateKey,163 cloudMintPrivateKey: yield* cloudMintPrivateKey,
162 cloudMintPublicKey: yield* cloudMintPublicKey,164 cloudMintPublicKey: yield* cloudMintPublicKey,
163 managedEndpointBaseDomain: yield* managedEndpointZoneName,165 managedEndpointBaseDomain: yield* managedEndpointZoneName,
packages/shared/src/relayAuth.ts+6−6
@@ -1,6 +1,6 @@
1export const RELAY_CLERK_JWT_TEMPLATE = "t3-relay";1export function relayClerkTokenOptions(template: string) {
2 2 return {
3export const RELAY_CLERK_TOKEN_OPTIONS = {3 template,
4 template: RELAY_CLERK_JWT_TEMPLATE,4 skipCache: true,
5 skipCache: true,5 } as const;
6} as const;6}
scripts/lib/public-config.test.ts+11−2
@@ -21,6 +21,9 @@ describe("loadRepoEnv", () => { · 20 unmodified lines
21 expect(env.T3CODE_CLERK_PUBLISHABLE_KEY).toBeUndefined();21 expect(env.T3CODE_CLERK_PUBLISHABLE_KEY).toBeUndefined();
22 expect(env.VITE_CLERK_PUBLISHABLE_KEY).toBeUndefined();22 expect(env.VITE_CLERK_PUBLISHABLE_KEY).toBeUndefined();
23 expect(env.EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY).toBeUndefined();23 expect(env.EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY).toBeUndefined();
24 expect(env.T3CODE_CLERK_JWT_TEMPLATE).toBeUndefined();
25 expect(env.VITE_CLERK_JWT_TEMPLATE).toBeUndefined();
26 expect(env.EXPO_PUBLIC_CLERK_JWT_TEMPLATE).toBeUndefined();
24 expect(env.T3CODE_RELAY_URL).toBeUndefined();27 expect(env.T3CODE_RELAY_URL).toBeUndefined();
25 expect(env.VITE_T3CODE_RELAY_URL).toBeUndefined();28 expect(env.VITE_T3CODE_RELAY_URL).toBeUndefined();
26 });29 });
@@ -29,11 +32,11 @@ describe("loadRepoEnv", () => { · 2 unmodified lines
29 const repoRoot = makeTemporaryDirectory();32 const repoRoot = makeTemporaryDirectory();
30 writeFileSync(33 writeFileSync(
31 join(repoRoot, ".env"),34 join(repoRoot, ".env"),
32 "T3CODE_CLERK_PUBLISHABLE_KEY=pk_root\nT3CODE_RELAY_URL=https://root.example.test\n",35 "T3CODE_CLERK_PUBLISHABLE_KEY=pk_root\nT3CODE_CLERK_JWT_TEMPLATE=template_root\nT3CODE_RELAY_URL=https://root.example.test\n",
33 );36 );
34 writeFileSync(37 writeFileSync(
35 join(repoRoot, ".env.local"),38 join(repoRoot, ".env.local"),
36 "T3CODE_CLERK_PUBLISHABLE_KEY=pk_local\nT3CODE_RELAY_URL=https://local.example.test\n",39 "T3CODE_CLERK_PUBLISHABLE_KEY=pk_local\nT3CODE_CLERK_JWT_TEMPLATE=template_local\nT3CODE_RELAY_URL=https://local.example.test\n",
37 );40 );
38 41
39 expect(loadRepoEnv({ baseEnv: {}, repoRoot }).T3CODE_RELAY_URL).toBe(42 expect(loadRepoEnv({ baseEnv: {}, repoRoot }).T3CODE_RELAY_URL).toBe(
@@ -43,6 +46,7 @@ describe("loadRepoEnv", () => { · 3 unmodified lines
43 loadRepoEnv({46 loadRepoEnv({
44 baseEnv: {47 baseEnv: {
45 T3CODE_CLERK_PUBLISHABLE_KEY: "pk_ci",48 T3CODE_CLERK_PUBLISHABLE_KEY: "pk_ci",
49 T3CODE_CLERK_JWT_TEMPLATE: "template_ci",
46 T3CODE_RELAY_URL: "https://ci.example.test",50 T3CODE_RELAY_URL: "https://ci.example.test",
47 },51 },
48 repoRoot,52 repoRoot,
@@ -51,6 +55,9 @@ describe("loadRepoEnv", () => { · 2 unmodified lines
51 T3CODE_CLERK_PUBLISHABLE_KEY: "pk_ci",55 T3CODE_CLERK_PUBLISHABLE_KEY: "pk_ci",
52 VITE_CLERK_PUBLISHABLE_KEY: "pk_ci",56 VITE_CLERK_PUBLISHABLE_KEY: "pk_ci",
53 EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY: "pk_ci",57 EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY: "pk_ci",
58 T3CODE_CLERK_JWT_TEMPLATE: "template_ci",
59 VITE_CLERK_JWT_TEMPLATE: "template_ci",
60 EXPO_PUBLIC_CLERK_JWT_TEMPLATE: "template_ci",
54 T3CODE_RELAY_URL: "https://ci.example.test",61 T3CODE_RELAY_URL: "https://ci.example.test",
55 VITE_T3CODE_RELAY_URL: "https://ci.example.test",62 VITE_T3CODE_RELAY_URL: "https://ci.example.test",
56 });63 });
@@ -60,10 +67,12 @@ describe("loadRepoEnv", () => { · 3 unmodified lines
60 expect(67 expect(
61 resolvePublicConfig({68 resolvePublicConfig({
62 VITE_CLERK_PUBLISHABLE_KEY: "pk_legacy",69 VITE_CLERK_PUBLISHABLE_KEY: "pk_legacy",
70 VITE_CLERK_JWT_TEMPLATE: "template_legacy",
63 VITE_T3CODE_RELAY_URL: "https://legacy.example.test",71 VITE_T3CODE_RELAY_URL: "https://legacy.example.test",
64 }),72 }),
65 ).toEqual({73 ).toEqual({
66 clerkPublishableKey: "pk_legacy",74 clerkPublishableKey: "pk_legacy",
75 clerkJwtTemplate: "template_legacy",
67 relayUrl: "https://legacy.example.test",76 relayUrl: "https://legacy.example.test",
68 });77 });
69 });78 });
scripts/lib/public-config.ts+14−0
@@ -6,6 +6,7 @@ import * as NodeUtil from "node:util"; · 5 unmodified lines
6 6
7export interface T3CodePublicConfig {7export interface T3CodePublicConfig {
8 readonly clerkPublishableKey: string | undefined;8 readonly clerkPublishableKey: string | undefined;
9 readonly clerkJwtTemplate: string | undefined;
9 readonly relayUrl: string | undefined;10 readonly relayUrl: string | undefined;
10}11}
11 12
@@ -37,6 +38,13 @@ export function loadRepoEnv({ · 25 unmodified lines
37 EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY: config.clerkPublishableKey,38 EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY: config.clerkPublishableKey,
38 }39 }
39 : {}),40 : {}),
41 ...(config.clerkJwtTemplate
42 ? {
43 T3CODE_CLERK_JWT_TEMPLATE: config.clerkJwtTemplate,
44 VITE_CLERK_JWT_TEMPLATE: config.clerkJwtTemplate,
45 EXPO_PUBLIC_CLERK_JWT_TEMPLATE: config.clerkJwtTemplate,
46 }
47 : {}),
40 ...(config.relayUrl48 ...(config.relayUrl
41 ? {49 ? {
42 T3CODE_RELAY_URL: config.relayUrl,50 T3CODE_RELAY_URL: config.relayUrl,
@@ -54,6 +62,12 @@ export function resolvePublicConfig(...sources: readonly Environment[]): T3CodeP · 11 unmodified lines
54 "VITE_CLERK_PUBLISHABLE_KEY",62 "VITE_CLERK_PUBLISHABLE_KEY",
55 "EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY",63 "EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY",
56 ),64 ),
65 clerkJwtTemplate: firstNonEmpty(
66 sources,
67 "T3CODE_CLERK_JWT_TEMPLATE",
68 "VITE_CLERK_JWT_TEMPLATE",
69 "EXPO_PUBLIC_CLERK_JWT_TEMPLATE",
70 ),
57 relayUrl: firstNonEmpty(sources, "T3CODE_RELAY_URL", "VITE_T3CODE_RELAY_URL"),71 relayUrl: firstNonEmpty(sources, "T3CODE_RELAY_URL", "VITE_T3CODE_RELAY_URL"),
58 };72 };
59}73}