Move GitHub App ID and Client ID from env vars to TOML config

Non-secret config (app_id, client_id) now lives in [git] section of
~/.arc/arc.toml. Secrets remain in .env. Setup callback writes
non-secrets to TOML and secrets to .env.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-03-03 01:05:48 -05:00
parent eab81aee99
commit ae41f1c2c3
5 changed files with 92 additions and 9 deletions

View file

@ -12,8 +12,6 @@ export ARC_JWT_PRIVATE_KEY=
export ARC_JWT_PUBLIC_KEY=
export SESSION_SECRET=
export GITHUB_APP_ID=
export GITHUB_APP_CLIENT_ID=
export GITHUB_APP_CLIENT_SECRET=
export GITHUB_APP_WEBHOOK_SECRET=
export GITHUB_APP_PRIVATE_KEY=

View file

@ -13,9 +13,16 @@ interface ApiConfig {
authentication_strategy: "jwt" | "insecure_disabled";
}
interface GitConfig {
provider: "github";
app_id: string | null;
client_id: string | null;
}
export interface AppConfig {
auth: AuthConfig;
api: ApiConfig;
git: GitConfig;
}
const AUTH_DEFAULTS: AuthConfig = {
@ -28,6 +35,12 @@ const API_DEFAULTS: ApiConfig = {
authentication_strategy: "jwt",
};
const GIT_DEFAULTS: GitConfig = {
provider: "github",
app_id: null,
client_id: null,
};
function loadAppConfig(): AppConfig {
const configPath = join(homedir(), ".arc", "arc.toml");
@ -40,10 +53,12 @@ function loadAppConfig(): AppConfig {
const rawAuth = (raw.auth ?? {}) as Partial<AuthConfig>;
const rawApi = (raw.api ?? {}) as Partial<ApiConfig>;
const rawGit = (raw.git ?? {}) as Partial<GitConfig>;
return {
auth: { ...AUTH_DEFAULTS, ...rawAuth },
api: { ...API_DEFAULTS, ...rawApi },
git: { ...GIT_DEFAULTS, ...rawGit },
};
}

View file

@ -1,9 +1,10 @@
import { GitHub, generateState } from "arctic";
import { getAppConfig } from "./config.server";
export { generateState };
export function getGitHubOAuth() {
const clientId = process.env.GITHUB_APP_CLIENT_ID;
const clientId = getAppConfig().git.client_id;
const clientSecret = process.env.GITHUB_APP_CLIENT_SECRET;
if (!clientId || !clientSecret) {
throw new Error("GitHub App is not configured");
@ -12,7 +13,7 @@ export function getGitHubOAuth() {
}
export function isGitHubAppConfigured(): boolean {
return !!process.env.GITHUB_APP_CLIENT_ID;
return getAppConfig().git.client_id !== null;
}
export function getGitHubAppPrivateKey(): string {

View file

@ -1,10 +1,13 @@
import { readFile, writeFile } from "node:fs/promises";
import { readFile, writeFile, mkdir } from "node:fs/promises";
import { resolve } from "node:path";
import { homedir } from "node:os";
import { randomBytes } from "node:crypto";
import { redirect } from "react-router";
import { parse, stringify } from "smol-toml";
import type { Route } from "./+types/setup-callback";
const ENV_PATH = resolve(import.meta.dirname, "../../../../.env");
const TOML_PATH = resolve(homedir(), ".arc", "arc.toml");
export async function loader({ request }: Route.LoaderArgs) {
const url = new URL(request.url);
@ -33,6 +36,23 @@ export async function loader({ request }: Route.LoaderArgs) {
const sessionSecret = randomBytes(32).toString("hex");
// Write non-secrets to TOML config
let tomlConfig: Record<string, unknown> = {};
try {
tomlConfig = parse(await readFile(TOML_PATH, "utf-8")) as Record<string, unknown>;
} catch {
// file doesn't exist yet
}
tomlConfig.git = {
...((tomlConfig.git as Record<string, unknown>) ?? {}),
provider: "github",
app_id: String(data.id),
client_id: data.client_id,
};
await mkdir(resolve(homedir(), ".arc"), { recursive: true });
await writeFile(TOML_PATH, stringify(tomlConfig), "utf-8");
// Write secrets to .env
let existing = "";
try {
existing = await readFile(ENV_PATH, "utf-8");
@ -42,8 +62,6 @@ export async function loader({ request }: Route.LoaderArgs) {
const newVars = [
`export SESSION_SECRET=${sessionSecret}`,
`export GITHUB_APP_ID=${data.id}`,
`export GITHUB_APP_CLIENT_ID=${data.client_id}`,
`export GITHUB_APP_CLIENT_SECRET=${data.client_secret}`,
`export GITHUB_APP_WEBHOOK_SECRET=${data.webhook_secret}`,
`export GITHUB_APP_PRIVATE_KEY=${Buffer.from(data.pem).toString("base64")}`,
@ -53,8 +71,6 @@ export async function loader({ request }: Route.LoaderArgs) {
await writeFile(ENV_PATH, envContent, "utf-8");
process.env.SESSION_SECRET = sessionSecret;
process.env.GITHUB_APP_ID = String(data.id);
process.env.GITHUB_APP_CLIENT_ID = data.client_id;
process.env.GITHUB_APP_CLIENT_SECRET = data.client_secret;
process.env.GITHUB_APP_WEBHOOK_SECRET = data.webhook_secret;
process.env.GITHUB_APP_PRIVATE_KEY = Buffer.from(data.pem).toString("base64");

View file

@ -57,6 +57,26 @@ impl Default for ApiConfig {
}
}
#[derive(Debug, Clone, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum GitProvider {
Github,
}
impl Default for GitProvider {
fn default() -> Self {
Self::Github
}
}
#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
pub struct GitConfig {
#[serde(default)]
pub provider: GitProvider,
pub app_id: Option<String>,
pub client_id: Option<String>,
}
#[derive(Debug, Default, Deserialize)]
pub struct AppConfig {
pub data_dir: Option<PathBuf>,
@ -64,6 +84,8 @@ pub struct AppConfig {
pub auth: AuthConfig,
#[serde(default)]
pub api: ApiConfig,
#[serde(default)]
pub git: GitConfig,
}
/// Load app config from `~/.arc/arc.toml`, returning defaults if the file doesn't exist.
@ -138,6 +160,11 @@ allowed_usernames = ["brynary", "alice"]
[api]
base_url = "http://example.com:8080"
authentication_strategy = "jwt"
[git]
provider = "github"
app_id = "12345"
client_id = "Iv1.abc123"
"#;
let config: AppConfig = toml::from_str(toml).unwrap();
assert_eq!(config.auth.provider, AuthProvider::Github);
@ -147,6 +174,9 @@ authentication_strategy = "jwt"
config.api.authentication_strategy,
ApiAuthenticationStrategy::Jwt
);
assert_eq!(config.git.provider, GitProvider::Github);
assert_eq!(config.git.app_id.as_deref(), Some("12345"));
assert_eq!(config.git.client_id.as_deref(), Some("Iv1.abc123"));
}
#[test]
@ -168,6 +198,29 @@ authentication_strategy = "jwt"
);
}
#[test]
fn parse_git_config() {
let toml = r#"
[git]
provider = "github"
app_id = "12345"
client_id = "Iv1.abc123"
"#;
let config: AppConfig = toml::from_str(toml).unwrap();
assert_eq!(config.git.provider, GitProvider::Github);
assert_eq!(config.git.app_id.as_deref(), Some("12345"));
assert_eq!(config.git.client_id.as_deref(), Some("Iv1.abc123"));
}
#[test]
fn parse_git_defaults() {
let toml = "";
let config: AppConfig = toml::from_str(toml).unwrap();
assert_eq!(config.git.provider, GitProvider::Github);
assert_eq!(config.git.app_id, None);
assert_eq!(config.git.client_id, None);
}
#[test]
fn parse_insecure_disabled_values() {
let toml = r#"