Skip to content

Commit eae3f53

Browse files
authored
feat(cli,api): add skillx publish command with auth and ownership validation (#4)
- Add `skillx publish` CLI command (auto-detect git remote, --path, --scan, --dry-run) - Secure Register API with authentication (API key or session) - Validate GitHub repo ownership (require write access via collaborator permission API) - Add path traversal protection on skill_path input - Handle GitHub token expiry with clear error messages - Create shared authenticate-request.ts helper (API key + session fallback) - Create validate-repo-ownership.ts (GitHub permission check) - Archive 3 completed/draft plans to plans/archived/ - Update docs (codebase-summary, system-architecture)
1 parent 7779d48 commit eae3f53

29 files changed

+698
-6
lines changed
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
/**
2+
* Shared request authentication: try API key first, fallback to session.
3+
* Returns userId or null if unauthenticated.
4+
*/
5+
6+
import { getDb } from "~/lib/db";
7+
import { apiKeys } from "~/lib/db/schema";
8+
import { eq, and, isNull } from "drizzle-orm";
9+
import { verifyApiKey } from "./api-key-utils";
10+
import { getSession } from "./session-helpers";
11+
12+
interface AuthResult {
13+
userId: string;
14+
method: "api-key" | "session";
15+
}
16+
17+
export async function authenticateRequest(
18+
request: Request,
19+
env: Env,
20+
): Promise<AuthResult | null> {
21+
// Try API key first (CLI usage)
22+
const authHeader = request.headers.get("Authorization");
23+
if (authHeader?.startsWith("Bearer ")) {
24+
const apiKeyPlaintext = authHeader.substring(7);
25+
const prefix = apiKeyPlaintext.substring(0, 8);
26+
27+
const db = getDb(env.DB);
28+
const [foundKey] = await db
29+
.select()
30+
.from(apiKeys)
31+
.where(and(eq(apiKeys.key_prefix, prefix), isNull(apiKeys.revoked_at)))
32+
.limit(1);
33+
34+
if (foundKey) {
35+
const isValid = await verifyApiKey(apiKeyPlaintext, foundKey.key_hash);
36+
if (isValid) {
37+
// Update last used timestamp (best-effort, may be dropped on Workers after response)
38+
await db.update(apiKeys)
39+
.set({ last_used_at: new Date() })
40+
.where(eq(apiKeys.id, foundKey.id));
41+
42+
return { userId: foundKey.user_id, method: "api-key" };
43+
}
44+
}
45+
}
46+
47+
// Fallback to session (web usage)
48+
const session = await getSession(request, env);
49+
if (session?.user?.id) {
50+
return { userId: session.user.id, method: "session" };
51+
}
52+
53+
return null;
54+
}
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
/**
2+
* Validate that an authenticated user has access to a GitHub repository.
3+
* Uses the user's GitHub OAuth access token from Better Auth account table.
4+
*/
5+
6+
import { getDb } from "~/lib/db";
7+
import { account } from "~/lib/db/schema";
8+
import { eq, and } from "drizzle-orm";
9+
10+
interface OwnershipResult {
11+
valid: boolean;
12+
reason?: string;
13+
}
14+
15+
export async function validateRepoOwnership(
16+
userId: string,
17+
owner: string,
18+
repo: string,
19+
db: ReturnType<typeof getDb>,
20+
): Promise<OwnershipResult> {
21+
// Look up GitHub account for this user
22+
const [ghAccount] = await db
23+
.select()
24+
.from(account)
25+
.where(and(eq(account.userId, userId), eq(account.providerId, "github")))
26+
.limit(1);
27+
28+
if (!ghAccount || !ghAccount.accessToken) {
29+
return {
30+
valid: false,
31+
reason: "No GitHub account linked. Please sign in with GitHub first.",
32+
};
33+
}
34+
35+
const githubUsername = ghAccount.accountId;
36+
37+
// Check if token might be expired
38+
if (ghAccount.accessTokenExpiresAt) {
39+
const expiresAt = new Date(ghAccount.accessTokenExpiresAt);
40+
if (expiresAt < new Date()) {
41+
return {
42+
valid: false,
43+
reason: "GitHub token expired. Please re-login with GitHub at https://skillx.sh/settings",
44+
};
45+
}
46+
}
47+
48+
// Check if user is owner (fast path — username matches repo owner)
49+
if (githubUsername.toLowerCase() === owner.toLowerCase()) {
50+
return { valid: true };
51+
}
52+
53+
// Check collaborator permission level via GitHub API (require write access)
54+
try {
55+
const res = await fetch(
56+
`https://api.github.com/repos/${owner}/${repo}/collaborators/${githubUsername}/permission`,
57+
{
58+
headers: {
59+
Authorization: `token ${ghAccount.accessToken}`,
60+
Accept: "application/vnd.github.v3+json",
61+
"User-Agent": "SkillX/1.0",
62+
},
63+
},
64+
);
65+
66+
if (res.status === 401) {
67+
return {
68+
valid: false,
69+
reason: "GitHub token expired. Please re-login with GitHub at https://skillx.sh/settings",
70+
};
71+
}
72+
73+
if (res.status === 404) {
74+
return {
75+
valid: false,
76+
reason: `User "${githubUsername}" does not have access to ${owner}/${repo}.`,
77+
};
78+
}
79+
80+
if (res.status === 403) {
81+
return {
82+
valid: false,
83+
reason: "GitHub token lacks permissions. Please re-login with GitHub.",
84+
};
85+
}
86+
87+
if (res.ok) {
88+
const data = (await res.json()) as { permission: string };
89+
const allowed = ["admin", "write"];
90+
if (allowed.includes(data.permission)) {
91+
return { valid: true };
92+
}
93+
return {
94+
valid: false,
95+
reason: `User "${githubUsername}" has "${data.permission}" access to ${owner}/${repo}. Write access required to publish.`,
96+
};
97+
}
98+
99+
return {
100+
valid: false,
101+
reason: `GitHub API returned ${res.status} when checking repo access.`,
102+
};
103+
} catch (error) {
104+
return {
105+
valid: false,
106+
reason: `Failed to verify repo ownership: ${error instanceof Error ? error.message : "unknown error"}`,
107+
};
108+
}
109+
}

apps/web/app/routes/api.skill-register.ts

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,11 @@ import { eq } from "drizzle-orm";
1414
import { fetchGitHubSkill } from "~/lib/github/fetch-github-skill";
1515
import { scanGitHubRepo } from "~/lib/github/scan-github-repo";
1616
import { indexSkill } from "~/lib/vectorize/index-skill";
17+
import { authenticateRequest } from "~/lib/auth/authenticate-request";
18+
import { validateRepoOwnership } from "~/lib/github/validate-repo-ownership";
1719

1820
const GITHUB_REPO_PATTERN = /^[a-zA-Z0-9._-]+\/[a-zA-Z0-9._-]+$/;
21+
const SAFE_PATH_PATTERN = /^[a-zA-Z0-9._\-/]+$/;
1922

2023
interface RegisterBody {
2124
owner?: string;
@@ -26,6 +29,17 @@ interface RegisterBody {
2629

2730
export async function action({ request, context }: ActionFunctionArgs) {
2831
try {
32+
const env = context.cloudflare.env as Env;
33+
34+
// Require authentication
35+
const auth = await authenticateRequest(request, env);
36+
if (!auth) {
37+
return Response.json(
38+
{ error: "Authentication required. Use API key (Authorization: Bearer) or sign in." },
39+
{ status: 401 },
40+
);
41+
}
42+
2943
const body = (await request.json()) as RegisterBody;
3044
const { owner, repo, skill_path, scan } = body;
3145

@@ -36,7 +50,25 @@ export async function action({ request, context }: ActionFunctionArgs) {
3650
);
3751
}
3852

39-
const env = context.cloudflare.env as Env;
53+
// Validate skill_path if provided (prevent path traversal)
54+
if (skill_path) {
55+
if (skill_path.includes("..") || skill_path.startsWith("/") || !SAFE_PATH_PATTERN.test(skill_path)) {
56+
return Response.json(
57+
{ error: "Invalid skill_path. Must be a relative path without '..' sequences." },
58+
{ status: 400 },
59+
);
60+
}
61+
}
62+
63+
// Validate GitHub repo ownership
64+
const db = getDb(env.DB);
65+
const ownership = await validateRepoOwnership(auth.userId, owner, repo, db);
66+
if (!ownership.valid) {
67+
return Response.json(
68+
{ error: ownership.reason || "You do not have access to this repository." },
69+
{ status: 403 },
70+
);
71+
}
4072

4173
// Mode: specific skill_path → register single subfolder skill
4274
if (skill_path) {

docs/codebase-summary.md

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@ skillx/
1111
│ │ ├── components/ # React UI components (14 files)
1212
│ │ ├── lib/
1313
│ │ │ ├── db/ # Drizzle ORM schema + database helpers
14-
│ │ │ ├── auth/ # Better Auth config + session helpers
14+
│ │ │ ├── auth/ # Better Auth config + session helpers + authenticate-request
15+
│ │ │ ├── github/ # GitHub API utilities + validate-repo-ownership
1516
│ │ │ ├── search/ # Hybrid search orchestration (5 modules)
1617
│ │ │ ├── vectorize/ # Embedding indexing (3 modules)
1718
│ │ │ └── cache/ # KV caching utilities
@@ -70,6 +71,7 @@ skillx/
7071
| `api.skill-install.ts` | API | ? | Track skill install (fire-and-forget) |
7172
| `api.usage-report.ts` | API | 99 | Log skill execution outcomes |
7273
| `api.user-api-keys.ts` | API | 133 | Create/list/revoke API keys |
74+
| `api.skill-register.ts` | API | ? | Register/publish skills from GitHub repos (auth required) |
7375
| `api.admin.seed.ts` | API | 121 | Load demo seed data |
7476
| `$.tsx` | Catch-all | 23 | 404 page |
7577

@@ -140,12 +142,22 @@ skillx/
140142
| `auth-client.ts` | React client for sessions (getSession, signIn, signOut) |
141143
| `session-helpers.ts` | `getSession(request, env)`, `requireAuth()` — request-level auth |
142144
| `api-key-utils.ts` | Hash/verify API keys (SHA-256), generate prefixes |
145+
| `authenticate-request.ts` | Unified auth: tries API key first, fallbacks to session; returns `{ userId, method }` |
143146

144147
**Flow:**
145148
1. User clicks "Sign in with GitHub"
146149
2. Better Auth → GitHub OAuth → session cookie (7d expiry)
147150
3. Routes check: `const session = await getSession(request, env)`
148151
4. Protected routes: `await requireAuth(session)` → 401 if missing
152+
5. For dual-auth endpoints (CLI + Web): `const auth = await authenticateRequest(request, env)` → try API key, fallback to session
153+
154+
### GitHub Integration (apps/web/app/lib/github)
155+
156+
| Module | Purpose |
157+
|------------------------------|---------|
158+
| `validate-repo-ownership.ts` | Verify user has write access to GitHub repo (used by skill registration) |
159+
160+
**Used by:** Register API to validate author owns the GitHub repository before publishing skills.
149161

150162
### Vectorization (apps/web/app/lib/vectorize)
151163

@@ -169,6 +181,7 @@ skillx/
169181
| `index.ts` | - | Commander.js CLI entry + command registration |
170182
| `commands/search.ts` | 86 | `skillx search "..."` → API call → table output |
171183
| `commands/use.ts` | 78 | `skillx use skill1 skill2` → fetch SKILL.md, POST install, echo to stdout |
184+
| `commands/publish.ts` | 183 | `skillx publish [owner/repo]` → register/publish skills from GitHub |
172185
| `commands/report.ts` | 90 | `skillx report` → POST usage metrics to API |
173186
| `commands/config.ts` | 91 | `skillx config set/get KEY VALUE` → local store |
174187
| `lib/api-client.ts` | 35 | HTTP client with API key auth |
@@ -179,7 +192,10 @@ skillx/
179192
npm install -g skillx-sh
180193
skillx search "data processing"
181194
skillx use skillx-search skillx-email
182-
skillx config set SKILLX_API_KEY sk_...
195+
skillx publish owner/repo # Auto-detect or explicit owner/repo
196+
skillx publish owner/repo --path path/to/skill --scan
197+
skillx publish --dry-run
198+
skillx config set api-key sk_...
183199
skillx report --outcome success --duration 1234
184200
```
185201

docs/system-architecture.md

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -281,6 +281,7 @@ Response:
281281
| POST | `/api/skills/:slug/review` | Write review |
282282
| POST | `/api/skills/:slug/favorite` | Add/remove favorite |
283283
| POST | `/api/skills/:slug/install` | Track install (fire-and-forget) |
284+
| POST | `/api/skills/register` | Register/publish skills from GitHub repo (validates write access) |
284285
| POST | `/api/report` | Report usage |
285286

286287
### User Endpoints (Session Only)
@@ -298,6 +299,59 @@ Response:
298299
|--------|------|---------|
299300
| POST | `/api/admin/seed` | Load demo data (dev only) |
300301

302+
## Skill Registration (Publish) API
303+
304+
**Endpoint:** `POST /api/skills/register`
305+
306+
**Authentication:** Required (API key or session)
307+
308+
**Request Body:**
309+
```json
310+
{
311+
"owner": "github-username",
312+
"repo": "repo-name",
313+
"skill_path": "path/to/skill", // optional: specific skill subfolder
314+
"scan": true // optional: scan entire repo for SKILL.md files
315+
}
316+
```
317+
318+
**Validation:**
319+
- User must authenticate (API key or session)
320+
- GitHub repo ownership verified (write access required)
321+
- Scans repo for SKILL.md files at specified path or root
322+
- Falls back to repo-wide scan if single skill not found
323+
324+
**Response (single skill):**
325+
```json
326+
{
327+
"skill": {
328+
"slug": "owner-skill-name",
329+
"name": "Skill Name",
330+
"author": "github-username",
331+
"description": "..."
332+
},
333+
"created": true
334+
}
335+
```
336+
337+
**Response (multi-skill scan):**
338+
```json
339+
{
340+
"skills": [
341+
{ "slug": "owner-skill-1", "name": "...", "author": "..." },
342+
{ "slug": "owner-skill-2", "name": "...", "author": "..." }
343+
],
344+
"registered": 2,
345+
"skipped": 1
346+
}
347+
```
348+
349+
**Status Codes:**
350+
- 200: Success
351+
- 401: Unauthenticated
352+
- 403: No write access to GitHub repo
353+
- 404: No SKILL.md files found
354+
301355
## Deployment Architecture
302356

303357
```

0 commit comments

Comments
 (0)