Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 44 additions & 1 deletion packages/server/src/__tests__/oauth-bootstrap.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { authIdentities } from "../db/schema/auth-identities.js";
import { members } from "../db/schema/members.js";
import { organizations } from "../db/schema/organizations.js";
import { findOrCreateUserFromExternalAccount } from "../services/auth-identity.js";
import { completeExternalAccountBootstrap } from "../services/oauth-bootstrap.js";
import { completeExternalAccountBootstrap, shouldPreserveSoloSignupNext } from "../services/oauth-bootstrap.js";
import { uuidv7 } from "../uuid.js";
import { useTestApp } from "./helpers.js";

Expand Down Expand Up @@ -105,6 +105,24 @@ describe("provider-neutral OAuth bootstrap", () => {
expect(result).toMatchObject({ joinPath: "solo", next: "/", teamCreated: true });
});

it("preserves a strict Agent Template intent across solo signup", async () => {
const app = getApp();
const account = await findOrCreateUserFromExternalAccount(
app.db,
googleExternalProfile({ sub: "google-template-intent-subject", name: "Template Intent User" }),
);
const next = "/templates/pr-engineer?use=1";

const result = await completeExternalAccountBootstrap(app.db, account, {
next,
allowedOrganizationId: null,
ip: null,
userAgent: null,
});

expect(result).toMatchObject({ joinPath: "solo", next, teamCreated: true });
});

it("serializes concurrent first sign-ins into one personal team graph", async () => {
const app = getApp();
const databaseUrl = process.env.DATABASE_URL ?? "";
Expand Down Expand Up @@ -224,3 +242,28 @@ describe("provider-neutral OAuth bootstrap", () => {
}
});
});

describe("shouldPreserveSoloSignupNext", () => {
it("keeps the known-campaign quickstart next", () => {
expect(
shouldPreserveSoloSignupNext("/quickstart?campaign=production-scan&repo=https%3A%2F%2Fexample.com%2Fr"),
).toBe(true);
});

it("preserves only the strict Template intent URL", () => {
expect(shouldPreserveSoloSignupNext("/templates/pr-engineer?use=1")).toBe(true);
// Invalid slug.
expect(shouldPreserveSoloSignupNext("/templates/PR_Engineer?use=1")).toBe(false);
// Extra query parameters.
expect(shouldPreserveSoloSignupNext("/templates/pr-engineer?use=1&campaign=production-scan")).toBe(false);
// Missing / wrong intent flag.
expect(shouldPreserveSoloSignupNext("/templates/pr-engineer")).toBe(false);
expect(shouldPreserveSoloSignupNext("/templates/pr-engineer?use=0")).toBe(false);
// Fragment.
expect(shouldPreserveSoloSignupNext("/templates/pr-engineer?use=1#details")).toBe(false);
// Anything else, including ordinary deep links.
expect(shouldPreserveSoloSignupNext("/settings/github")).toBe(false);
expect(shouldPreserveSoloSignupNext("/")).toBe(false);
expect(shouldPreserveSoloSignupNext("/templates")).toBe(false);
});
});
7 changes: 6 additions & 1 deletion packages/server/src/services/oauth-bootstrap.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { isKnownLandingCampaignSlug } from "@first-tree/shared";
import { isKnownLandingCampaignSlug, parseAgentTemplateIntentPath } from "@first-tree/shared";
import { eq } from "drizzle-orm";
import type { Database } from "../db/connection.js";
import { users } from "../db/schema/users.js";
Expand Down Expand Up @@ -120,6 +120,11 @@ export async function completeExternalAccountBootstrap(
}

export function shouldPreserveSoloSignupNext(next: string): boolean {
// A canonical Agent Template "use" intent survives solo signup so the new
// member lands back on the Template they picked. Parsing is strict (exact
// pathname, schema slug, sole `use=1` query, no fragment) so this never
// widens into a general deep-link preservation.
if (parseAgentTemplateIntentPath(next) !== null) return true;
const parsed = new URL(next, "http://first-tree.local");
return parsed.pathname === "/quickstart" && isKnownLandingCampaignSlug(parsed.searchParams.get("campaign"));
}
Expand Down
74 changes: 74 additions & 0 deletions packages/shared/src/__tests__/agent-template-intent.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { describe, expect, it } from "vitest";
import { agentTemplateIntentPath, parseAgentTemplateIntentPath } from "../agent-template-intent.js";

