Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
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
7 changes: 6 additions & 1 deletion app/api/mcp/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ function payloadTooLarge() {
/** Shape we require from a verified MCP access token payload. */
const accessTokenClaimsSchema = z.looseObject({
sub: z.uuid(),
azp: z.string().optional(),
});

/**
Expand Down Expand Up @@ -173,7 +174,11 @@ async function verifyMcpAuth(request: Request) {
function authContextFromPayload(payload: unknown): AuthContext | null {
const parsed = accessTokenClaimsSchema.safeParse(payload);
if (!parsed.success) return null;
return makeAuthContext(parsed.data.sub);
return makeAuthContext(parsed.data.sub, {
source: "mcp",
userId: parsed.data.sub,
clientId: parsed.data.azp ?? null,
});
}

/**
Expand Down
42 changes: 42 additions & 0 deletions app/api/task/[taskId]/events/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { getAuthContext } from "@/lib/auth/context";
import { ForbiddenError, assertTaskAccess } from "@/lib/auth/authorization";
import { listTaskActivity } from "@/lib/data/activity";
import { internalError } from "@/lib/api/error";
import { error, ok } from "@/lib/api/response";

/**
* GET handler — paginated activity for a task, newest-first.
*
* @param req - Incoming request; reads `?cursor` and `?limit`.
* @param params - Route params with `taskId`.
* @returns 200 with `{ events, nextCursor }`, or 401/404/500.
*/
export async function GET(
req: Request,
{ params }: { params: Promise<{ taskId: string }> },
): Promise<Response> {
const { taskId } = await params;

let ctx;
try {
ctx = await getAuthContext();
} catch {
return error("Unauthorized", 401);
}

try {
await assertTaskAccess(taskId, ctx);
const url = new URL(req.url);
const cursor = url.searchParams.get("cursor") ?? undefined;
const limitRaw = url.searchParams.get("limit");
const limit = limitRaw ? Number.parseInt(limitRaw, 10) : undefined;
const page = await listTaskActivity(ctx, taskId, {
cursor,
limit: limit !== undefined && Number.isFinite(limit) ? limit : undefined,
});
return ok(page);
} catch (err) {
if (err instanceof ForbiddenError) return error("Task not found", 404);
return internalError("task-events", err);
}
}
1 change: 0 additions & 1 deletion app/project/[projectId]/_components/WorkspaceClient.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -448,7 +448,6 @@ function useSelectedTaskBody(
priority: slim.priority ?? null,
estimate: slim.estimate ?? null,
files: [],
history: [],
createdAt: new Date(),
updatedAt: slim.updatedAt,
taskRef: slim.taskRef,
Expand Down
117 changes: 84 additions & 33 deletions components/workspace/detail/ActivitySection.tsx
Original file line number Diff line number Diff line change
@@ -1,60 +1,105 @@
"use client";

import { useInfiniteQuery } from "@tanstack/react-query";
import { Avatar } from "@/components/shared/Avatar";
import type { HistoryEntry } from "@/lib/types";
import type { ActivityEvent } from "@/lib/types";
import { taskKeys } from "@/lib/query/keys";
import { SectionHeader } from "./SectionHeader";

interface ActivitySectionProps {
/** History entries from the schema. */
history: HistoryEntry[] | null | undefined;
/** Owning project id (for the query key). */
projectId: string;
/** Task whose activity to show. */
taskId: string;
}

interface ActivityPage {
events: ActivityEvent[];
nextCursor: string | null;
}

/**
* Fetch one page of activity for a task.
* @param taskId - Task id.
* @param cursor - Opaque keyset cursor or null for the first page.
* @returns The page payload.
* @throws Error when the request fails.
*/
async function fetchActivity(
taskId: string,
cursor: string | null,
): Promise<ActivityPage> {
const qs = cursor ? `?cursor=${encodeURIComponent(cursor)}` : "";
const res = await fetch(`/api/task/${taskId}/events${qs}`);
if (!res.ok) throw new Error(`activity ${res.status}`);
return res.json();
}

/**
* Vertical activity timeline matching the prototype — avatar per row plus a
* thin connector running through the avatar centers. One-line entries with a
* mono relative date pinned to the right.
* Activity timeline — avatar + actor name + harness badge + entity-referencing
* summary + relative time (absolute on hover). Lazy-loaded and paginated.
*
* @param props - Section configuration.
* @returns Section element or null when there is no history.
* @param props - Project + task identifiers.
* @returns Section element, or null while empty.
*/
export function ActivitySection({ history }: ActivitySectionProps) {
if (!history || history.length === 0) return null;
export function ActivitySection({ projectId, taskId }: ActivitySectionProps) {
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } =
useInfiniteQuery({
queryKey: taskKeys.activity(projectId, taskId),
queryFn: ({ pageParam }) => fetchActivity(taskId, pageParam),
initialPageParam: null as string | null,
getNextPageParam: (last) => last.nextCursor,
});

const events = data?.pages.flatMap((p) => p.events) ?? [];
if (events.length === 0) return null;

return (
<section className="mb-7">
<SectionHeader label="Activity" count={history.length} />
<SectionHeader label="Activity" count={events.length} />
<ul className="flex flex-col">
{history.map((entry, i) => (
<ActivityRow
key={entry.id}
entry={entry}
isLast={i === history.length - 1}
/>
{events.map((e, i) => (
<ActivityRow key={e.id} event={e} isLast={i === events.length - 1} />
))}
</ul>
{hasNextPage && (
<button
type="button"
onClick={() => fetchNextPage()}
disabled={isFetchingNextPage}
className="mt-2 text-[11px] text-text-faint hover:text-text-secondary"
>
{isFetchingNextPage ? "Loading…" : "Show more"}
</button>
)}
</section>
);
}

interface ActivityRowProps {
/** The single history entry to render. */
entry: HistoryEntry;
/** Whether this is the last row — controls the trailing connector line. */
/** Event to render. */
event: ActivityEvent;
/** Whether this is the last row — controls the trailing connector. */
isLast: boolean;
}

/**
* Single timeline row — avatar + author/verb sentence + relative date.
*
* Single timeline row.
* @param props - Row configuration.
* @returns List item element.
*/
function ActivityRow({ entry, isLast }: ActivityRowProps) {
const author = entry.actor === "ai" ? "agent" : "user";
function ActivityRow({ event, isLast }: ActivityRowProps) {
const name = event.actorName ?? (event.source === "web" ? "user" : "agent");
const isAgent = event.source === "mcp";
return (
<li className="relative flex items-center gap-2.5 py-2">
<span className="relative flex w-[22px] justify-center">
<Avatar name={author} size={18} accent={entry.actor === "ai"} />
<Avatar
name={name}
src={event.actorAvatar ?? undefined}
size={18}
accent={isAgent}
/>
{!isLast && (
<span
aria-hidden="true"
Expand All @@ -63,22 +108,28 @@ function ActivityRow({ entry, isLast }: ActivityRowProps) {
)}
</span>
<span className="min-w-0 flex-1 truncate text-[12.5px] text-text-secondary">
<span className="font-medium text-text-primary">{author}</span>{" "}
{entry.label.toLowerCase()}
<span className="font-medium text-text-primary">{name}</span>
{isAgent && event.agent && (
<span className="ml-1 rounded bg-surface-raised px-1 py-px font-mono text-[9px] text-text-faint">
{event.agent}
</span>
)}{" "}
{event.summary}
</span>
<span className="font-mono text-[10px] tabular-nums text-text-faint">
{formatRelative(entry.date)}
<span
className="font-mono text-[10px] tabular-nums text-text-faint"
title={new Date(event.createdAt).toLocaleString()}
>
{formatRelative(event.createdAt)}
</span>
</li>
);
}

/**
* Compact relative-time formatter — picks the largest unit that fits and
* appends the unit suffix (`12m`, `2h`, `3d`, `2w`).
*
* Compact relative-time formatter.
* @param iso - ISO date string.
* @returns Two-character relative label, or `—` if unparseable.
* @returns Short relative label, or `—` if unparseable.
*/
function formatRelative(iso: string): string {
const ts = Date.parse(iso);
Expand Down
2 changes: 1 addition & 1 deletion components/workspace/detail/DetailView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -218,7 +218,7 @@ export function DetailView({

<ExecutionSection record={task.executionRecord} />

<ActivitySection history={task.history} />
<ActivitySection projectId={projectId} taskId={task.id} />
</div>
)}
</div>
Expand Down
5 changes: 5 additions & 0 deletions docker/grants.sql
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,11 @@ GRANT CREATE ON SCHEMA public TO service_role;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO app_user, service_role;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO app_user, service_role;

-- Explicit per-table grant for activity_events (the documented convention for
-- new public tables; the schema-wide grant above only covers tables that
-- exist when it runs).
GRANT SELECT, INSERT, UPDATE, DELETE ON "activity_events" TO app_user, service_role;

-- neon_auth: app_user reaches it only via SECURITY DEFINER functions.
-- Explicit REVOKEs make re-runs idempotent on pre-lockdown installs.
GRANT USAGE ON SCHEMA neon_auth TO service_role, auth_role;
Expand Down
43 changes: 43 additions & 0 deletions docker/rls-functions.sql
Original file line number Diff line number Diff line change
Expand Up @@ -376,6 +376,49 @@ $$;
REVOKE EXECUTE ON FUNCTION public.task_assignees_visible(uuid) FROM public;
GRANT EXECUTE ON FUNCTION public.task_assignees_visible(uuid) TO app_user;

-- Resolve the distinct actor profiles for a task's activity events. Gated on
-- the caller's membership of the task's org, like task_assignees_visible.
CREATE OR REPLACE FUNCTION public.activity_actors_visible(p_task_id uuid)
RETURNS TABLE (user_id uuid, name text, image text)
LANGUAGE plpgsql
STABLE
SECURITY DEFINER
SET search_path = public, neon_auth, pg_catalog, pg_temp
AS $$
BEGIN
RETURN QUERY
SELECT DISTINCT u.id, u.name, u.image
FROM public.activity_events ae
INNER JOIN neon_auth."user" u ON u.id = ae.actor_user_id
WHERE ae.task_id = p_task_id
AND EXISTS (
SELECT 1
FROM public.tasks t
INNER JOIN public.projects pj ON pj.id = t.project_id
INNER JOIN neon_auth."member" caller
ON caller."organizationId" = pj.organization_id
WHERE t.id = p_task_id
AND caller."userId" = NULLIF(current_setting('app.user_id', TRUE), '')::uuid
);
END;
$$;
REVOKE EXECUTE ON FUNCTION public.activity_actors_visible(uuid) FROM public;
GRANT EXECUTE ON FUNCTION public.activity_actors_visible(uuid) TO app_user;

-- Resolve a single OAuth client display name (not secret). Scalar to avoid
-- text[] array binding on the read path; callers loop the page's few ids.
CREATE OR REPLACE FUNCTION public.oauth_client_name(p_client_id text)
RETURNS text
LANGUAGE sql
STABLE
SECURITY DEFINER
SET search_path = public, neon_auth, pg_catalog, pg_temp
AS $$
SELECT c.name FROM neon_auth."oauthClient" c WHERE c."clientId" = p_client_id;
$$;
REVOKE EXECUTE ON FUNCTION public.oauth_client_name(text) FROM public;
GRANT EXECUTE ON FUNCTION public.oauth_client_name(text) TO app_user;

-- Per-project sibling of task_assignees_visible: one membership probe
-- for the whole project instead of N (old LATERAL pattern). Probing a
-- foreign project UUID is indistinguishable from a missing one.
Expand Down
8 changes: 8 additions & 0 deletions docker/rls-policies.sql
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,12 @@ CREATE POLICY "task_links_member_access" ON "task_links" AS PERMISSIVE FOR ALL T
USING (task_id IN (SELECT id FROM public.tasks))
WITH CHECK (task_id IN (SELECT id FROM public.tasks));

-- activity_events — 2-hop via projects' RLS, mirroring tasks.
DROP POLICY IF EXISTS "activity_events_member_access" ON "activity_events";
CREATE POLICY "activity_events_member_access" ON "activity_events" AS PERMISSIVE FOR ALL TO app_user
USING (project_id IN (SELECT id FROM public.projects))
WITH CHECK (project_id IN (SELECT id FROM public.projects));

-- RESTRICTIVE write floor on task_edges. RESTRICTIVE AND's with the OR of
-- permissives, so a future stray permissive cannot OR-relax both-endpoints
-- -visible. Scoped per-command to leave SELECT on the permissive policy.
Expand Down Expand Up @@ -136,6 +142,7 @@ ALTER TABLE "task_acceptance_criteria" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "task_decisions" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "task_links" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "team_invite_code" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "activity_events" ENABLE ROW LEVEL SECURITY;

-- FORCE subjects the table owner to RLS. BYPASSRLS roles and real
-- superusers still sidestep.
Expand All @@ -147,3 +154,4 @@ ALTER TABLE "task_acceptance_criteria" FORCE ROW LEVEL SECURITY;
ALTER TABLE "task_decisions" FORCE ROW LEVEL SECURITY;
ALTER TABLE "task_links" FORCE ROW LEVEL SECURITY;
ALTER TABLE "team_invite_code" FORCE ROW LEVEL SECURITY;
ALTER TABLE "activity_events" FORCE ROW LEVEL SECURITY;
22 changes: 19 additions & 3 deletions lib/auth/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,15 @@ declare const authContextBrand: unique symbol;
* `organizationId` arg (MCP create); both paths re-verify membership on
* every request.
*/
/** Durable identity keys behind a request; display is resolved at read time. */
export type ActorDescriptor =
| { source: "web"; userId: string }
| { source: "mcp"; userId: string; clientId: string | null }
| { source: "system"; userId: string };

export type AuthContext = {
readonly userId: string;
readonly actor: ActorDescriptor;
readonly [authContextBrand]: true;
};

Expand All @@ -27,10 +34,16 @@ export type AuthContext = {
* code paths that have validated the principal (session, JWT). Application
* code should depend on `AuthContext` values handed in, not construct them.
* @param userId - Verified user id (e.g. `session.user.id`, JWT `sub`).
* @param actor - Resolved actor descriptor; defaults to a `system` actor
* bound to `userId` for callers (tests, internal jobs) that do not carry
* surface identity.
* @returns Branded auth context.
*/
export function makeAuthContext(userId: string): AuthContext {
return { userId } as unknown as AuthContext;
export function makeAuthContext(
userId: string,
actor: ActorDescriptor = { source: "system", userId },
): AuthContext {
return { userId, actor } as unknown as AuthContext;
}

/**
Expand All @@ -44,5 +57,8 @@ export function makeAuthContext(userId: string): AuthContext {
*/
export async function getAuthContext(): Promise<AuthContext> {
const session = await requireSession();
return makeAuthContext(session.user.id);
return makeAuthContext(session.user.id, {
source: "web",
userId: session.user.id,
});
}
Loading