Fix worktree auth after instance migration

This commit is contained in:
Dotta committed 2026-05-22 13:00:15 +00:00
1 parent 6f416935a0
commit a3bff6e1ce
6 files changed
+214 -7

No files matched your search

+43 -1
View File
@@ -5,6 +5,7 @@ import {
buildBetterAuthAdvancedOptions,
deriveAuthCookiePrefix,
deriveAuthTrustedOrigins,
shouldDisableSecureAuthCookies,
} from "../auth/better-auth.js";
const ORIGINAL_INSTANCE_ID = process.env.PAPERCLIP_INSTANCE_ID;
@@ -29,7 +30,7 @@ describe("Better Auth cookie scoping", () => {
cookiePrefix: "paperclip-sat-worktree",
});
expect(getCookies({ advanced } as BetterAuthOptions).sessionToken.name).toBe(
"paperclip-sat-worktree.session_token",
"__Secure-paperclip-sat-worktree.session_token",
);
});
@@ -40,6 +41,47 @@ describe("Better Auth cookie scoping", () => {
cookiePrefix: "paperclip-pap-worktree",
useSecureCookies: false,
});
expect(getCookies({
advanced: buildBetterAuthAdvancedOptions({ disableSecureCookies: true }),
} as BetterAuthOptions).sessionToken.name).toBe("paperclip-pap-worktree.session_token");
});
it("disables secure cookies for authenticated private auto-origin dev servers", () => {
expect(shouldDisableSecureAuthCookies({
deploymentMode: "authenticated",
deploymentExposure: "private",
authBaseUrlMode: "auto",
authPublicBaseUrl: undefined,
publicUrl: undefined,
})).toBe(true);
});
it("keeps secure cookies for authenticated public auto-origin servers", () => {
expect(shouldDisableSecureAuthCookies({
deploymentMode: "authenticated",
deploymentExposure: "public",
authBaseUrlMode: "auto",
authPublicBaseUrl: undefined,
publicUrl: undefined,
})).toBe(false);
});
it("uses an explicit public URL when deciding whether secure cookies are required", () => {
expect(shouldDisableSecureAuthCookies({
deploymentMode: "authenticated",
deploymentExposure: "private",
authBaseUrlMode: "auto",
authPublicBaseUrl: undefined,
publicUrl: "https://paperclip.example.test",
})).toBe(false);
expect(shouldDisableSecureAuthCookies({
deploymentMode: "authenticated",
deploymentExposure: "public",
authBaseUrlMode: "explicit",
authPublicBaseUrl: "http://paperclip-dev:3100",
publicUrl: undefined,
})).toBe(true);
});
it("adds hostname port variants for authenticated mode on non-default ports", () => {
@@ -63,6 +63,42 @@ describe("dev-runner worktree env bootstrap", () => {
expect(env.PAPERCLIP_OPTIONAL).toBe("");
});
it("repairs stale migrated config paths before loading worktree env", () => {
const root = createTempRoot("paperclip-dev-runner-worktree-migrated-env-");
const localConfigPath = path.join(root, ".paperclip", "config.json");
const worktreesDir = path.join(root, ".paperclip-worktrees");
fs.mkdirSync(path.dirname(localConfigPath), { recursive: true });
fs.writeFileSync(path.join(root, ".git"), "gitdir: /tmp/paperclip/.git/worktrees/feature\n", "utf8");
fs.writeFileSync(localConfigPath, "{}\n", "utf8");
fs.writeFileSync(
resolveWorktreeEnvFilePath(root),
[
"PAPERCLIP_HOME=/Users/dotta/.paperclip-worktrees",
"PAPERCLIP_INSTANCE_ID=feature-worktree",
"PAPERCLIP_CONFIG=/Users/dotta/paperclip/.paperclip/worktrees/feature/.paperclip/config.json",
"PAPERCLIP_CONTEXT=/Users/dotta/.paperclip-worktrees/context.json",
"PAPERCLIP_IN_WORKTREE=true",
"PAPERCLIP_WORKTREE_NAME=feature-worktree",
"",
].join("\n"),
"utf8",
);
const env: NodeJS.ProcessEnv = {
PAPERCLIP_WORKTREES_DIR: worktreesDir,
};
const result = bootstrapDevRunnerWorktreeEnv(root, env);
expect(result).toEqual({
envPath: resolveWorktreeEnvFilePath(root),
missingEnv: false,
});
expect(env.PAPERCLIP_HOME).toBe(worktreesDir);
expect(env.PAPERCLIP_CONFIG).toBe(localConfigPath);
expect(env.PAPERCLIP_CONTEXT).toBe(path.join(worktreesDir, "context.json"));
expect(env.PAPERCLIP_INSTANCE_ID).toBe("feature-worktree");
});
it("reports uninitialized linked worktrees so dev runner can fail fast", () => {
const root = createTempRoot("paperclip-dev-runner-worktree-missing-");
fs.writeFileSync(path.join(root, ".git"), "gitdir: /tmp/paperclip/.git/worktrees/feature\n", "utf8");
@@ -210,6 +210,56 @@ describe("worktree config repair", () => {
expect(repairedConfig.database.embeddedPostgresPort).toBe(54331);
});
it("ignores stale migrated env paths when the dev runner resolved the local config", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-worktree-migrated-env-"));
const worktreeRoot = path.join(tempRoot, "PAP-9940-what-can-we-learn");
const paperclipDir = path.join(worktreeRoot, ".paperclip");
const configPath = path.join(paperclipDir, "config.json");
const envPath = path.join(paperclipDir, ".env");
const oldHome = "/Users/dotta/.paperclip-worktrees";
const isolatedHome = path.join(tempRoot, ".paperclip-worktrees");
await fs.mkdir(paperclipDir, { recursive: true });
await fs.writeFile(configPath, JSON.stringify(buildLegacyConfig(oldHome), null, 2) + "\n", "utf8");
await fs.writeFile(
envPath,
[
"# Paperclip environment variables",
"PAPERCLIP_HOME=/Users/dotta/.paperclip-worktrees",
"PAPERCLIP_INSTANCE_ID=pap-9940-what-can-we-learn",
"PAPERCLIP_CONFIG=/Users/dotta/paperclip/.paperclip/worktrees/PAP-9940-what-can-we-learn/.paperclip/config.json",
"PAPERCLIP_CONTEXT=/Users/dotta/.paperclip-worktrees/context.json",
"PAPERCLIP_IN_WORKTREE=true",
"PAPERCLIP_WORKTREE_NAME=PAP-9940-what-can-we-learn",
"",
].join("\n"),
"utf8",
);
process.chdir(worktreeRoot);
process.env.PAPERCLIP_IN_WORKTREE = "true";
process.env.PAPERCLIP_CONFIG = configPath;
process.env.PAPERCLIP_WORKTREES_DIR = isolatedHome;
delete process.env.PAPERCLIP_HOME;
delete process.env.PAPERCLIP_INSTANCE_ID;
delete process.env.PAPERCLIP_CONTEXT;
const result = maybeRepairLegacyWorktreeConfigAndEnvFiles();
const repairedConfig = JSON.parse(await fs.readFile(configPath, "utf8"));
const repairedEnv = await fs.readFile(envPath, "utf8");
const instanceRoot = path.join(isolatedHome, "instances", "pap-9940-what-can-we-learn");
expect(result).toEqual({
repairedConfig: true,
repairedEnv: true,
});
expect(repairedConfig.database.embeddedPostgresDataDir).toBe(path.join(instanceRoot, "db"));
expect(repairedConfig.secrets.localEncrypted.keyFilePath).toBe(path.join(instanceRoot, "secrets", "master.key"));
expect(repairedEnv).toContain(`PAPERCLIP_HOME=${JSON.stringify(isolatedHome)}`);
expect(repairedEnv).toContain(`PAPERCLIP_CONFIG=${JSON.stringify(configPath)}`);
expect(repairedEnv).not.toContain("/Users/dotta");
});
it("does not persist transient runtime home overrides over repo-local worktree env", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-worktree-runtime-override-"));
const isolatedHome = path.join(tempRoot, ".paperclip-worktrees");
@@ -508,6 +558,8 @@ describe("worktree config repair", () => {
process.env.PAPERCLIP_HOME = isolatedHome;
process.env.PAPERCLIP_INSTANCE_ID = "pap-878-create-a-mine-tab-in-inbox";
process.env.PAPERCLIP_CONFIG = configPath;
delete process.env.PORT;
delete process.env.DATABASE_URL;
maybePersistWorktreeRuntimePorts({
serverPort: 3103,
@@ -590,6 +642,8 @@ describe("worktree config repair", () => {
process.env.PAPERCLIP_HOME = isolatedHome;
process.env.PAPERCLIP_INSTANCE_ID = "pap-125-public-base-url";
process.env.PAPERCLIP_CONFIG = configPath;
delete process.env.PORT;
delete process.env.DATABASE_URL;
maybePersistWorktreeRuntimePorts({
serverPort: 3103,
+27 -2
View File
@@ -44,6 +44,25 @@ export function buildBetterAuthAdvancedOptions(input: { disableSecureCookies: bo
};
}
export function shouldDisableSecureAuthCookies(input: {
deploymentMode: Config["deploymentMode"];
deploymentExposure: Config["deploymentExposure"];
authBaseUrlMode: Config["authBaseUrlMode"];
authPublicBaseUrl: string | undefined;
publicUrl: string | undefined;
}): boolean {
const publicUrl = input.publicUrl ?? (
input.authBaseUrlMode === "explicit" ? input.authPublicBaseUrl : undefined
);
if (publicUrl) return publicUrl.startsWith("http://");
return (
input.deploymentMode === "authenticated" &&
input.deploymentExposure === "private" &&
input.authBaseUrlMode === "auto"
);
}
function headersFromNodeHeaders(rawHeaders: IncomingHttpHeaders): Headers {
const headers = new Headers();
for (const [key, raw] of Object.entries(rawHeaders)) {
@@ -100,7 +119,13 @@ export function createBetterAuthInstance(db: Db, config: Config, trustedOrigins:
);
}
const publicUrl = process.env.PAPERCLIP_PUBLIC_URL ?? baseUrl;
const isHttpOnly = publicUrl ? publicUrl.startsWith("http://") : false;
const disableSecureCookies = shouldDisableSecureAuthCookies({
deploymentMode: config.deploymentMode,
deploymentExposure: config.deploymentExposure,
authBaseUrlMode: config.authBaseUrlMode,
authPublicBaseUrl: config.authPublicBaseUrl,
publicUrl,
});
const authConfig = {
baseURL: baseUrl,
@@ -120,7 +145,7 @@ export function createBetterAuthInstance(db: Db, config: Config, trustedOrigins:
requireEmailVerification: false,
disableSignUp: config.authDisableSignUp,
},
advanced: buildBetterAuthAdvancedOptions({ disableSecureCookies: isHttpOnly }),
advanced: buildBetterAuthAdvancedOptions({ disableSecureCookies }),
};
if (!baseUrl) {
+45 -1
View File
@@ -1,4 +1,5 @@
import { existsSync, lstatSync, readFileSync } from "node:fs";
import os from "node:os";
import path from "node:path";
function parseEnvFile(contents: string): Record<string, string> {
@@ -55,6 +56,45 @@ export function resolveWorktreeEnvFilePath(rootDir: string): string {
return path.resolve(rootDir, ".paperclip", ".env");
}
function expandHomePrefix(value: string): string {
if (value === "~") return os.homedir();
if (value.startsWith("~/")) return path.resolve(os.homedir(), value.slice(2));
return value;
}
function resolveHomeAwarePath(value: string): string {
return path.resolve(expandHomePrefix(value));
}
function resolveDefaultWorktreeHome(env: NodeJS.ProcessEnv): string {
return path.resolve(expandHomePrefix(env.PAPERCLIP_WORKTREES_DIR?.trim() || "~/.paperclip-worktrees"));
}
function repairStaleMigratedWorktreeEnvEntries(
rootDir: string,
entries: Record<string, string>,
env: NodeJS.ProcessEnv,
): Record<string, string> {
const localConfigPath = path.resolve(rootDir, ".paperclip", "config.json");
const configuredPath = entries.PAPERCLIP_CONFIG?.trim();
if (!configuredPath) return entries;
const resolvedConfiguredPath = resolveHomeAwarePath(configuredPath);
const staleConfigPath =
resolvedConfiguredPath !== localConfigPath &&
!existsSync(resolvedConfiguredPath) &&
existsSync(localConfigPath);
if (!staleConfigPath) return entries;
const homeDir = resolveDefaultWorktreeHome(env);
return {
...entries,
PAPERCLIP_HOME: homeDir,
PAPERCLIP_CONFIG: localConfigPath,
PAPERCLIP_CONTEXT: path.resolve(homeDir, "context.json"),
};
}
export function bootstrapDevRunnerWorktreeEnv(
rootDir: string,
env: NodeJS.ProcessEnv = process.env,
@@ -74,7 +114,11 @@ export function bootstrapDevRunnerWorktreeEnv(
};
}
const entries = parseEnvFile(readFileSync(envPath, "utf8"));
const entries = repairStaleMigratedWorktreeEnvEntries(
rootDir,
parseEnvFile(readFileSync(envPath, "utf8")),
env,
);
for (const [key, value] of Object.entries(entries)) {
if (typeof env[key] === "string" && env[key]!.trim().length > 0) continue;
env[key] = value;
+9 -3
View File
@@ -115,17 +115,23 @@ function resolveWorktreeRuntimeContext(
const configPath = resolvePaperclipConfigPath(overrideConfigPath);
const envPath = resolvePaperclipEnvPath(configPath);
const persistedEnv = readEnvEntries(envPath);
const persistedConfigPath = nonEmpty(persistedEnv.PAPERCLIP_CONFIG);
const persistedConfigLooksStale =
persistedConfigPath !== null &&
path.resolve(expandHomePrefix(persistedConfigPath)) !== path.resolve(configPath) &&
!fs.existsSync(resolveHomeAwarePath(persistedConfigPath));
const stablePersistedEnv = persistedConfigLooksStale ? {} : persistedEnv;
const worktreeRoot = path.resolve(path.dirname(configPath), "..");
const worktreeName =
nonEmpty(persistedEnv.PAPERCLIP_WORKTREE_NAME) ??
nonEmpty(stablePersistedEnv.PAPERCLIP_WORKTREE_NAME) ??
nonEmpty(env.PAPERCLIP_WORKTREE_NAME) ??
path.basename(worktreeRoot);
const instanceId =
nonEmpty(persistedEnv.PAPERCLIP_INSTANCE_ID) ??
nonEmpty(stablePersistedEnv.PAPERCLIP_INSTANCE_ID) ??
nonEmpty(env.PAPERCLIP_INSTANCE_ID) ??
sanitizeWorktreeInstanceId(worktreeName);
const homeDir = resolveHomeAwarePath(
nonEmpty(persistedEnv.PAPERCLIP_HOME) ??
nonEmpty(stablePersistedEnv.PAPERCLIP_HOME) ??
nonEmpty(env.PAPERCLIP_HOME) ??
nonEmpty(env.PAPERCLIP_WORKTREES_DIR) ??
"~/.paperclip-worktrees",