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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions frontend/lib/agent-delegation-route-core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
*
* POST actions:
* delegate — upsert a row into `agent_delegations` (wallet_pubkey + chain_type)
* and backfill `profiles.wallet_pubkey` if null (GHB-110)
* revoke — set revoked_at = now() on the caller's row
*
* GET — returns the caller's current delegation row (or null if none).
Expand Down Expand Up @@ -49,6 +50,16 @@ export async function delegateWallet(
{ onConflict: "user_id" },
);
if (error) return { ok: false, error: "internal", detail: error.message };

// GHB-110: backfill profiles.wallet_pubkey so MCP auth can read it
// from the profiles table. Only set when currently null — don't
// overwrite an existing value.
await supabase
.from("profiles")
.update({ wallet_pubkey: input.wallet_pubkey, updated_at: now })
.eq("user_id", input.user_id)
.is("wallet_pubkey", null);

return { ok: true };
}

Expand Down
163 changes: 140 additions & 23 deletions frontend/tests/agent-delegation-route-core.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,12 +35,35 @@ type DelegationRow =
// ---------------------------------------------------------------------------

describe("delegateWallet", () => {
test("returns ok:true on successful upsert", async () => {
const supabase = {
from: vi.fn().mockReturnValue({
upsert: vi.fn().mockResolvedValue({ error: null }),
function mockSupabase(profileUpdateChain?: {
update: ReturnType<typeof vi.fn>;
eq: ReturnType<typeof vi.fn>;
is: ReturnType<typeof vi.fn>;
}) {
const updateFn = profileUpdateChain?.update ?? vi.fn();
const eqFn = profileUpdateChain?.eq ?? vi.fn();
const isFn = profileUpdateChain?.is ?? vi.fn();

// Wire the chain: from("profiles") → .update() → .eq() → .is()
// If callers provide an explicit chain we still wire defaults so
// from("agent_delegations") continues to work.
const defaultUpdate = vi.fn().mockReturnValue({ eq: vi.fn().mockReturnValue({ is: vi.fn().mockResolvedValue({ error: null }) }) });

return {
from: vi.fn((table: string) => {
if (table === "profiles" && profileUpdateChain) {
return { update: updateFn };
}
if (table === "agent_delegations") {
return { upsert: vi.fn().mockResolvedValue({ error: null }) };
}
return { update: defaultUpdate, upsert: vi.fn().mockResolvedValue({ error: null }) };
}),
} as unknown as SupabaseClient<Database>;
}

test("returns ok:true on successful upsert", async () => {
const supabase = mockSupabase();

const result = await delegateWallet(supabase, {
user_id: USER_ID,
Expand All @@ -51,14 +74,66 @@ describe("delegateWallet", () => {
expect(supabase.from).toHaveBeenCalledWith("agent_delegations");
});

test("backfills profiles.wallet_pubkey on delegation", async () => {
const updateFn = vi.fn(() => ({
eq: vi.fn(() => ({
is: vi.fn().mockResolvedValue({ error: null }),
})),
}));
const supabase = mockSupabase({ update: updateFn, eq: vi.fn(), is: vi.fn() });

await delegateWallet(supabase, {
user_id: USER_ID,
wallet_pubkey: WALLET,
});

expect(supabase.from).toHaveBeenCalledWith("profiles");
expect(updateFn).toHaveBeenCalledWith({
wallet_pubkey: WALLET,
updated_at: expect.any(String),
});
});

test("backfill targets only null wallet_pubkey", async () => {
let capturedIs: unknown;
const isFn = vi.fn().mockImplementation((col: string) => {
capturedIs = col;
return Promise.resolve({ error: null });
});
const eqFn = vi.fn().mockReturnValue({ is: isFn });
const updateFn = vi.fn().mockReturnValue({ eq: eqFn });
const supabase = mockSupabase({ update: updateFn, eq: eqFn, is: isFn });

await delegateWallet(supabase, {
user_id: USER_ID,
wallet_pubkey: WALLET,
});

expect(capturedIs).toBe("wallet_pubkey");
});

test("defaults chain_type to 'solana'", async () => {
let capturedRows: unknown;
const supabase = {
from: vi.fn().mockReturnValue({
upsert: vi.fn().mockImplementation((rows: unknown) => {
capturedRows = rows;
return Promise.resolve({ error: null });
}),
from: vi.fn((table: string) => {
if (table === "agent_delegations") {
return {
upsert: vi.fn().mockImplementation((rows: unknown) => {
capturedRows = rows;
return Promise.resolve({ error: null });
}),
};
}
if (table === "profiles") {
return {
update: vi.fn().mockReturnValue({
eq: vi.fn().mockReturnValue({
is: vi.fn().mockResolvedValue({ error: null }),
}),
}),
};
}
return {};
}),
} as unknown as SupabaseClient<Database>;

Expand All @@ -70,11 +145,25 @@ describe("delegateWallet", () => {
test("passes chain_type when provided", async () => {
let capturedRows: unknown;
const supabase = {
from: vi.fn().mockReturnValue({
upsert: vi.fn().mockImplementation((rows: unknown) => {
capturedRows = rows;
return Promise.resolve({ error: null });
}),
from: vi.fn((table: string) => {
if (table === "agent_delegations") {
return {
upsert: vi.fn().mockImplementation((rows: unknown) => {
capturedRows = rows;
return Promise.resolve({ error: null });
}),
};
}
if (table === "profiles") {
return {
update: vi.fn().mockReturnValue({
eq: vi.fn().mockReturnValue({
is: vi.fn().mockResolvedValue({ error: null }),
}),
}),
};
}
return {};
}),
} as unknown as SupabaseClient<Database>;

Expand All @@ -90,11 +179,25 @@ describe("delegateWallet", () => {
test("sets revoked_at to null on upsert", async () => {
let capturedRows: unknown;
const supabase = {
from: vi.fn().mockReturnValue({
upsert: vi.fn().mockImplementation((rows: unknown) => {
capturedRows = rows;
return Promise.resolve({ error: null });
}),
from: vi.fn((table: string) => {
if (table === "agent_delegations") {
return {
upsert: vi.fn().mockImplementation((rows: unknown) => {
capturedRows = rows;
return Promise.resolve({ error: null });
}),
};
}
if (table === "profiles") {
return {
update: vi.fn().mockReturnValue({
eq: vi.fn().mockReturnValue({
is: vi.fn().mockResolvedValue({ error: null }),
}),
}),
};
}
return {};
}),
} as unknown as SupabaseClient<Database>;

Expand All @@ -105,10 +208,24 @@ describe("delegateWallet", () => {

test("returns ok:false with detail on Supabase error", async () => {
const supabase = {
from: vi.fn().mockReturnValue({
upsert: vi.fn().mockResolvedValue({
error: { message: "FK violation" },
}),
from: vi.fn((table: string) => {
if (table === "agent_delegations") {
return {
upsert: vi.fn().mockResolvedValue({
error: { message: "FK violation" },
}),
};
}
if (table === "profiles") {
return {
update: vi.fn().mockReturnValue({
eq: vi.fn().mockReturnValue({
is: vi.fn().mockResolvedValue({ error: null }),
}),
}),
};
}
return {};
}),
} as unknown as SupabaseClient<Database>;

Expand Down