Skip to content

Commit 46c4826

Browse files
committed
fix(api): resolve session auth to account's first active API key on workflow deploy/bundle
1 parent d332f9c commit 46c4826

2 files changed

Lines changed: 67 additions & 4 deletions

File tree

packages/api/src/lib/ownership.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,3 +92,38 @@ export async function resolveKeyIds(c: any): Promise<string[] | undefined> {
9292
// Return undefined if no keys — empty array would produce invalid SQL `IN ()`
9393
return ids.length > 0 ? ids : undefined;
9494
}
95+
96+
/**
97+
* Resolve the api_key_id to attribute a write to.
98+
*
99+
* If the request was authenticated with an API key (sk-sl_…), returns that
100+
* key's id directly. If the request was authenticated with a session cookie
101+
* (ss-sl_…), the caller has an accountId but no apiKey — so we look up the
102+
* account's oldest active API key and return that. This lets session-scoped
103+
* chat deploys land on the user's "primary" key without prompting for key
104+
* selection in the UI.
105+
*
106+
* Returns undefined if:
107+
* - the request isn't authenticated at all (neither apiKey nor accountId)
108+
* - the account has zero active API keys (caller should 403 with NO_API_KEY)
109+
*/
110+
export async function resolveApiKeyIdForWrite(
111+
c: any,
112+
): Promise<string | undefined> {
113+
const direct = getApiKeyId(c);
114+
if (direct) return direct;
115+
116+
const accountId = getAccountId(c);
117+
if (!accountId) return undefined;
118+
119+
const row = await getDb()
120+
.selectFrom("api_keys")
121+
.select("id")
122+
.where("account_id", "=", accountId)
123+
.where("status", "=", "active")
124+
.orderBy("created_at", "asc")
125+
.limit(1)
126+
.executeTakeFirst();
127+
128+
return row?.id;
129+
}

packages/api/src/routes/workflows.ts

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,12 @@ import { VersionConflictError } from "@secondlayer/shared/errors";
2121
import { DeployWorkflowRequestSchema } from "@secondlayer/shared/schemas/workflows";
2222
import { Hono } from "hono";
2323
import { streamSSE } from "hono/streaming";
24-
import { getApiKeyId, resolveKeyIds } from "../lib/ownership.ts";
24+
import {
25+
getAccountId,
26+
getApiKeyId,
27+
resolveApiKeyIdForWrite,
28+
resolveKeyIds,
29+
} from "../lib/ownership.ts";
2530
import { InvalidJSONError } from "../middleware/error.ts";
2631

2732
const MAX_TAIL_DURATION_MS = 30 * 60 * 1000; // 30 minutes (matches logs.ts)
@@ -133,8 +138,24 @@ app.post("/", async (c) => {
133138
}
134139

135140
const parsed = DeployWorkflowRequestSchema.parse(body);
136-
const apiKeyId = getApiKeyId(c);
137-
if (!apiKeyId) return c.json({ error: "API key required" }, 401);
141+
142+
// Accept either direct API key auth (sk-sl_…) OR session cookie auth
143+
// (ss-sl_…, used by the web chat authoring loop). Session users get
144+
// attributed to their account's oldest active API key.
145+
const apiKeyId = await resolveApiKeyIdForWrite(c);
146+
if (!apiKeyId) {
147+
if (getAccountId(c)) {
148+
return c.json(
149+
{
150+
error:
151+
"This account has no active API keys. Create one in Settings before deploying workflows from chat.",
152+
code: "NO_API_KEY",
153+
},
154+
403,
155+
);
156+
}
157+
return c.json({ error: "API key required" }, 401);
158+
}
138159

139160
// Idempotency: replay cached result for dry-run-less deploys when the client
140161
// sends a clientRequestId and we've seen this exact tuple in the last 30s.
@@ -339,8 +360,15 @@ app.post("/", async (c) => {
339360
// doesn't treat "bundle" as a workflow name.
340361

341362
app.post("/bundle", async (c) => {
363+
// Bundling is a pure, stateless operation — it doesn't attribute the
364+
// result to any specific key, it just needs to know the caller is
365+
// authenticated. Accept either an explicit API key OR a session cookie
366+
// (via accountId set by requireAuth).
342367
const apiKeyId = getApiKeyId(c);
343-
if (!apiKeyId) return c.json({ error: "API key required" }, 401);
368+
const accountId = getAccountId(c);
369+
if (!apiKeyId && !accountId) {
370+
return c.json({ error: "Unauthorized" }, 401);
371+
}
344372
const origin = readOrigin(c);
345373

346374
let body: { code?: unknown };

0 commit comments

Comments
 (0)