fix(auth): trust allowed hostname port variants on detected listen port (#4554)

## Thinking Path

> - Paperclip is the control plane for autonomous AI companies, so
authenticated board access has to be predictable across local and
worktree deployments.
> - This change sits in the authenticated-mode server startup and Better
Auth origin-trust wiring.
> - The original auth branch fixed one real gap by adding port-qualified
trusted origins for allowed hostnames on non-default ports.
> - Review of that branch found a second-order bug: trusted origins were
still derived from the configured port before startup detected the
actual listen port.
> - In isolated worktrees, that meant a common `3100 -> 3101` port shift
could still leave Better Auth trusting the stale origin.
> - This pull request keeps the original allowed-hostname port-variant
fix, then moves trust derivation onto the resolved listen port and adds
regression coverage around startup wiring.
> - The benefit is that authenticated sessions keep working on allowed
private hostnames even when Paperclip has to auto-shift to a different
local port.

## What Changed

- Added `:port` trusted-origin variants for authenticated-mode
`allowedHostnames` when Paperclip runs on non-default ports.
- Changed authenticated startup so `listenPort` is detected before
Better Auth initialization, and explicit auth base URLs are rewritten
before auth startup.
- Updated `deriveAuthTrustedOrigins()` to accept the resolved listen
port so Better Auth trusts the actual browser origin instead of the
stale configured port.
- Added focused regression coverage in
`server/src/__tests__/better-auth.test.ts` and
`server/src/__tests__/server-startup-feedback-export.test.ts`.

## Verification

- `pnpm exec vitest run server/src/__tests__/better-auth.test.ts
server/src/__tests__/server-startup-feedback-export.test.ts`
- Reviewer re-check: reviewed commits `380f5b9f` and `092bb34c` after
the follow-up fix landed and found no remaining issues.

## Risks

- Low risk: this only affects authenticated-mode origin derivation and
startup ordering around detected listen ports.
- Main behavioral shift: startup no longer mutates `config.port` to the
selected port; it now carries `requestedListenPort` separately and uses
`listenPort` where runtime behavior needs the resolved value.
- If another path was implicitly relying on `config.port` being
overwritten during startup, that path would need follow-up, though the
current startup/test coverage did not reveal one.

> I checked `ROADMAP.md` and did not find an overlapping planned core
work item for this auth trusted-origin port handling fix.

## Model Used

- OpenAI Codex via Paperclip `codex_local` agents for implementation and
review. Exact backend model ID/context window were not surfaced in this
run context; work was performed through the Codex local adapter with
tool use, code execution, and review passes.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] If this change affects the UI, I have included before/after
screenshots
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge
This commit is contained in:
Devin Foley 2026-04-26 15:40:39 -07:00 committed by GitHub
parent 08af830430
commit b2496c8067
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 148 additions and 51 deletions

View file