describe("agentTemplateIntentPath", () => {
it("builds the canonical intent URL", () => {
expect(agentTemplateIntentPath("pr-engineer")).toBe("/templates/pr-engineer?use=1");
});
});

describe("parseAgentTemplateIntentPath", () => {
it("accepts the strict canonical intent URL", () => {
expect(parseAgentTemplateIntentPath("/templates/pr-engineer?use=1")).toBe("pr-engineer");
});

it("round-trips the builder output", () => {
expect(parseAgentTemplateIntentPath(agentTemplateIntentPath("docs-writer"))).toBe("docs-writer");
});

it("rejects an invalid slug", () => {
expect(parseAgentTemplateIntentPath("/templates/PR_Engineer?use=1")).toBeNull();
expect(parseAgentTemplateIntentPath("/templates/-leading-dash?use=1")).toBeNull();
expect(parseAgentTemplateIntentPath("/templates/?use=1")).toBeNull();
expect(parseAgentTemplateIntentPath(`/templates/${"a".repeat(101)}?use=1`)).toBeNull();
});

it("rejects extra or missing query parameters", () => {
expect(parseAgentTemplateIntentPath("/templates/pr-engineer")).toBeNull();
expect(parseAgentTemplateIntentPath("/templates/pr-engineer?use=0")).toBeNull();
expect(parseAgentTemplateIntentPath("/templates/pr-engineer?use=1&campaign=x")).toBeNull();
expect(parseAgentTemplateIntentPath("/templates/pr-engineer?campaign=x&use=1")).toBeNull();
expect(parseAgentTemplateIntentPath("/templates/pr-engineer?use=1&use=1")).toBeNull();
});

it("rejects a fragment", () => {
expect(parseAgentTemplateIntentPath("/templates/pr-engineer?use=1#section")).toBeNull();
});

it("rejects nested paths and other routes", () => {
expect(parseAgentTemplateIntentPath("/templates/pr-engineer/extra?use=1")).toBeNull();
expect(parseAgentTemplateIntentPath("/quickstart?use=1")).toBeNull();
expect(parseAgentTemplateIntentPath("/")).toBeNull();
});

it("rejects absolute and protocol-relative URLs, including same-origin spellings", () => {
expect(parseAgentTemplateIntentPath("https://evil.example/templates/pr-engineer?use=1")).toBeNull();
expect(parseAgentTemplateIntentPath("//evil.example/templates/pr-engineer?use=1")).toBeNull();
// Same-origin absolute / protocol-relative forms are valid URLs but not
// the canonical relative intent.
expect(parseAgentTemplateIntentPath("http://first-tree.local/templates/pr-engineer?use=1")).toBeNull();
expect(parseAgentTemplateIntentPath("//first-tree.local/templates/pr-engineer?use=1")).toBeNull();
});

it("rejects URL-normalized but non-canonical spellings", () => {
// Trailing separator the URL parser would drop.
expect(parseAgentTemplateIntentPath("/templates/pr-engineer?use=1&")).toBeNull();
// Percent-encoded characters that decode to the same params.
expect(parseAgentTemplateIntentPath("/templates/pr-engineer?%75se=1")).toBeNull();
expect(parseAgentTemplateIntentPath("/templates/%70r-engineer?use=1")).toBeNull();
// Backslash the WHATWG parser would fold into a slash.
expect(parseAgentTemplateIntentPath("/templates\\pr-engineer?use=1")).toBeNull();
// Trailing slash on the path.
expect(parseAgentTemplateIntentPath("/templates/pr-engineer/?use=1")).toBeNull();
});

it("rejects path traversal and encoded separators", () => {
expect(parseAgentTemplateIntentPath("/templates/../settings?use=1")).toBeNull();
expect(parseAgentTemplateIntentPath("/templates/a%2Fb?use=1")).toBeNull();
});

it("never throws on garbage input", () => {
expect(parseAgentTemplateIntentPath("")).toBeNull();
expect(parseAgentTemplateIntentPath("not a url at all:::")).toBeNull();
});
});
56 changes: 56 additions & 0 deletions packages/shared/src/agent-template-intent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { type AgentTemplateSlug, agentTemplateSlugSchema } from "./schemas/agent-template.js";

