diff --git a/apps/web/src/components/user-menu.tsx b/apps/web/src/components/user-menu.tsx index 0205141..e6281f5 100644 --- a/apps/web/src/components/user-menu.tsx +++ b/apps/web/src/components/user-menu.tsx @@ -1,5 +1,4 @@ -import { authClient } from "@code/auth/web"; -import { api } from "@code/backend/convex/_generated/api"; +import { signOutWeb, useWebAuth } from "@code/auth/web"; import { Button } from "@code/ui/components/button"; import { DropdownMenu, @@ -10,12 +9,13 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "@code/ui/components/dropdown-menu"; -import { useQuery } from "convex/react"; import { useNavigate } from "react-router"; export default function UserMenu() { const navigate = useNavigate(); - const user = useQuery(api.auth.getCurrentUser); + const auth = useWebAuth(); + + const user = auth.status === "authenticated" ? auth.user : null; return ( @@ -30,12 +30,8 @@ export default function UserMenu() { { - authClient.signOut({ - fetchOptions: { - onSuccess: () => { - navigate("/login", { replace: true }); - }, - }, + void signOutWeb().then(() => { + navigate("/login", { replace: true }); }); }} > diff --git a/apps/web/src/hooks/use-personal-organization.test.ts b/apps/web/src/hooks/use-personal-organization.test.ts index 3a1dc23..a601bf4 100644 --- a/apps/web/src/hooks/use-personal-organization.test.ts +++ b/apps/web/src/hooks/use-personal-organization.test.ts @@ -6,18 +6,18 @@ import { usePersonalOrganization } from "./use-personal-organization"; const mocks = vi.hoisted(() => ({ cleanup: undefined as (() => void) | undefined, ensurePersonalOrganization: vi.fn(), - session: { - data: { session: { id: "session-a" } }, - } as { data: { session: { id: string } } | null }, - state: null as { - error?: Error; - organizationId?: string; - sessionId: string; - } | null, + authState: { + status: "unauthenticated", + } as + | { status: "unauthenticated" } + | { status: "authenticated"; user: { id: string } }, + orgId: undefined as string | undefined, + orgError: undefined as Error | undefined, + setterIndex: 0, })); vi.mock("@code/auth/web", () => ({ - authClient: { useSession: () => mocks.session }, + useWebAuth: () => mocks.authState, })); vi.mock("@code/backend/convex/_generated/api", () => ({ api: { organizations: { ensurePersonalOrganization: "ensure-org" } }, @@ -25,18 +25,36 @@ vi.mock("@code/backend/convex/_generated/api", () => ({ vi.mock("convex/react", () => ({ useMutation: () => mocks.ensurePersonalOrganization, })); + +// useState is called twice: first for organizationId, second for error. +// We give each its own state cell so the test can read them independently. +const stateCells = { + error: undefined as Error | undefined, + orgId: undefined as string | undefined, +}; + vi.mock("react", () => ({ useEffect: (effect: EffectCallback) => { mocks.cleanup?.(); const cleanup = effect(); mocks.cleanup = typeof cleanup === "function" ? cleanup : undefined; }, - useState: () => [ - mocks.state, - (next: { error?: Error; organizationId?: string; sessionId: string }) => { - mocks.state = next; - }, - ], + useState: (initial: T) => { + const callIndex = mocks.setterIndex; + mocks.setterIndex = (mocks.setterIndex + 1) % 2; + const cellKey = callIndex === 0 ? "orgId" : "error"; + const current = + cellKey === "orgId" ? stateCells.orgId : (stateCells.error as T); + const value = current === undefined ? initial : current; + const setter = (next: T) => { + if (cellKey === "orgId") { + stateCells.orgId = next as string | undefined; + } else { + stateCells.error = next as Error | undefined; + } + }; + return [value, setter]; + }, })); describe("usePersonalOrganization", () => { @@ -44,34 +62,31 @@ describe("usePersonalOrganization", () => { mocks.cleanup?.(); mocks.cleanup = undefined; mocks.ensurePersonalOrganization.mockReset(); - mocks.session = { data: { session: { id: "session-a" } } }; - mocks.state = null; + mocks.authState = { status: "unauthenticated" }; + mocks.setterIndex = 0; + stateCells.error = undefined; + stateCells.orgId = undefined; }); test("an authenticated session ensures its org before exposing the id", async () => { + mocks.authState = { status: "authenticated", user: { id: "user-a" } }; const ensured = Promise.withResolvers<{ _id: string }>(); mocks.ensurePersonalOrganization.mockReturnValue(ensured.promise); - expect(usePersonalOrganization()).toEqual({}); + usePersonalOrganization(); expect(mocks.ensurePersonalOrganization).toHaveBeenCalledTimes(1); ensured.resolve({ _id: "org-a" }); await ensured.promise; await Promise.resolve(); - expect(usePersonalOrganization()).toEqual({ - error: undefined, - organizationId: "org-a", - }); + expect(stateCells.orgId).toBe("org-a"); }); - test("logout cannot expose the prior session organization", async () => { - mocks.ensurePersonalOrganization.mockResolvedValue({ _id: "org-a" }); - usePersonalOrganization(); - await Promise.resolve(); - await Promise.resolve(); + test("an unauthenticated session never ensures an org", () => { + mocks.authState = { status: "unauthenticated" }; - mocks.session = { data: null }; expect(usePersonalOrganization()).toEqual({}); + expect(mocks.ensurePersonalOrganization).not.toHaveBeenCalled(); }); }); diff --git a/apps/web/src/hooks/use-personal-organization.ts b/apps/web/src/hooks/use-personal-organization.ts index 67bd811..b25fd1d 100644 --- a/apps/web/src/hooks/use-personal-organization.ts +++ b/apps/web/src/hooks/use-personal-organization.ts @@ -1,33 +1,30 @@ -import { authClient } from "@code/auth/web"; +import { useWebAuth } from "@code/auth/web"; import { api } from "@code/backend/convex/_generated/api"; import type { Id } from "@code/backend/convex/_generated/dataModel"; import { useMutation } from "convex/react"; import { useEffect, useState } from "react"; -interface OrganizationBootstrapState { - readonly error?: Error; - readonly organizationId?: Id<"organizations">; - readonly sessionId: string; -} - export interface PersonalOrganizationState { readonly error?: Error; readonly organizationId?: Id<"organizations">; } -/** Ensure the authenticated session has its personal tenancy boundary. */ +/** Ensure the authenticated user has its personal tenancy boundary. */ export const usePersonalOrganization = (): PersonalOrganizationState => { - const { data: session } = authClient.useSession(); - const sessionId = session?.session.id; + const auth = useWebAuth(); + const userId = auth.status === "authenticated" ? auth.user.id : null; const ensurePersonalOrganization = useMutation( api.organizations.ensurePersonalOrganization ); - const [bootstrap, setBootstrap] = useState( - null - ); + const [organizationId, setOrganizationId] = useState< + Id<"organizations"> | undefined + >(); + const [error, setError] = useState(); useEffect(() => { - if (!sessionId) { + if (!userId) { + setOrganizationId(undefined); + setError(undefined); return; } @@ -36,20 +33,16 @@ export const usePersonalOrganization = (): PersonalOrganizationState => { try { const organization = await ensurePersonalOrganization(); if (active) { - setBootstrap({ - organizationId: organization._id, - sessionId, - }); + setOrganizationId(organization._id); + setError(undefined); } } catch (caughtError: unknown) { if (active) { - setBootstrap({ - error: - caughtError instanceof Error - ? caughtError - : new Error(String(caughtError)), - sessionId, - }); + setError( + caughtError instanceof Error + ? caughtError + : new Error(String(caughtError)) + ); } } }; @@ -58,13 +51,11 @@ export const usePersonalOrganization = (): PersonalOrganizationState => { return () => { active = false; }; - }, [ensurePersonalOrganization, sessionId]); + }, [ensurePersonalOrganization, userId]); - if (!sessionId || bootstrap?.sessionId !== sessionId) { + if (!userId) { return {}; } - return { - error: bootstrap.error, - organizationId: bootstrap.organizationId, - }; + + return { error, organizationId }; }; diff --git a/apps/web/src/routes/_app.tsx b/apps/web/src/routes/_app.tsx index 62cc28c..495893d 100644 --- a/apps/web/src/routes/_app.tsx +++ b/apps/web/src/routes/_app.tsx @@ -1,15 +1,23 @@ -import { useConvexAuth } from "convex/react"; +import { useWebAuth } from "@code/auth/web"; import { Navigate, Outlet, useLocation } from "react-router"; export default function AppLayout() { - const { isAuthenticated, isLoading } = useConvexAuth(); + const auth = useWebAuth(); const location = useLocation(); - if (isLoading) { + if (auth.status === "loading") { return
Loading…
; } - if (!isAuthenticated) { + if (auth.status === "inconsistent") { + return ( +
+ Your session could not be verified +
+ ); + } + + if (auth.status === "unauthenticated") { return ; } diff --git a/apps/web/src/routes/_auth.tsx b/apps/web/src/routes/_auth.tsx index 4aa4173..a3b472b 100644 --- a/apps/web/src/routes/_auth.tsx +++ b/apps/web/src/routes/_auth.tsx @@ -1,14 +1,14 @@ -import { useConvexAuth } from "convex/react"; +import { useWebAuth } from "@code/auth/web"; import { Navigate, Outlet } from "react-router"; export default function AuthLayout() { - const { isAuthenticated, isLoading } = useConvexAuth(); + const auth = useWebAuth(); - if (isLoading) { + if (auth.status === "loading") { return
Loading…
; } - if (isAuthenticated) return ; + if (auth.status === "authenticated") return ; return (
diff --git a/packages/auth/package.json b/packages/auth/package.json index ce61425..6b8b1b5 100644 --- a/packages/auth/package.json +++ b/packages/auth/package.json @@ -5,7 +5,6 @@ "type": "module", "exports": { "./native": "./src/native/index.ts", - "./server": "./src/server/index.ts", "./web": "./src/web/index.ts" }, "scripts": { diff --git a/packages/auth/src/server/index.ts b/packages/auth/src/server/index.ts deleted file mode 100644 index 177ebdc..0000000 --- a/packages/auth/src/server/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { authComponent, createAuth } from "@code/backend/convex/auth"; diff --git a/packages/auth/src/web/auth-provider.tsx b/packages/auth/src/web/auth-provider.tsx index 86e47d5..457ee24 100644 --- a/packages/auth/src/web/auth-provider.tsx +++ b/packages/auth/src/web/auth-provider.tsx @@ -5,7 +5,7 @@ import type { ReactNode } from "react"; import { authClient } from "./auth-client"; -const convex = new ConvexReactClient(env.VITE_CONVEX_URL); +const convex = new ConvexReactClient(env.VITE_CONVEX_URL, { expectAuth: true }); export const WebAuthProvider = ({ children }: { children: ReactNode }) => ( diff --git a/packages/auth/src/web/index.ts b/packages/auth/src/web/index.ts index 165cedf..abc2d17 100644 --- a/packages/auth/src/web/index.ts +++ b/packages/auth/src/web/index.ts @@ -1,5 +1,7 @@ export { authClient } from "./auth-client"; export { WebAuthProvider } from "./auth-provider"; export { useConvexAccessToken } from "./hooks"; +export { signOutWeb, useWebAuth } from "./use-web-auth"; +export type { WebAuthState, WebAuthUser } from "./use-web-auth"; export { LoginForm } from "./login-form"; export { SignupForm } from "./signup-form"; diff --git a/packages/auth/src/web/use-web-auth.ts b/packages/auth/src/web/use-web-auth.ts new file mode 100644 index 0000000..31161d3 --- /dev/null +++ b/packages/auth/src/web/use-web-auth.ts @@ -0,0 +1,81 @@ +import { api } from "@code/backend/convex/_generated/api"; +import { useEffect, useRef } from "react"; +import { useConvexAuth, useQuery } from "convex/react"; + +import { authClient } from "./auth-client"; + +export interface WebAuthUser { + readonly id: string; + readonly name: string; + readonly email: string; +} + +export type WebAuthState = + | { readonly status: "loading" } + | { readonly status: "unauthenticated" } + | { readonly status: "authenticated"; readonly user: WebAuthUser } + | { readonly status: "inconsistent" }; + +/** + * Sign out of the web session. Clears the Better Auth session, which in turn + * invalidates the cached Convex JWT (the session-token resolver keys on the + * session id). Protected Convex queries after sign-out are denied. + */ +export const signOutWeb = async (): Promise => { + await authClient.signOut({ fetchOptions: { throw: false } }); +}; + +/** + * The single web-facing auth interface. Combines Convex auth state with the + * normalized {@link api.auth.getCurrentUser} projection, skipping the user + * query until Convex has authenticated. Reports `inconsistent` only when a JWT + * is accepted but the Better Auth user row cannot be resolved. + */ +export const useWebAuth = (): WebAuthState => { + const { isAuthenticated, isLoading } = useConvexAuth(); + + // Skip the user query until Convex has accepted the JWT. This keeps the + // status `loading` during token restoration and `unauthenticated` when no + // session exists, without firing a query that would return null. + const userQuery = useQuery( + api.auth.getCurrentUser, + isAuthenticated ? {} : "skip" + ); + + const signOutWebRef = useRef(signOutWeb); + const inconsistent = + isAuthenticated && userQuery !== undefined && userQuery === null; + + // If Convex accepted the JWT but the Better Auth user row cannot be resolved, + // the session is inconsistent: sign out so the next sign-in starts clean. + useEffect(() => { + if (inconsistent) { + void signOutWebRef.current(); + } + }, [inconsistent]); + + if (isLoading) { + return { status: "loading" }; + } + + if (!isAuthenticated) { + return { status: "unauthenticated" }; + } + + if (userQuery === undefined) { + return { status: "loading" }; + } + + if (userQuery === null) { + return { status: "inconsistent" }; + } + + return { + status: "authenticated", + user: { + email: userQuery.email, + id: userQuery.id, + name: userQuery.name, + }, + }; +}; diff --git a/packages/backend/convex/auth.ts b/packages/backend/convex/auth.ts index 3fc3c2f..bd2829b 100644 --- a/packages/backend/convex/auth.ts +++ b/packages/backend/convex/auth.ts @@ -36,7 +36,33 @@ const createAuth = (ctx: GenericCtx) => export { createAuth }; +export interface AuthUserView { + readonly id: string; + readonly name: string; + readonly email: string; +} + +/** + * Resolve the authenticated user to the canonical web view. The returned `id` + * is the Convex `identity.tokenIdentifier`, the stable authenticated identity + * used across organization/conversation/Signal ownership. Returns null when no + * identity is present or the Better Auth user row cannot be resolved. + */ export const getCurrentUser = query({ args: {}, - handler: (ctx) => authComponent.safeGetAuthUser(ctx), + handler: async (ctx): Promise => { + const identity = await ctx.auth.getUserIdentity(); + if (!identity) { + return null; + } + const authUser = await authComponent.safeGetAuthUser(ctx); + if (!authUser) { + return null; + } + return { + email: authUser.email, + id: identity.tokenIdentifier, + name: authUser.name, + }; + }, }); diff --git a/packages/backend/convex/authz.ts b/packages/backend/convex/authz.ts index 24c2642..5a28f0a 100644 --- a/packages/backend/convex/authz.ts +++ b/packages/backend/convex/authz.ts @@ -11,7 +11,7 @@ export interface AuthContext { }; } -export const requireOwnerId = async (ctx: AuthContext): Promise => { +export const requireAuthUserId = async (ctx: AuthContext): Promise => { const identity = await ctx.auth.getUserIdentity(); if (!identity) { throw new ConvexError("Authentication required"); @@ -28,7 +28,7 @@ export const requireOrganizationMember = async ( ctx: QueryCtx | MutationCtx, organizationId: Id<"organizations"> ): Promise => { - const userId = await requireOwnerId(ctx); + const userId = await requireAuthUserId(ctx); const membership = await ctx.db .query("organizationMembers") .withIndex("by_organizationId_and_userId", (q) => diff --git a/packages/backend/convex/organizations.ts b/packages/backend/convex/organizations.ts index f5779c4..ff13d14 100644 --- a/packages/backend/convex/organizations.ts +++ b/packages/backend/convex/organizations.ts @@ -1,6 +1,6 @@ import type { Doc, Id } from "./_generated/dataModel"; import { mutation, query } from "./_generated/server"; -import { requireOwnerId } from "./authz"; +import { requireAuthUserId } from "./authz"; interface OrganizationView { readonly _id: Id<"organizations">; @@ -31,7 +31,7 @@ const toView = (org: Doc<"organizations">): OrganizationView => ({ export const ensurePersonalOrganization = mutation({ args: {}, handler: async (ctx): Promise => { - const ownerId = await requireOwnerId(ctx); + const ownerId = await requireAuthUserId(ctx); const existing = await ctx.db .query("organizations") .withIndex("by_createdBy_and_kind", (q) => @@ -70,7 +70,7 @@ export const ensurePersonalOrganization = mutation({ export const getCurrent = query({ args: {}, handler: async (ctx): Promise => { - const ownerId = await requireOwnerId(ctx); + const ownerId = await requireAuthUserId(ctx); const existing = await ctx.db .query("organizations") .withIndex("by_createdBy_and_kind", (q) => diff --git a/packages/backend/convex/projectArtifacts.ts b/packages/backend/convex/projectArtifacts.ts index 22c865c..e2ddc74 100644 --- a/packages/backend/convex/projectArtifacts.ts +++ b/packages/backend/convex/projectArtifacts.ts @@ -1,12 +1,12 @@ import { v } from "convex/values"; import { query } from "./_generated/server"; -import { requireOwnerId } from "./authz"; +import { requireAuthUserId } from "./authz" export const list = query({ args: { projectId: v.id("projects") }, handler: async (ctx, args) => { - const ownerId = await requireOwnerId(ctx); + const ownerId = await requireAuthUserId(ctx); const project = await ctx.db.get("projects", args.projectId); if (!project || project.ownerId !== ownerId) { return []; diff --git a/packages/backend/convex/projectIssues.ts b/packages/backend/convex/projectIssues.ts index bf32afa..f779191 100644 --- a/packages/backend/convex/projectIssues.ts +++ b/packages/backend/convex/projectIssues.ts @@ -3,7 +3,7 @@ import { ConvexError, v } from "convex/values"; import type { Id } from "./_generated/dataModel"; import { mutation, query } from "./_generated/server"; import type { MutationCtx } from "./_generated/server"; -import { requireOwnerId } from "./authz"; +import { requireAuthUserId } from "./authz" const requireProjectOwner = async ( ctx: MutationCtx, @@ -24,7 +24,7 @@ export const create = mutation({ title: v.string(), }, handler: async (ctx, args) => { - const ownerId = await requireOwnerId(ctx); + const ownerId = await requireAuthUserId(ctx); await requireProjectOwner(ctx, args.projectId, ownerId); const title = args.title.trim(); const body = args.body.trim(); @@ -81,7 +81,7 @@ export const create = mutation({ export const begin = mutation({ args: { issueId: v.id("projectIssues") }, handler: async (ctx, args) => { - const ownerId = await requireOwnerId(ctx); + const ownerId = await requireAuthUserId(ctx); const issue = await ctx.db.get("projectIssues", args.issueId); if (!issue) { throw new ConvexError("Issue not found"); @@ -109,7 +109,7 @@ export const begin = mutation({ export const markDispatchFailed = mutation({ args: { error: v.string(), issueId: v.id("projectIssues") }, handler: async (ctx, args) => { - const ownerId = await requireOwnerId(ctx); + const ownerId = await requireAuthUserId(ctx); const issue = await ctx.db.get("projectIssues", args.issueId); if (!issue) { throw new ConvexError("Issue not found"); @@ -133,7 +133,7 @@ export const markDispatchFailed = mutation({ export const list = query({ args: { projectId: v.id("projects") }, handler: async (ctx, args) => { - const ownerId = await requireOwnerId(ctx); + const ownerId = await requireAuthUserId(ctx); const project = await ctx.db.get("projects", args.projectId); if (!project || project.ownerId !== ownerId) { return []; diff --git a/packages/backend/convex/projects.ts b/packages/backend/convex/projects.ts index 5dbb273..573eec4 100644 --- a/packages/backend/convex/projects.ts +++ b/packages/backend/convex/projects.ts @@ -5,7 +5,7 @@ import { internal } from "./_generated/api"; import type { Id } from "./_generated/dataModel"; import { action, internalMutation, query } from "./_generated/server"; import { createInitialArtifacts } from "./artifactModel"; -import { requireOwnerId } from "./authz"; +import { requireAuthUserId } from "./authz" const gitHubRepositorySchema = z.object({ default_branch: z.string(), @@ -47,7 +47,7 @@ const readGitHubRepository = (value: unknown) => { export const connectGitHub = action({ args: { repository: v.string() }, handler: async (ctx, args): Promise> => { - const ownerId = await requireOwnerId(ctx); + const ownerId = await requireAuthUserId(ctx); const { owner, repo } = parseRepository(args.repository); const response = await fetch( `https://api.github.com/repos/${owner}/${repo}`, @@ -157,7 +157,7 @@ export const storeGitHubProject = internalMutation({ export const list = query({ args: {}, handler: async (ctx) => { - const ownerId = await requireOwnerId(ctx); + const ownerId = await requireAuthUserId(ctx); return await ctx.db .query("projects") .withIndex("by_owner_and_createdAt", (q) => q.eq("ownerId", ownerId)) @@ -169,7 +169,7 @@ export const list = query({ export const get = query({ args: { projectId: v.id("projects") }, handler: async (ctx, args) => { - const ownerId = await requireOwnerId(ctx); + const ownerId = await requireAuthUserId(ctx); const project = await ctx.db.get("projects", args.projectId); return project?.ownerId === ownerId ? project : null; }, diff --git a/packages/primitives/package.json b/packages/primitives/package.json index 0781e87..7b3f661 100644 --- a/packages/primitives/package.json +++ b/packages/primitives/package.json @@ -6,6 +6,7 @@ "exports": { ".": "./src/index.ts", "./agent-os": "./src/agent-os.ts", + "./project": "./src/project.ts", "./signal": "./src/signal.ts" }, "scripts": { diff --git a/packages/primitives/src/index.ts b/packages/primitives/src/index.ts index 7228130..d252f41 100644 --- a/packages/primitives/src/index.ts +++ b/packages/primitives/src/index.ts @@ -1,3 +1,4 @@ // oxlint-disable-next-line no-barrel-file -- The package root intentionally exposes its public modules. export * from "./agent-os"; +export * from "./project"; export * from "./signal"; diff --git a/packages/primitives/src/project.test.ts b/packages/primitives/src/project.test.ts new file mode 100644 index 0000000..e9578fb --- /dev/null +++ b/packages/primitives/src/project.test.ts @@ -0,0 +1,857 @@ +import { Effect, Layer } from "effect"; +import { describe, expect, it } from "vitest"; + +import { + CONTEXT_KINDS, + decideContextWrite, + makeInitialContext, + preparePublicGitSource, + ProjectApplication, + ProjectApplicationError, + type ProjectImportOutcome, + ProjectDomain, + type ProjectView, + PublicGit, + PublicGitError, + type PublicGitImportResult, + ProjectStore, + type PublicTextResult, + type ContextDocumentState, + type ContextKind, + type ContextWrite, + type PreparedPublicGitSource, +} from "./project"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const expectError = async ( + effect: Effect.Effect +): Promise => { + try { + await Effect.runPromise(effect as never); + throw new Error("Expected failure but got success"); + } catch (caught) { + if (caught instanceof Error && caught.message === "Expected failure but got success") { + throw caught; + } + return caught as E; + } +}; + +// --------------------------------------------------------------------------- +// In-memory ProjectStore +// --------------------------------------------------------------------------- + +interface InMemoryProject { + readonly id: string; + readonly organizationId: string; + readonly name: string; + readonly source: { + readonly kind: "git"; + readonly url: string; + readonly normalizedUrl: string; + readonly host: string; + readonly repositoryPath: string; + readonly defaultBranch?: string; + }; + documents: Map; + createdAt: number; + updatedAt: number; +} + +const decideWriteSync = ( + existing: ContextDocumentState | undefined, + write: ContextWrite +): { document: ContextDocumentState; changed: boolean } => { + const pathMap: Record = { + agents: "AGENTS.md", + business: "business.md", + design: "design.md", + product: "product.md", + readme: "README.md", + tech: "tech.md", + }; + const origin = + write._tag === "Repository" + ? "repository" + : write._tag === "PublicTextUrl" + ? "public-url" + : write.origin; + const sourceUrl = write._tag === "UserText" ? undefined : write.sourceUrl; + + if ( + existing && + existing.content === write.content && + existing.origin === origin && + existing.sourceUrl === sourceUrl + ) { + return { changed: false, document: existing }; + } + return { + changed: true, + document: { + content: write.content, + kind: write.kind, + origin, + path: pathMap[write.kind], + revision: (existing?.revision ?? 0) + 1, + sourceUrl, + }, + }; +}; + +const createInMemoryStoreLayer = () => { + const projects = new Map(); + const orgByUser = new Map(); + let nextId = 1; + + const toView = (p: InMemoryProject): ProjectView => ({ + contextDocuments: [...p.documents.values()], + createdAt: p.createdAt as never, + id: p.id as never, + name: p.name, + organizationId: p.organizationId as never, + sources: [ + { + createdAt: p.createdAt as never, + defaultBranch: p.source.defaultBranch, + host: p.source.host, + kind: "git", + normalizedUrl: p.source.normalizedUrl, + projectId: p.id as never, + repositoryPath: p.source.repositoryPath, + updatedAt: p.updatedAt as never, + url: p.source.url, + }, + ], + updatedAt: p.updatedAt as never, + }); + + const toOutcome = (p: InMemoryProject): ProjectImportOutcome => ({ + contextDocuments: [...p.documents.values()], + createdAt: p.createdAt as never, + id: p.id as never, + name: p.name, + organizationId: p.organizationId as never, + source: { + createdAt: p.createdAt as never, + defaultBranch: p.source.defaultBranch, + host: p.source.host, + kind: "git", + normalizedUrl: p.source.normalizedUrl, + projectId: p.id as never, + repositoryPath: p.source.repositoryPath, + updatedAt: p.updatedAt as never, + url: p.source.url, + }, + updatedAt: p.updatedAt as never, + }); + + return Layer.succeed( + ProjectStore, + ProjectStore.of({ + getCurrentOrganization: (userId) => + Effect.succeed((orgByUser.get(userId) ?? `org-${userId}`) as never), + listProjects: (userId) => + Effect.succeed( + (() => { + const orgId = orgByUser.get(userId); + return orgId + ? [...projects.values()] + .filter((p) => p.organizationId === orgId) + .map(toView) + : ([] as readonly ProjectView[]); + })() + ), + getProject: (userId, projectId) => + Effect.succeed( + (() => { + const orgId = orgByUser.get(userId); + const project = projects.get(projectId as string); + if (!project || project.organizationId !== orgId) return null; + return toView(project); + })() + ), + persistPublicGitImport: (input) => + Effect.sync(() => { + const orgId = orgByUser.get(input.userId) ?? `org-${input.userId}`; + orgByUser.set(input.userId, orgId); + + const existing = [...projects.values()].find( + (p) => + p.organizationId === orgId && + p.source.normalizedUrl === input.source.normalizedUrl + ); + + const now = Date.now(); + if (existing) { + existing.updatedAt = now; + for (const doc of input.remote.documents) { + const result = decideWriteSync(existing.documents.get(doc.kind), { + _tag: "Repository" as const, + content: doc.content, + kind: doc.kind, + sourceUrl: input.source.url, + }); + if (result.changed) + existing.documents.set(doc.kind, result.document); + } + return toOutcome(existing); + } + + const id = `project-${nextId++}`; + const documents = new Map(); + for (const seed of makeInitialContext( + input.source.projectName, + input.source.url + )) { + documents.set(seed.kind, seed); + } + for (const doc of input.remote.documents) { + const result = decideWriteSync(documents.get(doc.kind), { + _tag: "Repository" as const, + content: doc.content, + kind: doc.kind, + sourceUrl: input.source.url, + }); + if (result.changed) documents.set(doc.kind, result.document); + } + + const project: InMemoryProject = { + createdAt: now, + documents, + id, + name: input.source.projectName, + organizationId: orgId, + source: { + defaultBranch: input.remote.defaultBranch, + host: input.source.host, + kind: "git", + normalizedUrl: input.source.normalizedUrl, + repositoryPath: input.source.repositoryPath, + url: input.source.url, + }, + updatedAt: now, + }; + projects.set(id, project); + return toOutcome(project); + }), + putContext: (input) => + Effect.sync(() => { + const orgId = orgByUser.get(input.userId); + const project = projects.get(input.projectId as string); + if (!project || project.organizationId !== orgId) { + throw new Error("Project not found"); + } + const result = decideWriteSync( + project.documents.get(input.write.kind), + input.write + ); + if (result.changed) { + project.documents.set(input.write.kind, result.document); + project.updatedAt = Date.now(); + } + return { revision: result.document.revision }; + }), + }) + ); +}; + +// --------------------------------------------------------------------------- +// Fake PublicGit layer +// --------------------------------------------------------------------------- + +const makeFakePublicGitLayer = ( + inspectImpl: ( + source: PreparedPublicGitSource + ) => PublicGitImportResult | PublicGitError, + fetchImpl: (url: string) => PublicTextResult | PublicGitError +) => + Layer.succeed( + PublicGit, + PublicGit.of({ + inspect: (source) => + Effect.gen(function* () { + const result = inspectImpl(source); + if (result instanceof PublicGitError) { + return yield* Effect.fail(result); + } + return result; + }), + fetchText: (url) => + Effect.gen(function* () { + const result = fetchImpl(url); + if (result instanceof PublicGitError) { + return yield* Effect.fail(result); + } + return result; + }), + }) + ); + +const HELLO_WORLD_RESULT: PublicGitImportResult = { + defaultBranch: "main", + documents: [ + { + kind: "readme", + path: "README.md", + content: "# Hello World\n\nA test repository.\n", + }, + ], + warnings: [], +}; + +// --------------------------------------------------------------------------- +// Test layer +// --------------------------------------------------------------------------- + +const makeTestLayer = ( + fakePublicGit: ReturnType +) => + ProjectApplication.layer.pipe( + Layer.provide( + Layer.mergeAll(ProjectDomain.layer, fakePublicGit, createInMemoryStoreLayer()) + ) + ); + +const runApp = ( + effect: Effect.Effect, + layer: Layer.Layer +): Promise => Effect.runPromise(Effect.provide(effect, layer)); + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("preparePublicGitSource", () => { + it("normalizes a GitHub URL with .git suffix", async () => { + const result = await Effect.runPromise( + preparePublicGitSource("https://github.com/octocat/Hello-World.git") + ); + expect(result.host).toBe("github.com"); + expect(result.repositoryPath).toBe("octocat/Hello-World"); + expect(result.projectName).toBe("Hello-World"); + expect(result.normalizedUrl).toBe("https://github.com/octocat/Hello-World"); + }); + + it("normalizes a GitLab URL", async () => { + const result = await Effect.runPromise( + preparePublicGitSource("https://gitlab.com/gitlab-org/gitlab-test.git") + ); + expect(result.host).toBe("gitlab.com"); + expect(result.repositoryPath).toBe("gitlab-org/gitlab-test"); + }); + + it("normalizes a Codeberg URL", async () => { + const result = await Effect.runPromise( + preparePublicGitSource("https://codeberg.org/Codeberg/Documentation.git") + ); + expect(result.host).toBe("codeberg.org"); + expect(result.repositoryPath).toBe("Codeberg/Documentation"); + }); + + it("normalizes a generic HTTP(S) host without .git", async () => { + const result = await Effect.runPromise( + preparePublicGitSource("https://example.com/foo/bar") + ); + expect(result.repositoryPath).toBe("foo/bar"); + expect(result.projectName).toBe("bar"); + }); + + it("rejects SSH URLs", async () => { + const error = await expectError( + preparePublicGitSource("git@github.com:octocat/Hello-World.git") + ); + expect(error).toBeInstanceOf(ProjectApplicationError); + expect((error as ProjectApplicationError).reason).toBe( + "InvalidPublicGitUrl" + ); + }); + + it("rejects URLs with credentials", async () => { + const error = await expectError( + preparePublicGitSource( + "https://user:pass@github.com/octocat/Hello-World.git" + ) + ); + expect((error as ProjectApplicationError).reason).toBe( + "InvalidPublicGitUrl" + ); + }); + + it("rejects URLs with query strings", async () => { + const error = await expectError( + preparePublicGitSource( + "https://github.com/octocat/Hello-World.git?branch=dev" + ) + ); + expect((error as ProjectApplicationError).reason).toBe( + "InvalidPublicGitUrl" + ); + }); + + it("rejects URLs with fragments", async () => { + const error = await expectError( + preparePublicGitSource("https://github.com/octocat/Hello-World.git#readme") + ); + expect((error as ProjectApplicationError).reason).toBe( + "InvalidPublicGitUrl" + ); + }); + + it("rejects root-only paths", async () => { + const error = await expectError( + preparePublicGitSource("https://github.com/") + ); + expect((error as ProjectApplicationError).reason).toBe( + "InvalidPublicGitUrl" + ); + }); +}); + +describe("makeInitialContext", () => { + it("produces exactly six canonical seeds", () => { + const seeds = makeInitialContext("test-repo", "https://example.com/foo/bar"); + expect(seeds).toHaveLength(6); + const kinds = seeds.map((s) => s.kind).sort(); + expect(kinds).toEqual([...CONTEXT_KINDS].sort()); + }); + + it("seeds README with project name and repository URL", () => { + const seeds = makeInitialContext("my-project", "https://example.com/foo/bar"); + const readme = seeds.find((s) => s.kind === "readme")!; + expect(readme.content).toBe( + "# my-project\n\nRepository: https://example.com/foo/bar\n" + ); + }); + + it("seeds placeholders with exact title and scaffold text", () => { + const seeds = makeInitialContext("test", "https://example.com/foo/bar"); + const product = seeds.find((s) => s.kind === "product")!; + expect(product.content).toBe("# product.md\n\nContext not imported yet.\n"); + }); + + it("all seeds start at revision 1", () => { + const seeds = makeInitialContext("test", "https://example.com/foo/bar"); + for (const seed of seeds) { + expect(seed.revision).toBe(1); + } + }); +}); + +describe("decideContextWrite", () => { + it("increments revision on content change", async () => { + const existing: ContextDocumentState = { + content: "old", + kind: "product", + origin: "paste", + path: "product.md", + revision: 1, + sourceUrl: undefined, + }; + const result = await Effect.runPromise( + decideContextWrite({ + existing, + write: { + _tag: "UserText", + content: "new content", + kind: "product", + origin: "paste", + }, + }) + ); + expect(result.changed).toBe(true); + expect(result.document.revision).toBe(2); + expect(result.document.content).toBe("new content"); + }); + + it("returns no-op when content/origin/source are identical", async () => { + const existing: ContextDocumentState = { + content: "same", + kind: "product", + origin: "paste", + path: "product.md", + revision: 5, + sourceUrl: undefined, + }; + const result = await Effect.runPromise( + decideContextWrite({ + existing, + write: { + _tag: "UserText", + content: "same", + kind: "product", + origin: "paste", + }, + }) + ); + expect(result.changed).toBe(false); + expect(result.document).toBe(existing); + }); + + it("preserves exact text without trimming", async () => { + const result = await Effect.runPromise( + decideContextWrite({ + existing: undefined, + write: { + _tag: "UserText", + content: " trailing spaces \n\n", + kind: "tech", + origin: "paste", + }, + }) + ); + expect(result.document.content).toBe(" trailing spaces \n\n"); + }); +}); + +describe("ProjectApplication.importPublicGit", () => { + it("imports a repository with six canonical context documents", async () => { + const layer = makeTestLayer( + makeFakePublicGitLayer( + () => HELLO_WORLD_RESULT, + () => ({ content: "", finalUrl: "" }) + ) + ); + const outcome = await runApp( + Effect.gen(function* () { + const a = yield* ProjectApplication; + return yield* a.importPublicGit({ + userId: "user-a", + repositoryUrl: "https://github.com/octocat/Hello-World.git", + }); + }), + layer + ); + expect(outcome.name).toBe("Hello-World"); + expect(outcome.source.host).toBe("github.com"); + expect(outcome.contextDocuments).toHaveLength(6); + const readme = outcome.contextDocuments.find((d) => d.kind === "readme")!; + expect(readme.content).toBe("# Hello World\n\nA test repository.\n"); + }); + + it("is idempotent on re-import without .git suffix", async () => { + const layer = makeTestLayer( + makeFakePublicGitLayer( + () => HELLO_WORLD_RESULT, + () => ({ content: "", finalUrl: "" }) + ) + ); + const first = await runApp( + Effect.gen(function* () { + const a = yield* ProjectApplication; + return yield* a.importPublicGit({ + userId: "user-a", + repositoryUrl: "https://github.com/octocat/Hello-World.git", + }); + }), + layer + ); + const second = await runApp( + Effect.gen(function* () { + const a = yield* ProjectApplication; + return yield* a.importPublicGit({ + userId: "user-a", + repositoryUrl: "https://github.com/octocat/Hello-World", + }); + }), + layer + ); + expect(second.id).toBe(first.id); + }); + + it("rejects an invalid URL", async () => { + const layer = makeTestLayer( + makeFakePublicGitLayer( + () => HELLO_WORLD_RESULT, + () => ({ content: "", finalUrl: "" }) + ) + ); + const error = await expectError( + Effect.gen(function* () { + const a = yield* ProjectApplication; + return yield* a.importPublicGit({ + userId: "user-a", + repositoryUrl: "not-a-url", + }); + }).pipe(Effect.provide(layer)) + ); + expect((error as ProjectApplicationError).reason).toBe( + "InvalidPublicGitUrl" + ); + }); + + it("maps PublicGit unreachable error to PublicGitUnreachable", async () => { + const layer = makeTestLayer( + makeFakePublicGitLayer( + () => + new PublicGitError({ + message: "Connection refused", + reason: "Unreachable", + }), + () => ({ content: "", finalUrl: "" }) + ) + ); + const error = await expectError( + Effect.gen(function* () { + const a = yield* ProjectApplication; + return yield* a.importPublicGit({ + userId: "user-a", + repositoryUrl: "https://github.com/octocat/Hello-World.git", + }); + }).pipe(Effect.provide(layer)) + ); + expect((error as ProjectApplicationError).reason).toBe( + "PublicGitUnreachable" + ); + }); + + it("maps PublicGit cancelled error to PublicGitUnreachable", async () => { + const layer = makeTestLayer( + makeFakePublicGitLayer( + () => + new PublicGitError({ + message: "Timed out", + reason: "Cancelled", + }), + () => ({ content: "", finalUrl: "" }) + ) + ); + const error = await expectError( + Effect.gen(function* () { + const a = yield* ProjectApplication; + return yield* a.importPublicGit({ + userId: "user-a", + repositoryUrl: "https://github.com/octocat/Hello-World.git", + }); + }).pipe(Effect.provide(layer)) + ); + expect((error as ProjectApplicationError).reason).toBe( + "PublicGitUnreachable" + ); + }); +}); + +describe("ProjectApplication.putContext", () => { + const setupWithProject = async () => { + const layer = makeTestLayer( + makeFakePublicGitLayer( + () => HELLO_WORLD_RESULT, + () => ({ content: "", finalUrl: "" }) + ) + ); + const outcome = await runApp( + Effect.gen(function* () { + const a = yield* ProjectApplication; + return yield* a.importPublicGit({ + userId: "user-a", + repositoryUrl: "https://github.com/octocat/Hello-World.git", + }); + }), + layer + ); + return { layer, projectId: outcome.id }; + }; + + it("writes pasted context and increments revision", async () => { + const { layer, projectId } = await setupWithProject(); + const result = await runApp( + Effect.gen(function* () { + const a = yield* ProjectApplication; + return yield* a.putContext({ + userId: "user-a", + projectId, + kind: "design", + content: "# Design\n\nNew design notes.\n", + origin: "paste", + }); + }), + layer + ); + // Seeds start at revision 1; first explicit write bumps to 2 + expect(result.revision).toBe(2); + }); + + it("returns the same revision for identical content (no-op)", async () => { + const { layer, projectId } = await setupWithProject(); + await runApp( + Effect.gen(function* () { + const a = yield* ProjectApplication; + return yield* a.putContext({ + userId: "user-a", + projectId, + kind: "design", + content: "same content", + origin: "paste", + }); + }), + layer + ); + const result = await runApp( + Effect.gen(function* () { + const a = yield* ProjectApplication; + return yield* a.putContext({ + userId: "user-a", + projectId, + kind: "design", + content: "same content", + origin: "paste", + }); + }), + layer + ); + expect(result.revision).toBe(2); + }); + + it("last explicit write wins for the same kind", async () => { + const { layer, projectId } = await setupWithProject(); + await runApp( + Effect.gen(function* () { + const a = yield* ProjectApplication; + return yield* a.putContext({ + userId: "user-a", + projectId, + kind: "product", + content: "first paste", + origin: "paste", + }); + }), + layer + ); + const result = await runApp( + Effect.gen(function* () { + const a = yield* ProjectApplication; + return yield* a.putContext({ + userId: "user-a", + projectId, + kind: "product", + content: "second upload", + origin: "upload", + }); + }), + layer + ); + expect(result.revision).toBe(3); + }); +}); + +describe("ProjectApplication.importPublicText", () => { + it("imports text from a URL and increments revision", async () => { + const layer = makeTestLayer( + makeFakePublicGitLayer( + () => HELLO_WORLD_RESULT, + (url) => ({ + content: "# Tech via URL\n\nImported.\n", + finalUrl: url, + }) + ) + ); + const outcome = await runApp( + Effect.gen(function* () { + const a = yield* ProjectApplication; + return yield* a.importPublicGit({ + userId: "user-a", + repositoryUrl: "https://github.com/octocat/Hello-World.git", + }); + }), + layer + ); + const result = await runApp( + Effect.gen(function* () { + const a = yield* ProjectApplication; + return yield* a.importPublicText({ + userId: "user-a", + projectId: outcome.id, + kind: "tech", + url: "https://raw.githubusercontent.com/foo/bar/main/tech.md", + }); + }), + layer + ); + expect(result.revision).toBe(2); + }); + + it("rejects empty URL", async () => { + const layer = makeTestLayer( + makeFakePublicGitLayer( + () => HELLO_WORLD_RESULT, + () => ({ content: "text", finalUrl: "url" }) + ) + ); + const error = await expectError( + Effect.gen(function* () { + const a = yield* ProjectApplication; + return yield* a.importPublicText({ + userId: "user-a", + projectId: "project-1" as never, + kind: "tech", + url: " ", + }); + }).pipe(Effect.provide(layer)) + ); + expect((error as ProjectApplicationError).reason).toBe( + "InvalidContextInput" + ); + }); +}); + +describe("ProjectApplication tenant isolation", () => { + it("a second tenant cannot list the first tenant's projects", async () => { + const layer = makeTestLayer( + makeFakePublicGitLayer( + () => HELLO_WORLD_RESULT, + () => ({ content: "", finalUrl: "" }) + ) + ); + await runApp( + Effect.gen(function* () { + const a = yield* ProjectApplication; + return yield* a.importPublicGit({ + userId: "user-a", + repositoryUrl: "https://github.com/octocat/Hello-World.git", + }); + }), + layer + ); + const secondProjects = await runApp( + Effect.gen(function* () { + const a = yield* ProjectApplication; + return yield* a.listProjects({ userId: "user-b" }); + }), + layer + ); + expect(secondProjects).toHaveLength(0); + }); + + it("a second tenant cannot read the first tenant's project", async () => { + const layer = makeTestLayer( + makeFakePublicGitLayer( + () => HELLO_WORLD_RESULT, + () => ({ content: "", finalUrl: "" }) + ) + ); + const outcome = await runApp( + Effect.gen(function* () { + const a = yield* ProjectApplication; + return yield* a.importPublicGit({ + userId: "user-a", + repositoryUrl: "https://github.com/octocat/Hello-World.git", + }); + }), + layer + ); + const result = await runApp( + Effect.gen(function* () { + const a = yield* ProjectApplication; + return yield* a.getProject({ + userId: "user-b", + projectId: outcome.id, + }); + }), + layer + ); + expect(result).toBeNull(); + }); +}); diff --git a/packages/primitives/src/project.ts b/packages/primitives/src/project.ts new file mode 100644 index 0000000..c38a9ba --- /dev/null +++ b/packages/primitives/src/project.ts @@ -0,0 +1,754 @@ +import { Context, Effect, Layer, Schema } from "effect"; + +import { OrganizationId, ProjectId, TimestampMs } from "./signal.js"; +export type { OrganizationId, ProjectId } from "./signal.js"; + +// --------------------------------------------------------------------------- +// Domain constants +// --------------------------------------------------------------------------- + +export const MAX_CONTEXT_DOCUMENT_CHARACTERS = 200_000; +export const MAX_CONTEXT_BATCH_CHARACTERS = 600_000; + +export const CONTEXT_KINDS = [ + "readme", + "agents", + "product", + "business", + "design", + "tech", +] as const; + +export type ContextKind = (typeof CONTEXT_KINDS)[number]; + +const CONTEXT_KIND_TO_PATH: Readonly> = { + agents: "AGENTS.md", + business: "business.md", + design: "design.md", + product: "product.md", + readme: "README.md", + tech: "tech.md", +}; + +const PATH_TO_CONTEXT_KIND: Readonly> = + Object.fromEntries( + Object.entries(CONTEXT_KIND_TO_PATH).map(([kind, path]) => [ + path, + kind as ContextKind, + ]) + ); + +export const contextPathForKind = (kind: ContextKind): string => + CONTEXT_KIND_TO_PATH[kind]; + +export const contextKindForPath = (path: string): ContextKind | null => + PATH_TO_CONTEXT_KIND[path] ?? null; + +const ContextKindSchema = Schema.Literals([...CONTEXT_KINDS]); + +const MeaningfulString = Schema.String.check( + Schema.makeFilter((value) => value.trim().length > 0, { + expected: "a non-empty string", + }) +); + + +// --------------------------------------------------------------------------- +// Shared data shapes +// --------------------------------------------------------------------------- + +export const PreparedPublicGitSource = Schema.Struct({ + host: Schema.String, + projectName: MeaningfulString, + repositoryPath: MeaningfulString, + normalizedUrl: Schema.String, + url: Schema.String, +}); +export type PreparedPublicGitSource = typeof PreparedPublicGitSource.Type; + +export const RepositoryContextDocument = Schema.Struct({ + kind: ContextKindSchema, + path: Schema.String, + content: Schema.String, +}); +export type RepositoryContextDocument = typeof RepositoryContextDocument.Type; + +export const ProjectImportWarning = Schema.Struct({ + message: Schema.String, + path: Schema.String, +}); +export type ProjectImportWarning = typeof ProjectImportWarning.Type; + +export const PublicGitImportResult = Schema.Struct({ + defaultBranch: Schema.UndefinedOr(Schema.String), + documents: Schema.Array(RepositoryContextDocument), + warnings: Schema.Array(ProjectImportWarning), +}); +export type PublicGitImportResult = typeof PublicGitImportResult.Type; + +export const PublicTextResult = Schema.Struct({ + content: Schema.String, + finalUrl: Schema.String, +}); +export type PublicTextResult = typeof PublicTextResult.Type; + +export const ContextOrigin = Schema.Literals([ + "repository", + "paste", + "upload", + "public-url", +]); +export type ContextOrigin = typeof ContextOrigin.Type; + +export const ContextDocumentState = Schema.Struct({ + content: Schema.String, + kind: ContextKindSchema, + origin: ContextOrigin, + path: Schema.String, + revision: Schema.Number, + sourceUrl: Schema.UndefinedOr(Schema.String), +}); +export type ContextDocumentState = typeof ContextDocumentState.Type; + +const RepositoryWrite = Schema.Struct({ + _tag: Schema.Literal("Repository"), + content: Schema.String, + kind: ContextKindSchema, + sourceUrl: Schema.String, +}); + +const PublicTextUrlWrite = Schema.Struct({ + _tag: Schema.Literal("PublicTextUrl"), + content: Schema.String, + kind: ContextKindSchema, + sourceUrl: Schema.String, +}); + +const UserTextWrite = Schema.Struct({ + _tag: Schema.Literal("UserText"), + content: Schema.String, + kind: ContextKindSchema, + origin: Schema.Literals(["paste", "upload"]), +}); + +export const ContextWrite = Schema.Union([ + RepositoryWrite, + PublicTextUrlWrite, + UserTextWrite, +]); +export type ContextWrite = typeof ContextWrite.Type; + +export const ProjectSourceView = Schema.Struct({ + createdAt: TimestampMs, + defaultBranch: Schema.UndefinedOr(Schema.String), + host: Schema.String, + kind: Schema.Literal("git"), + normalizedUrl: Schema.String, + projectId: ProjectId, + repositoryPath: Schema.String, + updatedAt: TimestampMs, + url: Schema.String, +}); +export type ProjectSourceView = typeof ProjectSourceView.Type; + +export const ProjectView = Schema.Struct({ + contextDocuments: Schema.Array(ContextDocumentState), + createdAt: TimestampMs, + id: ProjectId, + name: Schema.String, + organizationId: OrganizationId, + sources: Schema.Array(ProjectSourceView), + updatedAt: TimestampMs, +}); +export type ProjectView = typeof ProjectView.Type; + +export const ProjectImportOutcome = Schema.Struct({ + contextDocuments: Schema.Array(ContextDocumentState), + createdAt: TimestampMs, + id: ProjectId, + name: Schema.String, + organizationId: OrganizationId, + source: ProjectSourceView, + updatedAt: TimestampMs, +}); +export type ProjectImportOutcome = typeof ProjectImportOutcome.Type; + +// --------------------------------------------------------------------------- +// Errors +// --------------------------------------------------------------------------- + +export const PublicGitErrorReason = Schema.Literals([ + "InvalidUrl", + "Unreachable", + "InvalidResponse", + "Cancelled", +]); +export type PublicGitErrorReason = typeof PublicGitErrorReason.Type; + +export class PublicGitError extends Schema.TaggedErrorClass()( + "PublicGitError", + { + message: Schema.String, + reason: PublicGitErrorReason, + } +) {} + +export const ProjectStoreErrorReason = Schema.Literals([ + "NotFound", + "Forbidden", + "Conflict", + "Persistence", +]); +export type ProjectStoreErrorReason = typeof ProjectStoreErrorReason.Type; + +export class ProjectStoreError extends Schema.TaggedErrorClass()( + "ProjectStoreError", + { + message: Schema.String, + reason: ProjectStoreErrorReason, + } +) {} + +export const ProjectApplicationErrorReason = Schema.Literals([ + "Authentication", + "InvalidPublicGitUrl", + "PublicGitUnreachable", + "InvalidRemoteResult", + "InvalidContextInput", + "ContextDocumentTooLarge", + "ContextBatchTooLarge", + "ProjectNotFound", + "Persistence", +]); +export type ProjectApplicationErrorReason = + typeof ProjectApplicationErrorReason.Type; + +export class ProjectApplicationError extends Schema.TaggedErrorClass()( + "ProjectApplicationError", + { + message: Schema.String, + reason: ProjectApplicationErrorReason, + } +) {} + +// --------------------------------------------------------------------------- +// Pure domain functions +// --------------------------------------------------------------------------- + +const isWhitespaceOnly = (value: string): boolean => value.trim().length === 0; + +const INVALID_GIT_URL = new ProjectApplicationError({ + message: "Use a public http(s) Git repository URL", + reason: "InvalidPublicGitUrl", +}); + +const INVALID_REMOTE = new ProjectApplicationError({ + message: "Invalid remote result", + reason: "InvalidRemoteResult", +}); + +export const preparePublicGitSource = ( + rawUrl: string +): Effect.Effect => + Effect.gen(function* () { + let parsed: URL; + try { + parsed = new URL(rawUrl); + } catch { + return yield* Effect.fail(INVALID_GIT_URL); + } + + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + return yield* Effect.fail(INVALID_GIT_URL); + } + + if (parsed.username || parsed.password || parsed.search || parsed.hash) { + return yield* Effect.fail(INVALID_GIT_URL); + } + + const host = parsed.hostname.toLowerCase(); + let repositoryPath = parsed.pathname; + if (repositoryPath.startsWith("/")) { + repositoryPath = repositoryPath.slice(1); + } + if (repositoryPath.endsWith("/")) { + repositoryPath = repositoryPath.slice(0, -1); + } + if (repositoryPath.endsWith(".git")) { + repositoryPath = repositoryPath.slice(0, -4); + } + + if (repositoryPath.length === 0) { + return yield* Effect.fail(INVALID_GIT_URL); + } + + const segments = repositoryPath.split("/").filter((s) => s.length > 0); + const lastSegment = segments[segments.length - 1]; + if (!lastSegment || isWhitespaceOnly(lastSegment)) { + return yield* Effect.fail(INVALID_GIT_URL); + } + + const projectName = lastSegment; + const normalizedUrl = `${parsed.protocol}//${host}/${repositoryPath}`; + + return { + host, + normalizedUrl, + projectName, + repositoryPath, + url: normalizedUrl, + }; + }); + +export const decodePublicGitImportResult = ( + raw: unknown +): Effect.Effect => + Effect.gen(function* () { + const result = yield* Schema.decodeUnknownEffect(PublicGitImportResult)( + raw + ).pipe(Effect.mapError(() => INVALID_REMOTE)); + + const seenKinds = new Set(); + const seenPaths = new Set(); + let totalChars = 0; + + for (const doc of result.documents) { + const canonicalPath = CONTEXT_KIND_TO_PATH[doc.kind]; + if (doc.path !== canonicalPath) { + return yield* Effect.fail(INVALID_REMOTE); + } + if (seenKinds.has(doc.kind) || seenPaths.has(doc.path)) { + return yield* Effect.fail(INVALID_REMOTE); + } + if (isWhitespaceOnly(doc.content)) { + return yield* Effect.fail(INVALID_REMOTE); + } + if (doc.content.length > MAX_CONTEXT_DOCUMENT_CHARACTERS) { + return yield* Effect.fail( + new ProjectApplicationError({ + message: "Context document exceeds 200000 characters", + reason: "ContextDocumentTooLarge", + }) + ); + } + seenKinds.add(doc.kind); + seenPaths.add(doc.path); + totalChars += doc.content.length; + } + + if (totalChars > MAX_CONTEXT_BATCH_CHARACTERS) { + return yield* Effect.fail( + new ProjectApplicationError({ + message: "Context batch exceeds 600000 characters", + reason: "ContextBatchTooLarge", + }) + ); + } + + return result; + }); + +export const decodePublicTextResult = ( + raw: unknown +): Effect.Effect => + Effect.gen(function* () { + const result = yield* Schema.decodeUnknownEffect(PublicTextResult)( + raw + ).pipe(Effect.mapError(() => INVALID_REMOTE)); + + if (isWhitespaceOnly(result.content)) { + return yield* Effect.fail( + new ProjectApplicationError({ + message: "Context document cannot be empty", + reason: "InvalidContextInput", + }) + ); + } + if (result.content.length > MAX_CONTEXT_DOCUMENT_CHARACTERS) { + return yield* Effect.fail( + new ProjectApplicationError({ + message: "Context document exceeds 200000 characters", + reason: "ContextDocumentTooLarge", + }) + ); + } + if (isWhitespaceOnly(result.finalUrl)) { + return yield* Effect.fail(INVALID_REMOTE); + } + + return result; + }); + +const resolveKindFromPath = (path: string): ContextKind => { + const kind = contextKindForPath(path); + if (kind === null) { + throw new Error(`Unknown canonical context path: ${path}`); + } + return kind; +}; + +export const makeInitialContext = ( + projectName: string, + repositoryUrl: string +): readonly ContextDocumentState[] => { + const placeholder = (path: string): ContextDocumentState => ({ + content: `# ${path}\n\nContext not imported yet.\n`, + kind: resolveKindFromPath(path), + origin: "repository", + path, + revision: 1, + sourceUrl: repositoryUrl, + }); + + return [ + { + content: `# ${projectName}\n\nRepository: ${repositoryUrl}\n`, + kind: "readme", + origin: "repository", + path: "README.md", + revision: 1, + sourceUrl: repositoryUrl, + }, + placeholder("AGENTS.md"), + placeholder("product.md"), + placeholder("business.md"), + placeholder("design.md"), + placeholder("tech.md"), + ]; +}; + +export interface DecideContextWriteInput { + readonly existing: ContextDocumentState | undefined; + readonly write: ContextWrite; +} + +export interface DecideContextWriteResult { + readonly document: ContextDocumentState; + readonly changed: boolean; +} + +export const decideContextWrite = ( + input: DecideContextWriteInput +): Effect.Effect => + Effect.gen(function* () { + const write = input.write; + const content = write.content; + const kind = write.kind; + const path = contextPathForKind(kind); + + if (isWhitespaceOnly(content)) { + return yield* Effect.fail( + new ProjectApplicationError({ + message: "Context document cannot be empty", + reason: "InvalidContextInput", + }) + ); + } + if (content.length > MAX_CONTEXT_DOCUMENT_CHARACTERS) { + return yield* Effect.fail( + new ProjectApplicationError({ + message: "Context document exceeds 200000 characters", + reason: "ContextDocumentTooLarge", + }) + ); + } + + const origin: ContextOrigin = + write._tag === "Repository" + ? "repository" + : write._tag === "PublicTextUrl" + ? "public-url" + : write.origin; + const sourceUrl: string | undefined = + write._tag === "UserText" ? undefined : write.sourceUrl; + + const existing = input.existing; + if ( + existing && + existing.content === content && + existing.origin === origin && + existing.sourceUrl === sourceUrl + ) { + return { changed: false, document: existing }; + } + + const nextRevision = (existing?.revision ?? 0) + 1; + return { + changed: true, + document: { content, kind, origin, path, revision: nextRevision, sourceUrl }, + }; + }); + +// --------------------------------------------------------------------------- +// ProjectDomain service +// --------------------------------------------------------------------------- + +interface ProjectDomainShape { + readonly preparePublicGitSource: typeof preparePublicGitSource; + readonly decodePublicGitImportResult: typeof decodePublicGitImportResult; + readonly decodePublicTextResult: typeof decodePublicTextResult; + readonly makeInitialContext: typeof makeInitialContext; + readonly decideContextWrite: typeof decideContextWrite; +} + +export class ProjectDomain extends Context.Service< + ProjectDomain, + ProjectDomainShape +>()("@code/primitives/project/ProjectDomain") { + static readonly layer = Layer.succeed( + ProjectDomain, + ProjectDomain.of({ + preparePublicGitSource, + decodePublicGitImportResult, + decodePublicTextResult, + makeInitialContext, + decideContextWrite, + }) + ); +} + +// --------------------------------------------------------------------------- +// PublicGit port +// --------------------------------------------------------------------------- + +interface PublicGitShape { + readonly inspect: ( + source: PreparedPublicGitSource + ) => Effect.Effect; + readonly fetchText: ( + url: string + ) => Effect.Effect; +} + +export class PublicGit extends Context.Service()( + "@code/primitives/project/PublicGit" +) {} + +// --------------------------------------------------------------------------- +// ProjectStore port +// --------------------------------------------------------------------------- + +interface ProjectStoreShape { + readonly getCurrentOrganization: ( + userId: string + ) => Effect.Effect; + readonly listProjects: ( + userId: string + ) => Effect.Effect; + readonly getProject: ( + userId: string, + projectId: ProjectId + ) => Effect.Effect; + readonly persistPublicGitImport: (input: { + readonly userId: string; + readonly source: PreparedPublicGitSource; + readonly remote: PublicGitImportResult; + }) => Effect.Effect; + readonly putContext: (input: { + readonly userId: string; + readonly projectId: ProjectId; + readonly write: ContextWrite; + }) => Effect.Effect<{ readonly revision: number }, ProjectStoreError>; +} + +export class ProjectStore extends Context.Service< + ProjectStore, + ProjectStoreShape +>()("@code/primitives/project/ProjectStore") {} + +// --------------------------------------------------------------------------- +// Error mapping +// --------------------------------------------------------------------------- + +const mapStoreError = (error: ProjectStoreError): ProjectApplicationError => { + if (error.reason === "NotFound" || error.reason === "Forbidden") { + return new ProjectApplicationError({ + message: "Project not found", + reason: "ProjectNotFound", + }); + } + return new ProjectApplicationError({ + message: error.message, + reason: "Persistence", + }); +}; + +const mapPublicGitError = (error: PublicGitError): ProjectApplicationError => { + if (error.reason === "InvalidUrl") { + return new ProjectApplicationError({ + message: error.message, + reason: "InvalidPublicGitUrl", + }); + } + if (error.reason === "Unreachable" || error.reason === "Cancelled") { + return new ProjectApplicationError({ + message: error.message, + reason: "PublicGitUnreachable", + }); + } + return new ProjectApplicationError({ + message: error.message, + reason: "InvalidRemoteResult", + }); +}; + +// --------------------------------------------------------------------------- +// ProjectApplication service +// --------------------------------------------------------------------------- + +interface ProjectApplicationShape { + readonly importPublicGit: (input: { + readonly userId: string; + readonly repositoryUrl: unknown; + }) => Effect.Effect; + readonly importPublicText: (input: { + readonly userId: string; + readonly projectId: ProjectId; + readonly kind: ContextKind; + readonly url: string; + }) => Effect.Effect<{ readonly revision: number }, ProjectApplicationError>; + readonly putContext: (input: { + readonly userId: string; + readonly projectId: ProjectId; + readonly kind: ContextKind; + readonly content: string; + readonly origin: "paste" | "upload"; + }) => Effect.Effect<{ readonly revision: number }, ProjectApplicationError>; + readonly listProjects: (input: { + readonly userId: string; + }) => Effect.Effect; + readonly getProject: (input: { + readonly userId: string; + readonly projectId: ProjectId; + }) => Effect.Effect; +} + +export class ProjectApplication extends Context.Service< + ProjectApplication, + ProjectApplicationShape +>()("@code/primitives/project/ProjectApplication") { + static readonly layer = Layer.effect( + ProjectApplication, + Effect.gen(function* () { + const domain = yield* ProjectDomain; + const publicGit = yield* PublicGit; + const store = yield* ProjectStore; + + return ProjectApplication.of({ + importPublicGit: Effect.fn("ProjectApplication.importPublicGit")( + function* ({ + userId, + repositoryUrl, + }: { + readonly userId: string; + readonly repositoryUrl: unknown; + }) { + if (typeof repositoryUrl !== "string") { + return yield* Effect.fail(INVALID_GIT_URL); + } + + const source = yield* domain.preparePublicGitSource( + repositoryUrl + ); + + const rawRemote = yield* publicGit + .inspect(source) + .pipe(Effect.mapError(mapPublicGitError)); + + const remote = yield* domain.decodePublicGitImportResult( + rawRemote + ); + + return yield* store + .persistPublicGitImport({ userId, source, remote }) + .pipe(Effect.mapError(mapStoreError)); + } + ), + importPublicText: Effect.fn("ProjectApplication.importPublicText")( + function* ({ + userId, + projectId, + kind, + url, + }: { + readonly userId: string; + readonly projectId: ProjectId; + readonly kind: ContextKind; + readonly url: string; + }) { + if (isWhitespaceOnly(url)) { + return yield* Effect.fail( + new ProjectApplicationError({ + message: "Use a public text URL", + reason: "InvalidContextInput", + }) + ); + } + + const rawText = yield* publicGit + .fetchText(url) + .pipe(Effect.mapError(mapPublicGitError)); + + const text = yield* domain.decodePublicTextResult(rawText); + + const write: ContextWrite = { + _tag: "PublicTextUrl", + content: text.content, + kind, + sourceUrl: text.finalUrl, + }; + + return yield* store + .putContext({ userId, projectId, write }) + .pipe(Effect.mapError(mapStoreError)); + } + ), + putContext: Effect.fn("ProjectApplication.putContext")( + function* ({ + userId, + projectId, + kind, + content, + origin, + }: { + readonly userId: string; + readonly projectId: ProjectId; + readonly kind: ContextKind; + readonly content: string; + readonly origin: "paste" | "upload"; + }) { + const write: ContextWrite = { + _tag: "UserText", + content, + kind, + origin, + }; + return yield* store + .putContext({ userId, projectId, write }) + .pipe(Effect.mapError(mapStoreError)); + } + ), + listProjects: Effect.fn("ProjectApplication.listProjects")( + function* ({ userId }: { readonly userId: string }) { + return yield* store + .listProjects(userId) + .pipe(Effect.mapError(mapStoreError)); + } + ), + getProject: Effect.fn("ProjectApplication.getProject")( + function* ({ + userId, + projectId, + }: { + readonly userId: string; + readonly projectId: ProjectId; + }) { + return yield* store + .getProject(userId, projectId) + .pipe(Effect.mapError(mapStoreError)); + } + ), + }); + }) + ); +}