@ -2,17 +2,50 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
const {
createAppMock,
createBetterAuthInstanceMock,
createDbMock,
detectPortMock,
loadConfigMock,
deriveAuthTrustedOriginsMock,
feedbackExportServiceMock,
feedbackServiceFactoryMock,
fakeServer,
loadConfigMock,
} = vi.hoisted(() => {
const createAppMock = vi.fn(async () => ((_: unknown, __: unknown) => {}) as never);
const createBetterAuthInstanceMock = vi.fn(() => ({}));
const createDbMock = vi.fn(() => ({}) as never);
const detectPortMock = vi.fn(async (port: number) => port);
const loadConfigMock = vi.fn(() => ({
const deriveAuthTrustedOriginsMock = vi.fn(() => []);
const feedbackExportServiceMock = {
flushPendingFeedbackTraces: vi.fn(async () => ({ attempted: 0, sent: 0, failed: 0 })),
};
const feedbackServiceFactoryMock = vi.fn(() => feedbackExportServiceMock);
const fakeServer = {
once: vi.fn().mockReturnThis(),
off: vi.fn().mockReturnThis(),
listen: vi.fn((_port: number, _host: string, callback?: () => void) => {
callback?.();
return fakeServer;
}),
close: vi.fn(),
};
const loadConfigMock = vi.fn();
return {
createAppMock,
createBetterAuthInstanceMock,
createDbMock,
detectPortMock,
deriveAuthTrustedOriginsMock,
feedbackExportServiceMock,
feedbackServiceFactoryMock,
fakeServer,
loadConfigMock,
};
});
function buildTestConfig(overrides: Record<string, unknown> = {}) {
return {
deploymentMode: "authenticated",
deploymentExposure: "private",
bind: "loopback",
@ -48,31 +81,9 @@ const {
heartbeatSchedulerEnabled: false,
heartbeatSchedulerIntervalMs: 30000,
companyDeletionEnabled: false,
}));
const feedbackExportServiceMock = {
flushPendingFeedbackTraces: vi.fn(async () => ({ attempted: 0, sent: 0, failed: 0 })),
...overrides,
};
const feedbackServiceFactoryMock = vi.fn(() => feedbackExportServiceMock);
const fakeServer = {
once: vi.fn().mockReturnThis(),
off: vi.fn().mockReturnThis(),
listen: vi.fn((_port: number, _host: string, callback?: () => void) => {
callback?.();
return fakeServer;
}),
close: vi.fn(),
};
return {
createAppMock,
createDbMock,
detectPortMock,
loadConfigMock,
feedbackExportServiceMock,
feedbackServiceFactoryMock,
fakeServer,
};
});
}
vi.mock("node:http", () => ({
createServer: vi.fn(() => fakeServer),
@ -169,8 +180,8 @@ vi.mock("../board-claim.js", () => ({
vi.mock("../auth/better-auth.js", () => ({
createBetterAuthHandler: vi.fn(() => undefined),
createBetterAuthInstance: vi.fn(() => ({})),
deriveAuthTrustedOrigins: vi.fn(() => []),
createBetterAuthInstance: createBetterAuthInstanceMock,
deriveAuthTrustedOrigins: deriveAuthTrustedOriginsMock,
resolveBetterAuthSession: vi.fn(async () => null),
resolveBetterAuthSessionFromHeaders: vi.fn(async () => null),
}));
@ -180,6 +191,9 @@ import { startServer } from "../index.ts";
describe("startServer feedback export wiring", () => {
beforeEach(() => {
vi.clearAllMocks();
loadConfigMock.mockReturnValue(buildTestConfig());
createBetterAuthInstanceMock.mockReturnValue({});
deriveAuthTrustedOriginsMock.mockReturnValue([]);
process.env.BETTER_AUTH_SECRET = "test-secret";
});
@ -197,9 +211,56 @@ describe("startServer feedback export wiring", () => {
});
});
describe("startServer authenticated auth origin setup", () => {
beforeEach(() => {
vi.clearAllMocks();
loadConfigMock.mockReturnValue(buildTestConfig());
createBetterAuthInstanceMock.mockReturnValue({});
deriveAuthTrustedOriginsMock.mockReturnValue([]);
process.env.BETTER_AUTH_SECRET = "test-secret";
});
it("derives trusted origins from the detected listen port before auth initializes", async () => {
loadConfigMock.mockReturnValue(buildTestConfig({
port: 3210,
allowedHostnames: ["board.example.test"],
authBaseUrlMode: "explicit",
authPublicBaseUrl: "http://127.0.0.1:3210",
}));
detectPortMock.mockResolvedValueOnce(3211);
deriveAuthTrustedOriginsMock.mockImplementation(
(_config: { port: number; authPublicBaseUrl?: string }, opts?: { listenPort?: number }) => [
`http://board.example.test:${opts?.listenPort ?? 0}`,
],
);
await startServer();
expect(deriveAuthTrustedOriginsMock).toHaveBeenCalledWith(
expect.objectContaining({
port: 3210,
authPublicBaseUrl: "http://127.0.0.1:3211/",
}),
{ listenPort: 3211 },
);
expect(createBetterAuthInstanceMock).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
port: 3210,
authPublicBaseUrl: "http://127.0.0.1:3211/",
}),
["http://board.example.test:3211"],
);
expect(createAppMock.mock.calls[0]?.[1]).toMatchObject({
serverPort: 3211,
});
});
});
describe("startServer PAPERCLIP_API_URL handling", () => {
beforeEach(() => {
vi.clearAllMocks();
loadConfigMock.mockReturnValue(buildTestConfig());
process.env.BETTER_AUTH_SECRET = "test-secret";
delete process.env.PAPERCLIP_API_URL;
});
@ -221,12 +282,11 @@ describe("startServer PAPERCLIP_API_URL handling", () => {
});
it("rewrites explicit-port auth public URLs when detect-port selects a new port", async () => {
loadConfigMock.mockReturnValueOnce({
...loadConfigMock(),
loadConfigMock.mockReturnValueOnce(buildTestConfig({
port: 3100,
authBaseUrlMode: "explicit",
authPublicBaseUrl: "http://my-host.ts.net:3100",
});
}));
detectPortMock.mockResolvedValueOnce(3110);
const started = await startServer();
@ -237,12 +297,11 @@ describe("startServer PAPERCLIP_API_URL handling", () => {
});
it("keeps no-port auth public URLs stable when detect-port selects a new port", async () => {
loadConfigMock.mockReturnValueOnce({
...loadConfigMock(),
loadConfigMock.mockReturnValueOnce(buildTestConfig({
port: 3100,
authBaseUrlMode: "explicit",
authPublicBaseUrl: "https://paperclip.example",
});
}));
detectPortMock.mockResolvedValueOnce(3110);
const started = await startServer();