/**
* Canonical "use this Template" intent URL: `/templates/<slug>?use=1`.
*
* One URL shape is shared by every surface that carries the intent across a
* login round-trip — the public detail CTA, the OAuth `next` parameter, and
* the server's solo-signup `next` preservation (`shouldPreserveSoloSignupNext`).
* This builder is the ONLY producer of a valid intent URL; the parser below
* accepts exactly this output and nothing else.
*/
export function agentTemplateIntentPath(slug: AgentTemplateSlug): string {
return `/templates/${slug}?use=1`;
}

/**
* Parse a candidate redirect target into its Template intent slug, or `null`
* when the value is anything other than the strict canonical intent URL.
*
* Two gates, in order:
* 1. Structural — parses as a same-origin relative URL with an exact
* `/templates/<slug>` pathname, a schema-valid slug, `use=1` as the ONLY
* query parameter, and no fragment.
* 2. Exact-canonical — the RAW input must be byte-identical to the
* builder's output for the extracted slug. This rejects absolute and
* protocol-relative spellings of the same origin, URL-normalized forms
* (trailing `&`, encoded characters, backslashes, duplicate separators),
* and anything else that is merely "equivalent" but not canonical.
*
* Never throws — an unparseable or non-canonical value is simply not an
* intent.
*/
export function parseAgentTemplateIntentPath(next: string): AgentTemplateSlug | null {
let parsed: URL;
try {
parsed = new URL(next, "http://first-tree.local");
} catch {
return null;
}
// Anything that resolved against a different origin (absolute or
// protocol-relative input) is not a same-app relative intent URL.
if (parsed.origin !== "http://first-tree.local") return null;
if (parsed.hash !== "") return null;
const match = /^\/templates\/([^/]+)$/.exec(parsed.pathname);
const slug = match?.[1];
if (!slug) return null;
const params = [...parsed.searchParams.entries()];
if (params.length !== 1 || params[0]?.[0] !== "use" || params[0]?.[1] !== "1") return null;
const validated = agentTemplateSlugSchema.safeParse(slug);
if (!validated.success) return null;
// Only the builder's exact output is an intent — no normalization-tolerant
// spellings, so OAuth `next` preservation can never widen into a general
// deep-link or open-redirect channel.
if (next !== agentTemplateIntentPath(validated.data)) return null;
return validated.data;
}
2 changes: 2 additions & 0 deletions packages/shared/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ export {
type BriefingFingerprint,
findAssembledBriefingFingerprint,
} from "./agent-briefing-guard.js";
// -- Canonical Agent Template "use" intent URL --
export { agentTemplateIntentPath, parseAgentTemplateIntentPath } from "./agent-template-intent.js";
export {
type CanonicalGitRepoIdentity,
CONTEXT_TREE_PROVIDERS,
Expand Down
40 changes: 40 additions & 0 deletions packages/web/src/__tests__/brand-foreground-token.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";

const indexCss = readFileSync(new URL("../index.css", import.meta.url), "utf8");

/**
* Brand-foreground token contract (WCAG AA on the brand green).
*
* The near-white `--fg-on-vivid` measures ~2.2:1 on `--brand`, so brand-filled
* primary actions must use the near-black `--fg-on-brand` instead. This test
* pins the token wiring — the alias, and the `.landing-marketing` scope where
* `--primary` is re-pointed at `--brand` — so a future edit cannot silently
* re-pair a marketing primary action with white-on-green. Real contrast
* verification (axe) stays with the shipped-surface QA gate; this guards the
* semantic binding, not a hardcoded page hex.
*/
describe("brand foreground token contract", () => {
it("defines a dedicated on-brand foreground distinct from --fg-on-vivid", () => {
expect(indexCss).toMatch(/--fg-on-brand:\s*oklch\(0\.17 0 0\)/);
expect(indexCss).toMatch(/--fg-on-vivid:\s*oklch\(0\.985 0 0\)/);
});

it("exposes the brand foreground as a Tailwind color alias", () => {
expect(indexCss).toContain("--color-brand-foreground: var(--fg-on-brand);");
});

it("points the marketing scope's primary-on at the brand foreground", () => {
const marketingScope = indexCss.slice(indexCss.indexOf(".landing-marketing {"));
const primaryOn = marketingScope.indexOf("--primary-on:");
expect(primaryOn).toBeGreaterThan(-1);
const declaration = marketingScope.slice(primaryOn, marketingScope.indexOf(";", primaryOn) + 1);
expect(declaration).toBe("--primary-on: var(--fg-on-brand);");
});

it("keeps --fg-on-vivid available for the other vivid surfaces", () => {
// Avatars/badges/overlays still use the near-white on-color; only the
// brand-green pair moved.
expect(indexCss).toContain("color: var(--fg-on-vivid);");
});
});
17 changes: 16 additions & 1 deletion packages/web/src/api/agent-templates.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,26 @@
import type { AgentResourcesOutput, AgentTemplatePublicList, UpdateAgentTemplates } from "@first-tree/shared";
import type {
AgentResourcesOutput,
AgentTemplatePublicList,
AgentTemplatePublicTemplate,
AgentTemplateSlug,
UpdateAgentTemplates,
} from "@first-tree/shared";
import { api } from "./client.js";

/** Public-safe official Template catalog (no private component data). */
export function listAgentTemplates(): Promise<AgentTemplatePublicList> {
return api.get<AgentTemplatePublicList>("/agent-templates");
}

/**
* Public-safe Template detail by slug. Available anonymously — the shared API
* client simply sends no Authorization header when the visitor is logged out.
* Never returns component payloads, only `AgentTemplatePublicTemplate`.
*/
export function getAgentTemplate(slug: AgentTemplateSlug): Promise<AgentTemplatePublicTemplate> {
return api.get<AgentTemplatePublicTemplate>(`/agent-templates/${encodeURIComponent(slug)}`);
}

/** Full replace-set write of an Agent's adopted Templates. */
export function updateAgentTemplates(agentId: string, body: UpdateAgentTemplates): Promise<AgentResourcesOutput> {
return api.patch<AgentResourcesOutput>(`/agents/${encodeURIComponent(agentId)}/templates`, body);
Expand Down
8 changes: 8 additions & 0 deletions packages/web/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ import { SettingsResourcesPage } from "./pages/settings/resources.js";
import { SettingsSetupPage } from "./pages/settings/setup.js";
import { SettingsLayout } from "./pages/settings.js";
import { TeamPage } from "./pages/team/index.js";
import { TemplateDetailPage } from "./pages/templates/template-detail-page.js";
import { TemplateLibraryPage } from "./pages/templates/template-library-page.js";
import { WorkspacePage } from "./pages/workspace/index.js";

const queryClient = new QueryClient({
Expand Down Expand Up @@ -204,6 +206,12 @@ export function App() {
{/* Public: the connect-code install popup lands here to auto-close. */}
<Route path="/onboarding/connected" element={<GithubConnectedPage />} />
<Route path="/invite/:token" element={<InviteAcceptPage />} />
{/* Public official Template Library + detail. `/templates/:slug?use=1`
is the canonical use-intent URL; the detail page itself routes
logged-out visitors through /login and signed-in members into
onboarding or an explicit Team choice. */}
<Route path="/templates" element={<TemplateLibraryPage />} />
<Route path="/templates/:slug" element={<TemplateDetailPage />} />
<Route path="/m/install" element={<MobileInstallRoute />} />
<Route path="/preview/context-tree-setup" element={<ContextTreeSetupPreviewRoute />} />
{ContextPreviewPage ? (
Expand Down
Loading
Loading