Skip to content

Commit d583416

Browse files
committed
feat(skills): user_skills table + /v1/skills CRUD (custom skills backend)
1 parent 425c113 commit d583416

14 files changed

Lines changed: 2398 additions & 0 deletions

File tree

apps/gateway-worker/src/index.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@ import {
9797
updateReplayShareRoute,
9898
} from "./replay-share-routes";
9999
import { searchWorkspaceRoute } from "./search-routes";
100+
import { createUserSkillRoute, deleteUserSkillRoute, listUserSkillsRoute } from "./skills-routes";
100101
import { clientErrorRoute, clientUserEventRoute, vitalsRoute } from "./telemetry-routes";
101102
import { listUsageDailyRoute } from "./usage-routes";
102103

@@ -310,6 +311,21 @@ export const gatewayRoutes = gatewayApp
310311
await rateLimit(c, userId, "GET /v1/threads/:threadId/replay-share");
311312
return getThreadReplayShareRoute(c.env, c.executionCtx, userId, c.req.param("threadId"));
312313
})
314+
.get("/v1/skills", async (c) => {
315+
const userId = await authenticate(c.req.raw, c.env, c.executionCtx);
316+
await rateLimit(c, userId, "GET /v1/skills");
317+
return listUserSkillsRoute(c.env, c.executionCtx, userId);
318+
})
319+
.post("/v1/skills", async (c) => {
320+
const userId = await authenticate(c.req.raw, c.env, c.executionCtx);
321+
await rateLimit(c, userId, "POST /v1/skills");
322+
return createUserSkillRoute(c.env, c.executionCtx, c.req.raw, userId);
323+
})
324+
.delete("/v1/skills/:skillId", async (c) => {
325+
const userId = await authenticate(c.req.raw, c.env, c.executionCtx);
326+
await rateLimit(c, userId, "DELETE /v1/skills/:skillId");
327+
return deleteUserSkillRoute(c.env, c.executionCtx, userId, c.req.param("skillId"));
328+
})
313329
.get("/v1/me", async (c) => {
314330
const userId = await authenticate(c.req.raw, c.env, c.executionCtx);
315331
await rateLimit(c, userId, "GET /v1/me");
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import {
2+
createDb,
3+
deleteUserSkill,
4+
listUserSkills,
5+
type UserSkillRecord,
6+
upsertUserSkill,
7+
withUserContext,
8+
} from "@cheatcode/db";
9+
import { APIError } from "@cheatcode/observability";
10+
import {
11+
CreateUserSkillSchema,
12+
type UserId,
13+
UserSkillSchema,
14+
UserSkillsResponseSchema,
15+
} from "@cheatcode/types";
16+
import { z } from "zod";
17+
import type { GatewayEnv } from "./index";
18+
19+
const IdParamSchema = z.string().uuid();
20+
const DEFAULT_SKILL_CATEGORY = "Builder & Apps";
21+
22+
function skillSummary(record: UserSkillRecord): unknown {
23+
return UserSkillSchema.parse({
24+
category: record.category,
25+
createdAt: record.createdAt.toISOString(),
26+
description: record.description,
27+
id: record.id,
28+
name: record.name,
29+
tags: record.tags,
30+
updatedAt: record.updatedAt.toISOString(),
31+
});
32+
}
33+
34+
/** `GET /v1/skills` — the caller's custom skills (body-less summaries). */
35+
export async function listUserSkillsRoute(
36+
env: GatewayEnv,
37+
ctx: ExecutionContext,
38+
userId: UserId,
39+
): Promise<Response> {
40+
const { db, close } = createDb(env.HYPERDRIVE);
41+
try {
42+
const rows = await withUserContext(db, userId, (tx) => listUserSkills(tx, userId));
43+
return Response.json(UserSkillsResponseSchema.parse({ skills: rows.map(skillSummary) }));
44+
} finally {
45+
ctx.waitUntil(close());
46+
}
47+
}
48+
49+
/**
50+
* `POST /v1/skills` — create or update (by name) a custom skill. Used by the agent's
51+
* `skill_create` tool path and the manual creation form.
52+
*/
53+
export async function createUserSkillRoute(
54+
env: GatewayEnv,
55+
ctx: ExecutionContext,
56+
request: Request,
57+
userId: UserId,
58+
): Promise<Response> {
59+
const parsed = CreateUserSkillSchema.safeParse(await request.json());
60+
if (!parsed.success) {
61+
throw new APIError(400, "invalid_request_body", "Invalid skill payload", {
62+
details: { issues: parsed.error.issues.map((issue) => issue.message) },
63+
retriable: false,
64+
});
65+
}
66+
const input = parsed.data;
67+
const { db, close } = createDb(env.HYPERDRIVE);
68+
try {
69+
const record = await withUserContext(db, userId, (tx) =>
70+
upsertUserSkill(tx, {
71+
body: input.body,
72+
category: input.category ?? DEFAULT_SKILL_CATEGORY,
73+
description: input.description,
74+
name: input.name,
75+
tags: input.tags ?? [],
76+
userId,
77+
}),
78+
);
79+
return Response.json(skillSummary(record), { status: 201 });
80+
} finally {
81+
ctx.waitUntil(close());
82+
}
83+
}
84+
85+
/** `DELETE /v1/skills/:id` — soft-delete a custom skill the caller owns. */
86+
export async function deleteUserSkillRoute(
87+
env: GatewayEnv,
88+
ctx: ExecutionContext,
89+
userId: UserId,
90+
skillId: string,
91+
): Promise<Response> {
92+
const parsed = IdParamSchema.safeParse(skillId);
93+
if (!parsed.success) {
94+
throw new APIError(400, "invalid_path_param", "Invalid skill id", { retriable: false });
95+
}
96+
const { db, close } = createDb(env.HYPERDRIVE);
97+
try {
98+
const deleted = await withUserContext(db, userId, (tx) =>
99+
deleteUserSkill(tx, userId, parsed.data),
100+
);
101+
if (!deleted) {
102+
throw new APIError(404, "not_found_skill", "Skill not found", { retriable: false });
103+
}
104+
return new Response(null, { status: 204 });
105+
} finally {
106+
ctx.waitUntil(close());
107+
}
108+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
-- User-created skills grants. Same posture as the other per-user tables: no
2+
-- row-level security (RLS stays limited to v2_provider_keys + v2_audit_log), with
3+
-- per-user isolation enforced in application code via withUserContext + userId
4+
-- filters. Skill bodies are user-authored markdown — no provider secrets at rest.
5+
--
6+
-- DSR / account deletion: the user_id foreign key cascades on delete, so removing
7+
-- a v2_users row tears down its custom skills automatically.
8+
9+
grant select, insert, update, delete on table
10+
v2_user_skills
11+
to app_worker;
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
CREATE TABLE "v2_user_skills" (
2+
"id" uuid PRIMARY KEY DEFAULT public.uuidv7() NOT NULL,
3+
"user_id" uuid NOT NULL,
4+
"name" text NOT NULL,
5+
"description" text NOT NULL,
6+
"category" text NOT NULL,
7+
"tags" jsonb DEFAULT '[]'::jsonb NOT NULL,
8+
"body" text NOT NULL,
9+
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
10+
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
11+
"deleted_at" timestamp with time zone
12+
);
13+
--> statement-breakpoint
14+
ALTER TABLE "v2_user_skills" ADD CONSTRAINT "v2_user_skills_user_id_v2_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."v2_users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
15+
CREATE INDEX "v2_user_skills_user_idx" ON "v2_user_skills" USING btree ("user_id");--> statement-breakpoint
16+
CREATE UNIQUE INDEX "v2_user_skills_user_name_idx" ON "v2_user_skills" USING btree ("user_id","name") WHERE deleted_at is null;

0 commit comments

Comments
 (0)