Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
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
6 changes: 3 additions & 3 deletions src/app/api/api-contracts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,8 +57,8 @@ const contracts: RouteContract[] = [
{ route: "/github/pat", methods: ["GET", "POST", "DELETE"], kind: "json", readsJson: true, invalidJson: "fallback-empty" },
{ route: "/github/tasks", methods: ["GET"], kind: "json" },
{ route: "/github/worktree", methods: ["POST"], kind: "json", readsJson: true, invalidJson: "guarded" },
{ route: "/grant-proposals/[id]", methods: ["PATCH"], kind: "json", readsJson: true, invalidJson: "guarded" },
{ route: "/grant-proposals", methods: ["GET", "POST"], kind: "json", readsJson: true, invalidJson: "guarded" },
{ route: "/grant-proposals/[id]", methods: ["PATCH"], kind: "json" },
{ route: "/grant-proposals", methods: ["GET", "POST"], kind: "json" },
{ route: "/harnesses", methods: ["GET"], kind: "json" },
{ route: "/inbox/[id]/dismiss", methods: ["POST"], kind: "json" },
{ route: "/inbox/[id]/done", methods: ["POST"], kind: "json" },
Expand Down Expand Up @@ -91,7 +91,7 @@ const contracts: RouteContract[] = [
{ route: "/onboarding/status", methods: ["GET"], kind: "json" },
{ route: "/openclaw-agents", methods: ["GET"], kind: "json" },
{ route: "/opencoven-tools/status", methods: ["GET"], kind: "json" },
{ route: "/project-grants", methods: ["GET", "POST", "DELETE"], kind: "json", readsJson: true, invalidJson: "guarded" },
{ route: "/project-grants", methods: ["GET", "POST", "DELETE"], kind: "json" },
{ route: "/project-file", methods: ["GET", "POST"], kind: "json", pathGuard: true, readsJson: true, invalidJson: "guarded" },
{ route: "/project-tree", methods: ["GET", "POST"], kind: "json", pathGuard: true, readsJson: true, invalidJson: "guarded" },
{ route: "/project/files", methods: ["GET"], kind: "json", pathGuard: true },
Expand Down
58 changes: 5 additions & 53 deletions src/app/api/grant-proposals/[id]/route.ts
Original file line number Diff line number Diff line change
@@ -1,58 +1,10 @@
import { NextResponse } from "next/server";

import {
ProjectAccessDeniedError,
resolveGrantProposal,
} from "@/lib/project-permissions";

export const dynamic = "force-dynamic";

function rejectRelayedApproval(payload: Record<string, unknown>): Response | null {
if (
payload.familiarId != null ||
payload.proposedBy != null ||
payload.claimedHumanApproval === true
) {
return NextResponse.json(
{ ok: false, error: "proposal decisions must be confirmed directly by the human" },
{ status: 403 },
);
}
return null;
}

export async function PATCH(
req: Request,
{ params: rawParams }: { params: Promise<{ id: string }> },
) {
const params = await rawParams;
let payload: Record<string, unknown>;
try {
payload = (await req.json()) as Record<string, unknown>;
} catch {
return NextResponse.json({ ok: false, error: "invalid JSON body" }, { status: 400 });
}
const rejected = rejectRelayedApproval(payload);
if (rejected) return rejected;
const decision = payload.decision === "accepted" || payload.decision === "rejected"
? payload.decision
: null;
if (!decision) {
return NextResponse.json(
{ ok: false, error: "decision must be accepted or rejected" },
{ status: 400 },
);
}
try {
const proposal = await resolveGrantProposal({
proposalId: params.id,
decision,
});
return NextResponse.json({ ok: true, proposal });
} catch (error) {
if (error instanceof ProjectAccessDeniedError) {
return NextResponse.json({ ok: false, error: error.message }, { status: error.status });
}
throw error;
}
export async function PATCH() {
return NextResponse.json(
{ ok: false, error: "proposal decisions require an authenticated human approval flow" },
{ status: 403 },
);
}
47 changes: 10 additions & 37 deletions src/app/api/grant-proposals/route.ts
Original file line number Diff line number Diff line change
@@ -1,47 +1,20 @@
import { NextResponse } from "next/server";

import {
ProjectAccessDeniedError,
createGrantProposal,
listGrantProposals,
} from "@/lib/project-permissions";
import { listGrantProposals } from "@/lib/project-permissions";

export const dynamic = "force-dynamic";

function proposalMutationDenied() {
return NextResponse.json(
{ ok: false, error: "grant proposals require an authenticated Supreme approval flow" },
{ status: 403 },
);
}

export async function GET() {
return NextResponse.json({ ok: true, proposals: await listGrantProposals() });
}
Comment thread
BunsDev marked this conversation as resolved.

export async function POST(req: Request) {
let payload: Record<string, unknown>;
try {
payload = (await req.json()) as Record<string, unknown>;
} catch {
return NextResponse.json({ ok: false, error: "invalid JSON body" }, { status: 400 });
}
const proposedBy = typeof payload.proposedBy === "string" ? payload.proposedBy.trim() : "";
const targetFamiliarId = typeof payload.targetFamiliarId === "string"
? payload.targetFamiliarId.trim()
: "";
const projectId = typeof payload.projectId === "string" ? payload.projectId.trim() : "";
if (!proposedBy || !targetFamiliarId || !projectId) {
return NextResponse.json(
{ ok: false, error: "proposedBy, targetFamiliarId, and projectId are required" },
{ status: 400 },
);
}
try {
const proposal = await createGrantProposal({
proposedBy: proposedBy,
targetFamiliarId: targetFamiliarId,
projectId: projectId,
claimedHumanApproval: payload.claimedHumanApproval === true,
});
return NextResponse.json({ ok: true, proposal }, { status: 201 });
} catch (error) {
if (error instanceof ProjectAccessDeniedError) {
return NextResponse.json({ ok: false, error: error.message }, { status: error.status });
}
throw error;
}
export async function POST() {
return proposalMutationDenied();
}
34 changes: 17 additions & 17 deletions src/app/api/project-grants/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,38 +33,38 @@ assert.match(grantsRoute, /export async function POST\(/, "project grants route
assert.match(grantsRoute, /export async function DELETE\(/, "project grants route should revoke human grants");
assert.match(
grantsRoute,
/rejectRelayedApproval\(payload\)/,
"direct grant mutations should reject actor/relayed-human fields instead of trusting familiar claims",
/directGrantMutationDenied/,
"direct grant mutations should fail closed instead of trusting request bodies",
);
assert.match(
grantsRoute,
/grantProjectToFamiliar\(\{[\s\S]*source: "human"/,
"direct grants should always be recorded with source=human",
);
assert.match(
assert.doesNotMatch(
grantsRoute,
/revokeProjectFromFamiliar/,
"direct grants route should call the revocation primitive",
/grantProjectToFamiliar|revokeProjectFromFamiliar/,
"direct grants route should not expose unauthenticated grant mutation primitives",
);

assert.match(proposalsRoute, /export async function GET\(/, "grant proposals route should list proposals");
assert.match(proposalsRoute, /export async function POST\(/, "grant proposals route should create proposals");
assert.match(
proposalsRoute,
/createGrantProposal\(\{[\s\S]*proposedBy:[\s\S]*targetFamiliarId:[\s\S]*projectId:[\s\S]*claimedHumanApproval:/,
"proposal route should pass Supreme proposal claims to the guarded core primitive",
/proposalMutationDenied/,
"proposal creation should fail closed instead of trusting caller-supplied Supreme claims",
);
assert.doesNotMatch(
proposalsRoute,
/createGrantProposal/,
"proposal route should not expose unauthenticated proposal creation",
);

assert.match(proposalItemRoute, /export async function PATCH\(/, "proposal item route should resolve proposals");
assert.match(
proposalItemRoute,
/rejectRelayedApproval\(payload\)/,
"proposal resolution should reject relayed human approval claims",
/authenticated human approval flow/,
"proposal resolution should fail closed without authenticated human approval",
);
assert.match(
assert.doesNotMatch(
proposalItemRoute,
/resolveGrantProposal\(\{[\s\S]*proposalId: params\.id[\s\S]*decision/,
"proposal item route should resolve the addressed proposal id",
/resolveGrantProposal/,
"proposal item route should not expose unauthenticated proposal resolution",
);

console.log("project-grants route.test.ts: ok");
72 changes: 10 additions & 62 deletions src/app/api/project-grants/route.ts
Original file line number Diff line number Diff line change
@@ -1,76 +1,24 @@
import { NextResponse } from "next/server";

import {
grantProjectToFamiliar,
listProjectGrants,
revokeProjectFromFamiliar,
} from "@/lib/project-permissions";
import { listProjectGrants } from "@/lib/project-permissions";

export const dynamic = "force-dynamic";

function rejectRelayedApproval(payload: Record<string, unknown>): Response | null {
if (
payload.familiarId != null ||
payload.proposedBy != null ||
payload.claimedHumanApproval === true
) {
return NextResponse.json(
{ ok: false, error: "grant changes must be confirmed directly by the human" },
{ status: 403 },
);
}
return null;
}

async function readPayload(req: Request): Promise<Record<string, unknown> | Response> {
try {
return (await req.json()) as Record<string, unknown>;
} catch {
return NextResponse.json({ ok: false, error: "invalid JSON body" }, { status: 400 });
}
}

function grantInput(payload: Record<string, unknown>) {
const targetFamiliarId = typeof payload.targetFamiliarId === "string"
? payload.targetFamiliarId.trim()
: "";
const projectId = typeof payload.projectId === "string" ? payload.projectId.trim() : "";
if (!targetFamiliarId || !projectId) return null;
return { familiarId: targetFamiliarId, projectId };
function directGrantMutationDenied() {
return NextResponse.json(
{ ok: false, error: "project grant changes require an authenticated human approval flow" },
{ status: 403 },
);
}

export async function GET() {
return NextResponse.json({ ok: true, grants: await listProjectGrants() });
}
Comment thread
BunsDev marked this conversation as resolved.

export async function POST(req: Request) {
const payload = await readPayload(req);
if (payload instanceof Response) return payload;
const rejected = rejectRelayedApproval(payload);
if (rejected) return rejected;
const input = grantInput(payload);
if (!input) {
return NextResponse.json(
{ ok: false, error: "targetFamiliarId and projectId are required" },
{ status: 400 },
);
}
await grantProjectToFamiliar({ ...input, source: "human" });
return NextResponse.json({ ok: true });
export async function POST() {
return directGrantMutationDenied();
}

export async function DELETE(req: Request) {
const payload = await readPayload(req);
if (payload instanceof Response) return payload;
const rejected = rejectRelayedApproval(payload);
if (rejected) return rejected;
const input = grantInput(payload);
if (!input) {
return NextResponse.json(
{ ok: false, error: "targetFamiliarId and projectId are required" },
{ status: 400 },
);
}
const revoked = await revokeProjectFromFamiliar(input);
return NextResponse.json({ ok: true, revoked });
export async function DELETE() {
return directGrantMutationDenied();
}
15 changes: 7 additions & 8 deletions src/lib/project-permissions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,8 @@ try {
);
assert.equal(
canAccessProject({ projectGrants: [] }, { familiarId: "supreme" }, "cave", "supreme"),
true,
"Supreme has implicit access to every project",
false,
"Supreme requires an explicit bootstrap grant",
);

await grantProjectToFamiliar({ familiarId: "nova", projectId: "cave", source: "human" });
Expand All @@ -49,12 +49,6 @@ try {
["cave"],
"project picker results are filtered server-side for normal familiars",
);
assert.deepEqual(
(await filterProjectsForFamiliar(projects, "supreme")).map((project) => project.id),
["cave", "docs"],
"Supreme sees all projects",
);

await assertProjectAccess({ familiarId: "nova" }, "cave", "chat");
await assert.rejects(
() => assertProjectAccess({ familiarId: "nova" }, "docs", "file-read"),
Expand Down Expand Up @@ -102,6 +96,11 @@ try {

await bootstrapSupremeProjectGrants(projects);
const bootstrapped = await loadProjectPermissions();
assert.deepEqual(
(await filterProjectsForFamiliar(projects, "supreme")).map((project) => project.id),
["cave", "docs"],
"Supreme sees bootstrapped projects through explicit grants",
);
assert.deepEqual(
bootstrapped.projectGrants
.filter((grant) => grant.familiarId === "supreme")
Expand Down
15 changes: 3 additions & 12 deletions src/lib/project-permissions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,11 +161,10 @@ export function canAccessProject(
file: Pick<ProjectPermissionsFile, "projectGrants">,
ctx: ProjectAccessContext,
projectId: string,
supremeFamiliarId: string,
_supremeFamiliarId: string,
Comment thread
BunsDev marked this conversation as resolved.
Outdated
): boolean {
const familiarId = ctx.familiarId?.trim();
if (!familiarId) return false;
if (familiarId === supremeFamiliarId) return true;
return file.projectGrants.some(
(grant) => grant.familiarId === familiarId && grant.projectId === projectId,
);
Expand All @@ -175,11 +174,7 @@ export async function filterProjectsForFamiliar(
projects: CaveProject[],
familiarId: string,
): Promise<CaveProject[]> {
const [permissions, config] = await Promise.all([
loadProjectPermissions(),
loadHumanPermissionConfig(),
]);
if (familiarId === config.supremeFamiliarId) return projects;
const permissions = await loadProjectPermissions();
const granted = new Set(
permissions.projectGrants
.filter((grant) => grant.familiarId === familiarId)
Expand Down Expand Up @@ -208,11 +203,7 @@ export async function assertProjectAccess(
loadHumanPermissionConfig(),
]);
const allowed = canAccessProject(permissions, ctx, projectId, config.supremeFamiliarId);
const reason = allowed
? familiarId === config.supremeFamiliarId
? "supreme"
: "grant"
: "missing-grant";
const reason = allowed ? "grant" : "missing-grant";

await appendAudit({
familiarId: familiarId || "unknown",
Expand Down