mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 09:07:46 +02:00
Fix worktree auth after instance migration
This commit is contained in:
1 parent
6f416935a0
commit
a3bff6e1ce
6 files changed
+214
-7
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user