Summary
The daemon's join-request endpoints (GET /api/context-graph/{id}/join-requests, POST .../approve-join, POST .../reject-join) are not curator-gated server-side. Any caller with a valid bearer token can read pending-moderation metadata for any context graph they know the ID of, and the approve/reject handlers record the caller's address as audit metadata without verifying it matches cg.curator.
The frontend currently enforces the curator-only convention client-side (in PendingJoinRequestsSection). This is sufficient for V10's clean-slate UX (every CG payload includes cg.curator, so the frontend always has the data it needs to gate correctly), but it's a defense-in-depth gap — anyone bypassing the UI (curl, postman, a second frontend, a script) can read or act on join-request data they shouldn't have access to.
Surfaced during PR #745 (S2 Context Graph Overview finalize), Codex review sweep 6 — see #745 (comment) and the thread defending PR #745's Bug Q at #745 (comment).
Vulnerable code paths
GET — packages/cli/src/daemon/routes/context-graph.ts:898-909
// GET /api/context-graph/{id}/join-requests — list pending join requests (curator view)
const joinRequestsMatch = path.match(/^\/api\/context-graph\/([^/]+)\/join-requests$/);
if (req.method === "GET" && joinRequestsMatch) {
const contextGraphId = decodeURIComponent(joinRequestsMatch[1]);
try {
const requests = await agent.listPendingJoinRequests(contextGraphId);
return jsonResponse(res, 200, { contextGraphId, requests });
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
return jsonResponse(res, 400, { error: msg });
}
}
The handler comment says (curator view) but the handler delegates straight through to agent.listPendingJoinRequests without any caller-identity check.
Agent layer — packages/agent/src/dkg-agent.ts:13126-13152
async listPendingJoinRequests(contextGraphId: string): Promise<…> {
const cgMetaGraph = contextGraphMetaGraphUri(contextGraphId);
const DKG = 'https://dkg.network/ontology#';
const result = await this.store.query(
`SELECT ?addr ?name ?sig ?ts ?status WHERE {
GRAPH <${cgMetaGraph}> {
?req a <${DKG}JoinRequest> ;
<${DKG}agentAddress> ?addr ;
<${DKG}signature> ?sig ;
<${DKG}requestTimestamp> ?ts ;
<${DKG}requestStatus> ?status .
OPTIONAL { ?req <https://schema.org/name> ?name }
}
}`,
);
// ... no curator-identity check ...
return result.bindings.map(...).filter((r) => r.status === 'pending');
}
Plain SPARQL read against the CG meta-graph. No authorisation check.
POST handlers — packages/cli/src/daemon/routes/context-graph.ts ~911-925 (approve) and the matching reject handler
The callerAgentAddress parameter is captured for audit metadata but is not verified against the CG's curator before the underlying agent method is invoked.
What the bug means in practice
Today, with any valid bearer token:
curl /api/context-graph/{id}/join-requests returns the full list of pending requesters' addresses, signatures, timestamps, and any display names — for any CG ID the caller can construct, regardless of whether that caller has any role on the CG.
curl -X POST .../approve-join (with a forged or arbitrary callerAgentAddress) currently flips a pending request to approved without the daemon verifying the caller is the curator.
The frontend's UI-side gate is the only thing currently preventing this in normal user flows. Any second-party tool, scripted client, or future UI surface that forgets the gate will bypass it.
Proposed fix
In the daemon route handler (packages/cli/src/daemon/routes/context-graph.ts), before delegating to the agent layer for any of the join-request endpoints:
- Resolve the caller's identity from the request's bearer token (assuming the auth middleware already exposes this on
req.auth or similar — use whatever the existing pattern is).
- Load the CG's
curator field (or the canonical curator identifier the agent layer can return).
- Compare via
canonicalAgentDid (the same canonicaliser the frontend uses, exposed from @origintrail/dkg-agent or its equivalent).
- If the caller is not the curator, return
403 Forbidden with a body like { "error": "Curator role required" }.
- Otherwise, proceed to the existing handler logic.
Pseudo-code:
const joinRequestsMatch = path.match(/^\/api\/context-graph\/([^/]+)\/join-requests$/);
if (req.method === "GET" && joinRequestsMatch) {
const contextGraphId = decodeURIComponent(joinRequestsMatch[1]);
const cg = await agent.getContextGraphMetadata(contextGraphId);
const callerId = canonicalAgentDid(req.auth.agentDid ?? req.auth.agentAddress);
if (!cg?.curator || canonicalAgentDid(cg.curator) !== callerId) {
return jsonResponse(res, 403, { error: "Curator role required" });
}
// existing handler...
}
Mirror the check on the approve/reject POST handlers.
Test plan
-
Unit / handler:
GET /api/context-graph/{id}/join-requests as the curator → 200 + request list.
GET .../join-requests as a non-curator agent → 403.
GET .../join-requests with no bearer / unauthenticated → 401 or whatever the existing auth-middleware shape returns.
POST .../approve-join as the curator → 200, request flips to approved.
POST .../approve-join as a non-curator → 403, request stays pending, audit metadata not written.
- Same matrix for
POST .../reject-join.
-
Integration:
- Spin a daemon with two distinct agents A and B. Create a CG curated by A. From B's daemon credentials, attempt all three endpoints — all should 403.
- From A's credentials, all three should succeed.
-
Regression:
- Existing daemon tests for approve/reject should pass with the curator credential — verify the pre-existing tests aren't running with a non-curator identity (they may have been doing so accidentally; if they were, those tests need updating too).
Frontend follow-up after the backend fix lands
Once the route gates server-side, the frontend can re-introduce the optimistic-fetch UX that was implemented and then reverted in PR #745 (the Bug L cycle — see #745 (comment) for the history). With server-side gating in place, the frontend can:
- Attempt
listJoinRequests() in the 'unknown' curator-status state (during /api/agent/identity loading or error).
- On
403 response, hide the section cleanly.
- On
200, show the requests + approve/reject controls.
This collapses the current 3-state UX (curator / not-curator / unknown) for the join-requests surface into a 2-state UX driven by the server's authoritative answer, eliminating the "Verifying access…" panel that real curators currently see in some transient identity-loading edge cases.
Priority
Medium — defense-in-depth, not UX-blocking. The frontend's client-side gate covers the normal V10 user flow correctly; the V10 invariant (every CG payload includes cg.curator) means real curators are never stuck in client-side 'unknown' state in practice. The exposure is to non-UI clients and any future surface that doesn't re-implement the gate. Worth scheduling alongside the next round of API hardening, but not blocking PR #745 or other Context Graph workstream items.
Acceptance
Summary
The daemon's join-request endpoints (
GET /api/context-graph/{id}/join-requests,POST .../approve-join,POST .../reject-join) are not curator-gated server-side. Any caller with a valid bearer token can read pending-moderation metadata for any context graph they know the ID of, and the approve/reject handlers record the caller's address as audit metadata without verifying it matchescg.curator.The frontend currently enforces the curator-only convention client-side (in
PendingJoinRequestsSection). This is sufficient for V10's clean-slate UX (every CG payload includescg.curator, so the frontend always has the data it needs to gate correctly), but it's a defense-in-depth gap — anyone bypassing the UI (curl, postman, a second frontend, a script) can read or act on join-request data they shouldn't have access to.Surfaced during PR #745 (S2 Context Graph Overview finalize), Codex review sweep 6 — see #745 (comment) and the thread defending PR #745's Bug Q at #745 (comment).
Vulnerable code paths
GET —
packages/cli/src/daemon/routes/context-graph.ts:898-909The handler comment says
(curator view)but the handler delegates straight through toagent.listPendingJoinRequestswithout any caller-identity check.Agent layer —
packages/agent/src/dkg-agent.ts:13126-13152Plain SPARQL read against the CG meta-graph. No authorisation check.
POST handlers —
packages/cli/src/daemon/routes/context-graph.ts ~911-925(approve) and the matching reject handlerThe
callerAgentAddressparameter is captured for audit metadata but is not verified against the CG's curator before the underlying agent method is invoked.What the bug means in practice
Today, with any valid bearer token:
curl /api/context-graph/{id}/join-requestsreturns the full list of pending requesters' addresses, signatures, timestamps, and any display names — for any CG ID the caller can construct, regardless of whether that caller has any role on the CG.curl -X POST .../approve-join(with a forged or arbitrarycallerAgentAddress) currently flips a pending request to approved without the daemon verifying the caller is the curator.The frontend's UI-side gate is the only thing currently preventing this in normal user flows. Any second-party tool, scripted client, or future UI surface that forgets the gate will bypass it.
Proposed fix
In the daemon route handler (
packages/cli/src/daemon/routes/context-graph.ts), before delegating to the agent layer for any of the join-request endpoints:req.author similar — use whatever the existing pattern is).curatorfield (or the canonical curator identifier the agent layer can return).canonicalAgentDid(the same canonicaliser the frontend uses, exposed from@origintrail/dkg-agentor its equivalent).403 Forbiddenwith a body like{ "error": "Curator role required" }.Pseudo-code:
Mirror the check on the approve/reject POST handlers.
Test plan
Unit / handler:
GET /api/context-graph/{id}/join-requestsas the curator → 200 + request list.GET .../join-requestsas a non-curator agent → 403.GET .../join-requestswith no bearer / unauthenticated → 401 or whatever the existing auth-middleware shape returns.POST .../approve-joinas the curator → 200, request flips to approved.POST .../approve-joinas a non-curator → 403, request stays pending, audit metadata not written.POST .../reject-join.Integration:
Regression:
Frontend follow-up after the backend fix lands
Once the route gates server-side, the frontend can re-introduce the optimistic-fetch UX that was implemented and then reverted in PR #745 (the Bug L cycle — see #745 (comment) for the history). With server-side gating in place, the frontend can:
listJoinRequests()in the'unknown'curator-status state (during/api/agent/identityloading or error).403response, hide the section cleanly.200, show the requests + approve/reject controls.This collapses the current 3-state UX (
curator/not-curator/unknown) for the join-requests surface into a 2-state UX driven by the server's authoritative answer, eliminating the "Verifying access…" panel that real curators currently see in some transient identity-loading edge cases.Priority
Medium — defense-in-depth, not UX-blocking. The frontend's client-side gate covers the normal V10 user flow correctly; the V10 invariant (every CG payload includes
cg.curator) means real curators are never stuck in client-side'unknown'state in practice. The exposure is to non-UI clients and any future surface that doesn't re-implement the gate. Worth scheduling alongside the next round of API hardening, but not blocking PR #745 or other Context Graph workstream items.Acceptance
403for non-curators.