mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-06 10:48:12 +02:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Cloud owns human identity and passes a verified identity to each tenant. > - The tenant can report no session while the Cloud session is still valid. > - The access gate and direct `/auth` route then show the instance password form. > - This pull request sends those users through the configured Cloud entry endpoint. > - Cloud can renew the tenant session or show its login page, then return to the original task. ## Linked Issues or Issue Description **What happened?** A Cloud tenant can display the self-hosted email/password form after an instance session check returns no session. This gives Cloud users the wrong login method. **Expected behavior** An active Cloud session renews tenant access automatically. A signed-out user signs in through Cloud. Staging and production use their own configured Cloud origins. Self-hosted instances keep their instance login form. **Steps to reproduce** 1. Open a Cloud tenant task or an `/auth?next=...` link. 2. Keep the Cloud session active but make the instance session check return 401. 3. Observe the instance password form instead of Cloud session recovery. **Deployment mode** Cloud-managed authenticated instances. No database or server API changes. Searched related authentication PRs. Native self-hosted OIDC support in #10411 is a separate feature; this change uses the existing Cloud entry contract. ## What Changed - Wait for deployment metadata before showing an instance login form. - Use the health response's Cloud origin and stack slug for session recovery. - Preserve the tenant path, query, and fragment. Reject external and recursive login return targets. - Limit automatic recovery per tab. Show a manual Cloud retry after failed recovery. Show service failures as errors. - Keep self-hosted login and local trusted access. Add focused tests, browser regressions, deployment documentation, and an unavailable-state design example. ## Verification - `pnpm -r typecheck` and `pnpm build` passed. - UI typecheck and `pnpm check:token-gates` passed after the final UI edits. - All UI tests passed: 639 files, 6,777 tests. - 63 focused Vitest tests passed across Auth, CloudAccessGate, Cloud links, and recovery coordination. - `pnpm exec playwright test --config tests/e2e/playwright.config.ts tests/e2e/cloud-auth.spec.ts`: 5 passed. The tests use the real tenant UI and database with a simulated Cloud HTTP endpoint. They cover both Cloud origins, direct auth/task links, no password-form flash, preserved URLs, reload, and self-hosted login. - Hands-on browser test used the real Cloud gateway and a fresh tenant build with disposable local data. Active Cloud session plus a forced missing instance session returned to the task. An expired tenant cookie also renewed automatically and returned to the task. Removing both sessions reached the real Cloud email/social login UI. Persistent failure stopped at the retry screen; retry succeeded after removing the injected fault. The fixture used a loopback transport adapter and a simulated signed-out OIDC issuer. No production session or deployment was changed. - The default local browser startup hit the host's embedded PostgreSQL resource limit. The passing run used a separate disposable database on the test PostgreSQL process. - The full local `pnpm test:run` sweep was stopped after about 31 minutes once CI completed the full suite. It had reported 33 failures in the unchanged runner API unit/integration files; both files pass in isolation (1,749 + 28 tests). The local sweep did not reach the later workspace/serialized groups. CI completed all of those groups successfully. - Greptile reviewed commit `d603fd4e39455de44da9dae81b72197096c0e1e8` at 5/5 with its only thread resolved. All CI gates are green on this commit, including all general/serialized server groups, workspace tests, Runner checks, typecheck, build, and all eight browser shards ([run](https://github.com/paperclipai/paperclip/actions/runs/36446230697)). ## Risks - Recovery depends on valid Cloud origin and stack metadata. Incomplete metadata shows an unavailable message instead of a password form. - Browsers with session storage disabled use the manual Cloud link, since automatic retries cannot be bounded across documents. - The external identity provider's email/social login was not completed in this local test. Existing Cloud authentication owns that flow. - No migration, credential format, membership rule, or production deployment changes. ## Model Used OpenAI GPT-6 through Codex. The exact served variant and context-window limit are not exposed in this session. Used reasoning, repository tools, code execution, and browser testing. ## 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 searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
65 lines
3.7 KiB
TypeScript
65 lines
3.7 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import { expect, test } from "@playwright/test";
|
|
|
|
// The tenant UI and task database are real. Cloud is an external dependency:
|
|
// simulate its entry endpoint and independent session states at the HTTP edge.
|
|
for (const cloudOrigin of ["https://my.paperclip.app", "https://my-staging.paperclip.app"]) {
|
|
for (const entry of ["auth", "task"] as const) {
|
|
test(`Cloud recovery preserves the task (${cloudOrigin}, ${entry})`, async ({ page, request, baseURL }, testInfo) => {
|
|
const companyResponse = await request.post("/api/companies", { data: { name: `Cloud auth ${randomUUID()}` } });
|
|
expect(companyResponse.ok()).toBe(true);
|
|
const company = await companyResponse.json();
|
|
const issueResponse = await request.post(`/api/companies/${company.id}/issues`, {
|
|
data: { title: "Return here after Cloud sign-in", status: "backlog" },
|
|
});
|
|
expect(issueResponse.ok()).toBe(true);
|
|
const issue = await issueResponse.json();
|
|
const target = `/${company.issuePrefix}/issues/${issue.identifier}?view=activity#recovered`;
|
|
let authenticated = false;
|
|
let handoffs = 0;
|
|
let formWasRendered = false;
|
|
await page.exposeFunction("reportInstanceLoginForm", () => { formWasRendered = true; });
|
|
await page.addInitScript(() => {
|
|
new MutationObserver(() => {
|
|
if (document.querySelector('input[autocomplete="current-password"]')) {
|
|
void (window as unknown as { reportInstanceLoginForm: () => Promise<void> }).reportInstanceLoginForm();
|
|
}
|
|
}).observe(document, { childList: true, subtree: true });
|
|
});
|
|
await page.route("**/api/health", async (route) => {
|
|
const response = await route.fetch();
|
|
await route.fulfill({ response, json: {
|
|
...await response.json(), deploymentMode: "authenticated", bootstrapStatus: "ready",
|
|
cloud: { managed: true, managedBy: "paperclip-cloud", cloudBaseUrl: cloudOrigin, stackSlug: "test-workspace" },
|
|
} });
|
|
});
|
|
await page.route("**/api/auth/get-session", (route) => route.fulfill(authenticated ? {
|
|
json: { session: { id: "test-session", userId: "local-board" }, user: { id: "local-board", email: "test@example.test", name: "Test user", image: null }, sentryDsn: null },
|
|
} : { status: 401, json: { error: "Board authentication required" } }));
|
|
await page.route(`${cloudOrigin}/v1/stacks/test-workspace/entry-redirect?**`, async (route) => {
|
|
handoffs++;
|
|
expect(new URL(route.request().url()).searchParams.get("returnTo")).toBe(target);
|
|
authenticated = true;
|
|
await route.fulfill({ status: 302, headers: { Location: `${baseURL}${target}` } });
|
|
});
|
|
await page.goto(entry === "auth" ? `/auth?next=${encodeURIComponent(target)}` : target);
|
|
await expect(page.getByRole("heading", { name: issue.title, exact: true })).toBeVisible();
|
|
expect(page.url()).toBe(`${baseURL}${target}`);
|
|
expect(handoffs).toBe(1);
|
|
expect(formWasRendered).toBe(false);
|
|
await page.screenshot({ path: testInfo.outputPath("cloud-session-recovered.png") });
|
|
await page.reload();
|
|
await expect(page.getByRole("heading", { name: issue.title, exact: true })).toBeVisible();
|
|
expect(handoffs).toBe(1);
|
|
expect(formWasRendered).toBe(false);
|
|
});
|
|
}
|
|
}
|
|
|
|
test("self-hosted auth still shows the instance sign-in form", async ({ page }) => {
|
|
await page.route("**/api/auth/get-session", (route) => route.fulfill({ status: 401, json: { error: "Board authentication required" } }));
|
|
await page.goto("/auth");
|
|
await expect(page.getByRole("heading", { name: "Sign in to Paperclip", exact: true })).toBeVisible();
|
|
await expect(page.getByLabel("Password", { exact: true })).toBeVisible();
|
|
});
|