Skip to content
Merged

Test #703

Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
f99277e
feat(measurement-jobs): free-tier card gate (setup mode) + instant ba…
sweetmantech Jun 16, 2026
158a1d4
Merge remote-tracking branch 'origin/main' into test
sweetmantech Jun 16, 2026
5fa5e3a
fix(songstats-backfill): backoff on 429 + defer instead of churn (cha…
sweetmantech Jun 16, 2026
1e9deac
Merge remote-tracking branch 'origin/main' into test
sweetmantech Jun 16, 2026
5f45946
refactor(songstats): remove local quota ledger + budget gate (chat#17…
sweetmantech Jun 16, 2026
5472795
Merge remote-tracking branch 'origin/main' into test
sweetmantech Jun 16, 2026
f24d4c7
feat: POST /api/catalogs (create + materialize from valuation snapsho…
sweetmantech Jun 18, 2026
ace0b01
Merge remote-tracking branch 'origin/main' into test
sweetmantech Jun 18, 2026
a520a1d
fix: LEFT-join artists in catalog-songs read (materialized tracks wer…
sweetmantech Jun 18, 2026
76b5739
Merge remote-tracking branch 'origin/main' into test
sweetmantech Jun 18, 2026
709ea0c
feat: add X (Twitter) + LinkedIn to the Composio connector whitelist …
sweetmantech Jun 18, 2026
8a3c3cb
Merge remote-tracking branch 'origin/main' into test
sweetmantech Jun 18, 2026
66cc2fe
chore: remove unused ALLOWED_ARTIST_CONNECTORS from api (chat#1793) (…
sweetmantech Jun 18, 2026
79c22da
Merge remote-tracking branch 'origin/main' into test
sweetmantech Jun 18, 2026
6fc10cc
fix: enrich valuation-captured songs (artists + notes) so they render…
sweetmantech Jun 18, 2026
0e42d02
Merge remote-tracking branch 'origin/main' into test
sweetmantech Jun 18, 2026
2fa7029
fix(tasks): let admins fetch any task by id alone (cross-account read…
sweetmantech Jun 19, 2026
bd2ae10
Merge remote-tracking branch 'origin/main' into test
sweetmantech Jun 19, 2026
cdb34d0
feat(connectors): POST /api/connectors/files — stage images for Linke…
sweetmantech Jun 20, 2026
acae4d8
Merge main into test (sync #692 Songstats work)
sweetmantech Jun 23, 2026
2a7af68
feat(artists): account_id override for DELETE /api/artists/{id} (#693)
sweetmantech Jun 23, 2026
8c962ef
Merge main into test (sync #696)
sweetmantech Jun 23, 2026
8b6a3bb
feat(chats): admins (RECOUP_ORG) can access any chat — read + write (…
sweetmantech Jun 23, 2026
03e0ef5
Enforce account_api_keys.expires_at in x-api-key auth (chat#1813) (#700)
sweetmantech Jun 24, 2026
bff29ae
Merge branch 'main' into test
sweetmantech Jun 24, 2026
78bd71b
refactor(chat): extract shared buildRunAgentInput (chat#1813) (#701)
sweetmantech Jun 24, 2026
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
54 changes: 54 additions & 0 deletions lib/auth/__tests__/getApiKeyAccountId.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
import { getApiKeyAccountId } from "@/lib/auth/getApiKeyAccountId";
import { selectAccountApiKeys } from "@/lib/supabase/account_api_keys/selectAccountApiKeys";

vi.mock("@/lib/networking/getCorsHeaders", () => ({ getCorsHeaders: () => ({}) }));
vi.mock("@/lib/keys/hashApiKey", () => ({ hashApiKey: (k: string) => `hashed_${k}` }));
vi.mock("@/lib/const", () => ({ PRIVY_PROJECT_SECRET: "test_secret" }));
vi.mock("@/lib/supabase/account_api_keys/selectAccountApiKeys", () => ({
selectAccountApiKeys: vi.fn(),
}));

function req(apiKey?: string) {
const headers = new Headers();
if (apiKey) headers.set("x-api-key", apiKey);
return new NextRequest("https://x.test/api", { headers });
}

const baseRow = {
id: "k",
account: "acc-1",
key_hash: "hashed_recoup_sk_x",
name: "n",
last_used: null,
created_at: "2026-01-01T00:00:00Z",
};

describe("getApiKeyAccountId", () => {
beforeEach(() => vi.clearAllMocks());

it("returns accountId for a non-expiring key (expires_at null)", async () => {
vi.mocked(selectAccountApiKeys).mockResolvedValue([{ ...baseRow, expires_at: null }]);
expect(await getApiKeyAccountId(req("recoup_sk_x"))).toBe("acc-1");
});

it("returns accountId for a future expiry", async () => {
const future = new Date(Date.now() + 60_000).toISOString();
vi.mocked(selectAccountApiKeys).mockResolvedValue([{ ...baseRow, expires_at: future }]);
expect(await getApiKeyAccountId(req("recoup_sk_x"))).toBe("acc-1");
});

it("rejects an expired key with 401", async () => {
const past = new Date(Date.now() - 60_000).toISOString();
vi.mocked(selectAccountApiKeys).mockResolvedValue([{ ...baseRow, expires_at: past }]);
const res = await getApiKeyAccountId(req("recoup_sk_x"));
expect(res).toBeInstanceOf(Response);
expect((res as Response).status).toBe(401);
});

it("401 when no x-api-key header", async () => {
const res = await getApiKeyAccountId(req());
expect((res as Response).status).toBe(401);
});
});
7 changes: 5 additions & 2 deletions lib/auth/getApiKeyAccountId.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import { getCorsHeaders } from "@/lib/networking/getCorsHeaders";
import { hashApiKey } from "@/lib/keys/hashApiKey";
import { isApiKeyExpired } from "@/lib/keys/isApiKeyExpired";
import { PRIVY_PROJECT_SECRET } from "@/lib/const";
import { selectAccountApiKeys } from "@/lib/supabase/account_api_keys/selectAccountApiKeys";

Expand Down Expand Up @@ -45,9 +46,11 @@ export async function getApiKeyAccountId(request: NextRequest): Promise<string |
);
}

const accountId = apiKeys[0]?.account ?? null;
const matched = apiKeys[0];
const accountId = matched?.account ?? null;

if (!accountId) {
// Reject an unknown key, or an ephemeral key past its TTL (chat#1813).
if (!accountId || isApiKeyExpired(matched?.expires_at)) {
return NextResponse.json(
{
status: "error",
Expand Down
61 changes: 61 additions & 0 deletions lib/chat/__tests__/buildRunAgentInput.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { buildRunAgentInput } from "@/lib/chat/buildRunAgentInput";
import { parseGitHubRepoIdentifiers } from "@/lib/github/parseGitHubRepoIdentifiers";
import { extractOrgId } from "@/lib/recoupable/extractOrgId";

vi.mock("@/lib/github/parseGitHubRepoIdentifiers", () => ({
parseGitHubRepoIdentifiers: vi.fn(() => ({ owner: "o", repo: "r" })),
}));
vi.mock("@/lib/recoupable/extractOrgId", () => ({
extractOrgId: vi.fn(() => "org-1"),
}));

const base = {
messages: [{ id: "m1", role: "user", parts: [] }] as never,
chatId: "chat-1",
sessionId: "sess-1",
accountId: "acc-1",
modelId: "anthropic/claude-haiku-4.5",
sessionTitle: "Weekly report",
cloneUrl: "https://github.com/o/r.git",
sandboxState: { type: "vercel", sandboxName: "sb-1" } as never,
workingDirectory: "/work",
skills: [] as never,
};

describe("buildRunAgentInput", () => {
beforeEach(() => vi.clearAllMocks());

it("assembles the workflow input, deriving repo ids + org id from cloneUrl", () => {
const input = buildRunAgentInput(base);
expect(input.chatId).toBe("chat-1");
expect(input.sessionId).toBe("sess-1");
expect(input.accountId).toBe("acc-1");
expect(input.modelId).toBe("anthropic/claude-haiku-4.5");
expect(input.sessionTitle).toBe("Weekly report");
expect(input.repoOwner).toBe("o");
expect(input.repoName).toBe("r");
expect(input.agentContext.recoupOrgId).toBe("org-1");
expect(input.agentContext.sandbox).toEqual({
state: { type: "vercel", sandboxName: "sb-1" },
workingDirectory: "/work",
});
});

it("includes recoupAccessToken when provided", () => {
const input = buildRunAgentInput({ ...base, recoupAccessToken: "tok-123" });
expect(input.agentContext.recoupAccessToken).toBe("tok-123");
});

it("omits recoupAccessToken entirely when absent", () => {
const input = buildRunAgentInput(base);
expect("recoupAccessToken" in input.agentContext).toBe(false);
});

it("leaves recoupOrgId undefined when cloneUrl is null (no org derivation)", () => {
const input = buildRunAgentInput({ ...base, cloneUrl: null });
expect(input.agentContext.recoupOrgId).toBeUndefined();
expect(extractOrgId).not.toHaveBeenCalled();
expect(parseGitHubRepoIdentifiers).toHaveBeenCalledWith(null);
});
});
66 changes: 66 additions & 0 deletions lib/chat/buildRunAgentInput.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import type { UIMessage } from "ai";
import type { RunAgentWorkflowInput } from "@/app/lib/workflows/runAgentWorkflow";
import type { DurableAgentContext } from "@/lib/agent/tools/AgentContext";
import type { VercelState } from "@/lib/sandbox/vercel/state";
import { parseGitHubRepoIdentifiers } from "@/lib/github/parseGitHubRepoIdentifiers";
import { extractOrgId } from "@/lib/recoupable/extractOrgId";

export type BuildRunAgentInputParams = {
messages: UIMessage[];
chatId: string;
sessionId: string;
accountId: string;
modelId: string;
sessionTitle?: string;
/** `session.clone_url` — the single source for repo ids + recoup org id. */
cloneUrl: string | null;
sandboxState: VercelState;
workingDirectory: string;
skills: DurableAgentContext["skills"];
/**
* Short-lived bearer for in-sandbox recoup-api calls: the user's Privy JWT
* (interactive `/api/chat/workflow`) or an ephemeral account key (headless
* `/api/chat/generate`). Omitted when absent so the service key never leaks.
*/
recoupAccessToken?: string;
};

/**
* Build the durable `RunAgentWorkflowInput` shared by the interactive and
* headless callers, so both construct workflow input identically
* (recoupable/chat#1813). Repo identifiers and the recoup org id are both
* derived from `cloneUrl` here — one source of truth, no caller duplication.
*/
export function buildRunAgentInput({
messages,
chatId,
sessionId,
accountId,
modelId,
sessionTitle,
cloneUrl,
sandboxState,
workingDirectory,
skills,
recoupAccessToken,
}: BuildRunAgentInputParams): RunAgentWorkflowInput {
const repoIds = parseGitHubRepoIdentifiers(cloneUrl);
const recoupOrgId = cloneUrl ? (extractOrgId(cloneUrl) ?? undefined) : undefined;

return {
messages,
chatId,
sessionId,
accountId,
modelId,
sessionTitle,
repoOwner: repoIds?.owner,
repoName: repoIds?.repo,
agentContext: {
sandbox: { state: sandboxState, workingDirectory },
recoupOrgId,
skills,
...(recoupAccessToken ? { recoupAccessToken } : {}),
},
};
}
46 changes: 14 additions & 32 deletions lib/chat/handleChatWorkflowStream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,7 @@ import { persistLatestUserMessage } from "@/lib/chat/persistLatestUserMessage";
import { errorResponse } from "@/lib/networking/errorResponse";
import { getCorsHeaders } from "@/lib/networking/getCorsHeaders";
import { runAgentWorkflow } from "@/app/lib/workflows/runAgentWorkflow";
import { extractOrgId } from "@/lib/recoupable/extractOrgId";
import { parseGitHubRepoIdentifiers } from "@/lib/github/parseGitHubRepoIdentifiers";
import { buildRunAgentInput } from "@/lib/chat/buildRunAgentInput";
import { DEFAULT_WORKING_DIRECTORY } from "@/lib/sandbox/vercel/sandbox/constants";
import { connectVercel } from "@/lib/sandbox/vercel/connect/connectVercel";
import type { VercelState } from "@/lib/sandbox/vercel/state";
Expand Down Expand Up @@ -92,9 +91,6 @@ export async function handleChatWorkflowStream(request: NextRequest): Promise<Re
void persistLatestUserMessage(validated.chatId, validated.messages as never);

const modelId = chat.model_id ?? DEFAULT_MODEL_ID;
const recoupOrgId = session.clone_url
? (extractOrgId(session.clone_url) ?? undefined)
: undefined;

// Connect the sandbox up-front so we can (a) read the real working
// directory and (b) discover project-level skills. The connected
Expand All @@ -119,40 +115,26 @@ export async function handleChatWorkflowStream(request: NextRequest): Promise<Re
);
}

// Derive repo identifiers from `session.clone_url` so we have a
// single source of truth. The `sessions.repo_owner/repo_name`
// columns exist in the schema but were never populated; treating
// `clone_url` as canonical avoids a denormalization where the
// columns could drift from the URL.
const repoIds = parseGitHubRepoIdentifiers(session.clone_url);

// Build the durable workflow input via the shared builder so the
// interactive and headless (/api/chat/generate) callers stay in lockstep
// (chat#1813). Repo ids + recoup org id are derived from `clone_url` inside
// the builder — the single source of truth. The short-lived Privy JWT from
// the chat UI is forwarded as `recoupAccessToken`; x-api-key callers don't
// send it, so the long-lived service key never enters model-issued bash.
const run = await start(runAgentWorkflow, [
{
buildRunAgentInput({
messages: validated.messages,
chatId: validated.chatId,
sessionId: validated.sessionId,
accountId: validated.accountId,
modelId,
sessionTitle: session.title ?? undefined,
repoOwner: repoIds?.owner,
repoName: repoIds?.repo,
agentContext: {
sandbox: {
state: session.sandbox_state as VercelState,
workingDirectory,
},
recoupOrgId,
skills,
// Forward the short-lived Privy JWT from the chat UI when
// present. The `recoup-api` skill's curl examples authenticate
// against recoup-api with this as a Bearer header (via the
// `$RECOUP_ACCESS_TOKEN` env var injected by buildRecoupExecEnv).
// x-api-key auth callers don't send this field — the long-lived
// recoup_sk_ key is deliberately NOT forwarded (exfiltration
// risk from model-issued bash).
...(validated.recoupAccessToken ? { recoupAccessToken: validated.recoupAccessToken } : {}),
},
},
cloneUrl: session.clone_url,
sandboxState: session.sandbox_state as VercelState,
workingDirectory,
skills,
recoupAccessToken: validated.recoupAccessToken,
}),
]);

// Promote placeholder → real run id via CAS. If something asynchronously
Expand Down
24 changes: 24 additions & 0 deletions lib/keys/__tests__/isApiKeyExpired.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { describe, it, expect } from "vitest";
import { isApiKeyExpired } from "@/lib/keys/isApiKeyExpired";

describe("isApiKeyExpired", () => {
const now = Date.parse("2026-06-23T00:00:00Z");

it("treats null/undefined expiry as never-expiring", () => {
expect(isApiKeyExpired(null, now)).toBe(false);
expect(isApiKeyExpired(undefined, now)).toBe(false);
});

it("is false for a future expiry", () => {
expect(isApiKeyExpired("2026-06-23T01:00:00Z", now)).toBe(false);
});

it("is true at or past the expiry", () => {
expect(isApiKeyExpired("2026-06-22T23:59:59Z", now)).toBe(true);
expect(isApiKeyExpired("2026-06-23T00:00:00Z", now)).toBe(true);
});

it("treats an unparseable expiry as non-expiring (never lock out)", () => {
expect(isApiKeyExpired("not-a-date", now)).toBe(false);
});
});
15 changes: 15 additions & 0 deletions lib/keys/isApiKeyExpired.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
/**
* Whether an api key's `expires_at` has passed. NULL/undefined = never expires.
* An unparseable value is treated as non-expiring so a bad row can't lock a
* caller out. Used by api auth to reject ephemeral keys past their TTL
* (recoupable/chat#1813).
*/
export function isApiKeyExpired(
expiresAt: string | null | undefined,
now: number = Date.now(),
): boolean {
if (!expiresAt) return false;
const exp = Date.parse(expiresAt);
if (Number.isNaN(exp)) return false;
return exp <= now;
}
3 changes: 3 additions & 0 deletions types/database.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export type Database = {
Row: {
account: string | null;
created_at: string;
expires_at: string | null;
id: string;
key_hash: string | null;
last_used: string | null;
Expand All @@ -20,6 +21,7 @@ export type Database = {
Insert: {
account?: string | null;
created_at?: string;
expires_at?: string | null;
id?: string;
key_hash?: string | null;
last_used?: string | null;
Expand All @@ -28,6 +30,7 @@ export type Database = {
Update: {
account?: string | null;
created_at?: string;
expires_at?: string | null;
id?: string;
key_hash?: string | null;
last_used?: string | null;
Expand Down
Loading