1import {
2 connectLoopbackRedirectUri,
3 CONNECT_OAUTH_SCOPES,
4 DEFAULT_HOSTED_APP_URL,
5} from "@t3tools/shared/connectAuth";
6import { clerkFrontendApiUrlFromPublishableKey } from "@t3tools/shared/relayAuth";
7import { normalizeSecureRelayUrl } from "@t3tools/shared/relayUrl";
8import * as Config from "effect/Config";
9import * as ConfigProvider from "effect/ConfigProvider";
10import * as Effect from "effect/Effect";
11import * as Schema from "effect/Schema";
12import * as SchemaIssue from "effect/SchemaIssue";
13
14declare const __T3CODE_BUILD_RELAY_URL__: string | undefined;
15declare const __T3CODE_BUILD_CLERK_PUBLISHABLE_KEY__: string | undefined;
16declare const __T3CODE_BUILD_CLERK_CLI_OAUTH_CLIENT_ID__: string | undefined;
17
18const CLOUD_CLI_OAUTH_LOOPBACK_PORT = 34338;
19const CLOUD_CLI_OAUTH_SCOPES = CONNECT_OAUTH_SCOPES;
20
21function validateRelayUrl(value: string) {
22 const relayUrl = normalizeSecureRelayUrl(value);
23 return relayUrl === null
24 ? Effect.fail(
25 new Config.ConfigError(
26 new Schema.SchemaError(
27 new SchemaIssue.InvalidValue({
28 message: "Relay URL must be a secure absolute HTTPS origin.",
29 }),
30 ),
31 ),
32 )
33 : Effect.succeed(relayUrl);
34}
35
36function readBuildTimeValue(value: string | undefined): string {
37 return typeof value === "undefined" ? "" : value.trim();
38}
39
40const buildTimeRelayUrl =
41 typeof __T3CODE_BUILD_RELAY_URL__ === "undefined"
42 ? ""
43 : (normalizeSecureRelayUrl(__T3CODE_BUILD_RELAY_URL__) ?? "");
44const buildTimeClerkPublishableKey = readBuildTimeValue(
45 typeof __T3CODE_BUILD_CLERK_PUBLISHABLE_KEY__ === "undefined"
46 ? undefined
47 : __T3CODE_BUILD_CLERK_PUBLISHABLE_KEY__,
48);
49const buildTimeClerkCliOAuthClientId = readBuildTimeValue(
50 typeof __T3CODE_BUILD_CLERK_CLI_OAUTH_CLIENT_ID__ === "undefined"
51 ? undefined
52 : __T3CODE_BUILD_CLERK_CLI_OAUTH_CLIENT_ID__,
53);
54export function makeRelayUrlConfig(fallback = buildTimeRelayUrl) {
55 const runtimeConfig = Config.NonEmptyString("T3CODE_RELAY_URL");
56 return (fallback ? runtimeConfig.pipe(Config.withDefault(fallback)) : runtimeConfig).pipe(
57 Config.mapEffect(validateRelayUrl),
58 );
59}
60
61export const relayUrlConfig = makeRelayUrlConfig();
62
63/**
64 * Hosted app origin used for out-of-band OAuth on headless
65 * machines. Overridable so staging/nightly builds can point their CLIs at a
66 * matching hosted deployment.
67 */
68export const hostedAppUrlConfig = makePublicValueConfig(
69 "T3CODE_HOSTED_APP_URL",
70 DEFAULT_HOSTED_APP_URL,
71).pipe(Config.mapEffect(validateHostedAppUrl));
72
73function validateHostedAppUrl(value: string) {
74 try {
75 const url = new URL(value);
76 const isLoopbackHttp =
77 url.protocol === "http:" &&
78 (url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "[::1]");
79 if (
80 (url.protocol !== "https:" && !isLoopbackHttp) ||
81 url.pathname !== "/" ||
82 url.search !== "" ||
83 url.hash !== ""
84 ) {
85 throw new Error("invalid hosted app origin");
86 }
87 return Effect.succeed(url.origin);
88 } catch {
89 return Effect.fail(
90 new Config.ConfigError(
91 new Schema.SchemaError(
92 new SchemaIssue.InvalidValue({
93 message: "Hosted app URL must be an absolute HTTPS origin (or HTTP loopback origin).",
94 }),
95 ),
96 ),
97 );
98 }
99}
100
101function makePublicValueConfig(name: string, fallback: string) {
102 const runtimeConfig = Config.NonEmptyString(name);
103 return (fallback ? runtimeConfig.pipe(Config.withDefault(fallback)) : runtimeConfig).pipe(
104 Config.map((value) => value.trim()),
105 );
106}
107
108/**
109 * The CLI never calls Clerk's /oauth/authorize itself: the browser leg goes
110 * through the hosted /connect page, which builds the authorize URL after a
111 * Clerk session exists (see CliTokenManager.login). The token endpoint and,
112 * for headless hosts, the device authorization endpoint are contacted
113 * directly.
114 */
115export interface CloudCliOAuthConfig {
116 readonly tokenEndpoint: string;
117 readonly deviceAuthorizationEndpoint: string;
118 readonly clientId: string;
119 readonly loopbackPort: number;
120 readonly redirectUri: string;
121 readonly scopes: typeof CLOUD_CLI_OAUTH_SCOPES;
122}
123
124export function makeCloudCliOAuthConfig({
125 clerkPublishableKeyFallback = buildTimeClerkPublishableKey,
126 clerkCliOAuthClientIdFallback = buildTimeClerkCliOAuthClientId,
127}: {
128 readonly clerkPublishableKeyFallback?: string;
129 readonly clerkCliOAuthClientIdFallback?: string;
130} = {}) {
131 return Config.all({
132 clerkPublishableKey: makePublicValueConfig(
133 "T3CODE_CLERK_PUBLISHABLE_KEY",
134 clerkPublishableKeyFallback,
135 ),
136 clientId: makePublicValueConfig(
137 "T3CODE_CLERK_CLI_OAUTH_CLIENT_ID",
138 clerkCliOAuthClientIdFallback,
139 ),
140 }).pipe(
141 Config.mapEffect(({ clerkPublishableKey, clientId }) =>
142 Effect.try({
143 try: () => clerkFrontendApiUrlFromPublishableKey(clerkPublishableKey),
144 catch: (cause) =>
145 new Config.ConfigError(
146 new ConfigProvider.SourceError({
147 message: "Failed to derive Clerk Frontend API URL from the publishable key.",
148 cause,
149 }),
150 ),
151 }).pipe(
152 Effect.map(
153 (clerkFrontendApiUrl) =>
154 ({
155 tokenEndpoint: `${clerkFrontendApiUrl}/oauth/token`,
156 deviceAuthorizationEndpoint: `${clerkFrontendApiUrl}/oauth/device_authorization`,
157 clientId,
158 loopbackPort: CLOUD_CLI_OAUTH_LOOPBACK_PORT,
159 redirectUri: connectLoopbackRedirectUri(CLOUD_CLI_OAUTH_LOOPBACK_PORT),
160 scopes: CLOUD_CLI_OAUTH_SCOPES,
161 }) satisfies CloudCliOAuthConfig,
162 ),
163 ),
164 ),
165 );
166}
167
168export const cloudCliOAuthConfig = makeCloudCliOAuthConfig();
169
170export const hasCloudPublicConfig = Boolean(
171 (normalizeSecureRelayUrl(process.env.T3CODE_RELAY_URL ?? "") ?? buildTimeRelayUrl) &&
172 (process.env.T3CODE_CLERK_PUBLISHABLE_KEY?.trim() || buildTimeClerkPublishableKey) &&
173 (process.env.T3CODE_CLERK_CLI_OAUTH_CLIENT_ID?.trim() || buildTimeClerkCliOAuthClientId),
174);