🚀 Socket Launch Week Day 5:Introducing Repository Access Permissions and Custom Roles.Learn more
Sign In

skill-base

Package Overview
Dependencies
Maintainers
1
Versions
45
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

skill-base - npm Package Compare versions

Comparing version
2.0.35
to
2.0.36
+126
src/utils/skill-webhook.js
/**
* Per-skill outbound webhooks: fire-and-forget POST with JSON body.
* Invalid URLs are rejected at write time; unsafe targets are dropped at send time.
*/
const net = require('node:net');
const WEBHOOK_MAX_URL_LENGTH = 2048;
const DEFAULT_TIMEOUT_MS = 10000;
function webhookTimeoutMs() {
const n = Number(process.env.SKILL_BASE_WEBHOOK_TIMEOUT_MS);
return Number.isFinite(n) && n > 0 ? Math.min(n, 60000) : DEFAULT_TIMEOUT_MS;
}
function isValidWebhookUrl(urlString) {
if (!urlString || typeof urlString !== 'string') return false;
if (urlString.length > WEBHOOK_MAX_URL_LENGTH) return false;
try {
const u = new URL(urlString);
return u.protocol === 'http:' || u.protocol === 'https:';
} catch {
return false;
}
}
function isLocalWebhookHostname(hostname) {
const normalized = String(hostname || '').toLowerCase();
return normalized === 'localhost' || normalized === '127.0.0.1' || normalized === '::1';
}
function isPrivateIpv4(hostname) {
if (net.isIP(hostname) !== 4) return false;
const [a, b] = hostname.split('.').map((part) => Number(part));
if (a === 127) return false;
if (a === 10) return true;
if (a === 192 && b === 168) return true;
if (a === 172 && b >= 16 && b <= 31) return true;
if (a === 169 && b === 254) return true;
if (a === 0) return true;
return false;
}
function isBlockedIpv6(hostname) {
if (net.isIP(hostname) !== 6) return false;
const normalized = hostname.toLowerCase();
if (normalized === '::1') return false;
return normalized === '::' || normalized.startsWith('fe8') || normalized.startsWith('fe9') || normalized.startsWith('fea') || normalized.startsWith('feb') || normalized.startsWith('fc') || normalized.startsWith('fd');
}
function isSafeWebhookTarget(urlString) {
if (!isValidWebhookUrl(urlString)) return false;
const { hostname } = new URL(urlString);
if (isLocalWebhookHostname(hostname)) return true;
if (isPrivateIpv4(hostname)) return false;
if (isBlockedIpv6(hostname)) return false;
return true;
}
function canViewSkillWebhook(currentUser, permission) {
if (!currentUser) return false;
if (currentUser.role === 'admin') return true;
return permission === 'owner';
}
/**
* @param {unknown} raw from JSON body
* @returns {{ ok: true, value: string | null, omit: boolean } | { ok: false, detail: string }}
*/
function parseWebhookUrlField(raw) {
if (raw === undefined) return { ok: true, value: null, omit: true };
if (raw === null || raw === '') return { ok: true, value: null, omit: false };
const s = String(raw).trim();
if (!s) return { ok: true, value: null, omit: false };
if (!isValidWebhookUrl(s)) {
return { ok: false, detail: 'webhook_url must be http(s) URL' };
}
return { ok: true, value: s, omit: false };
}
async function postWebhook(url, payload) {
const controller = new AbortController();
const t = setTimeout(() => controller.abort(), webhookTimeoutMs());
try {
await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
signal: controller.signal
});
} finally {
clearTimeout(t);
}
}
/**
* @param {object | null | undefined} skillRow must include webhook_url when configured
* @param {'skill.updated' | 'skill.deleted'} event
* @param {object} data
* @param {{ id: number, username?: string } | null} actor
*/
function notifySkillWebhook(skillRow, event, data, actor) {
const url = skillRow && skillRow.webhook_url;
if (!url || !isValidWebhookUrl(url) || !isSafeWebhookTarget(url)) return;
const payload = {
event,
skill_id: skillRow.id,
timestamp: new Date().toISOString(),
actor: actor && actor.id != null
? { id: actor.id, username: actor.username || null }
: null,
data
};
setImmediate(() => {
postWebhook(url, payload).catch(() => {});
});
}
module.exports = {
isValidWebhookUrl,
isSafeWebhookTarget,
canViewSkillWebhook,
parseWebhookUrlField,
notifySkillWebhook
};

Sorry, the diff of this file is too big to display

Sorry, the diff of this file is too big to display

+30
-0

@@ -299,2 +299,32 @@ # Skill Base

### PM2
全局安装 [PM2](https://pm2.keymetrics.io/) 后,可用 `npx` 启动 Skill Base(参数与上文 `npx skill-base` 一致)。下面 `-d` 使用 `./skill-data`,与上文快速开始一致;需要时也可换成绝对路径。
```bash
pnpm add -g pm2
pm2 start npx --name skill-base -- -y skill-base -d ./skill-data -p 8000
```
`-y` 传给 `npx`,避免在非交互环境下卡住。
若从本仓库源码运行,在仓库根目录执行,直接启动 CLI 入口:
```bash
pm2 start bin/skill-base.js --name skill-base -- -d ./skill-data -p 8000
```
环境变量写法与普通 Shell 一致(例如使用 SQLite 存储 Session):
```bash
SESSION_STORE=sqlite pm2 start npx --name skill-base -- -y skill-base -d ./skill-data -p 8000
```
持久化进程列表并在开机时自动拉起:
```bash
pm2 save
pm2 startup
```
### 备份

@@ -301,0 +331,0 @@

+1
-1
{
"name": "skill-base",
"version": "2.0.35",
"version": "2.0.36",
"description": "Skill Base - A lightweight, privately deployable agent skills distribution hub",

@@ -5,0 +5,0 @@ "main": "src/index.js",

@@ -301,2 +301,30 @@ # Skill Base

### PM2
Install [PM2](https://pm2.keymetrics.io/) globally, then start Skill Base with `npx` (same flags as above). Below uses `./skill-data` like **Quick start**; use an absolute path if you prefer.
```bash
pnpm add -g pm2
pm2 start npx --name skill-base -- -y skill-base -d ./skill-data -p 8000
```
If you cloned this repository and run from source, start the CLI entry from the project root:
```bash
pm2 start bin/skill-base.js --name skill-base -- -d ./skill-data -p 8000
```
Environment variables work like any shell command (for example SQLite-backed sessions):
```bash
SESSION_STORE=sqlite pm2 start npx --name skill-base -- -y skill-base -d ./skill-data -p 8000
```
For a persistent process list and boot-time restore:
```bash
pm2 save
pm2 startup
```
### Backup

@@ -303,0 +331,0 @@

@@ -359,2 +359,3 @@ const { execFileSync } = require('child_process');

try { db.exec("ALTER TABLE skill_versions ADD COLUMN description TEXT"); } catch(e) {}
try { db.exec('ALTER TABLE skills ADD COLUMN webhook_url TEXT'); } catch (e) {}

@@ -361,0 +362,0 @@ // Data migration: insert skill_collaborators record for existing Skills owners

@@ -61,4 +61,4 @@ const db = require('../database');

// Update Skill
update(id, name, description) {
// Update Skill (webhookUrl: undefined = leave column unchanged; null or '' clears)
update(id, name, description, webhookUrl) {
const fields = [];

@@ -74,2 +74,6 @@ const values = [];

}
if (webhookUrl !== undefined) {
fields.push('webhook_url = ?');
values.push(webhookUrl);
}
if (fields.length === 0) return this.findById(id);

@@ -76,0 +80,0 @@

@@ -5,2 +5,3 @@ const db = require('../database');

const { invalidateSkill } = require('../utils/model-cache');
const { notifySkillWebhook } = require('../utils/skill-webhook');

@@ -224,4 +225,4 @@ async function collaboratorsRoutes(fastify, options) {

// Check if Skill exists
const skill = db.prepare('SELECT id FROM skills WHERE id = ?').get(skill_id);
// Check if Skill exists (name + webhook_url for outbound notify)
const skill = db.prepare('SELECT id, name, webhook_url FROM skills WHERE id = ?').get(skill_id);
if (!skill) {

@@ -249,3 +250,10 @@ return reply.code(404).send({ ok: false, error: 'not_found', detail: 'Skill not found' });

invalidateSkill(skill_id);
notifySkillWebhook(
skill,
'skill.deleted',
{ name: skill.name, versions_count: versionsCount },
request.user
);
// Delete files from filesystem

@@ -252,0 +260,0 @@ const fs = require('fs');

@@ -8,2 +8,3 @@ const fs = require('fs');

const { canManageSkill } = require('../utils/permission');
const { parseWebhookUrlField, notifySkillWebhook, canViewSkillWebhook } = require('../utils/skill-webhook');

@@ -43,3 +44,3 @@ function listCollaboratorUsersForSkillDetail(skillId) {

if (currentUser) {
if (currentUser.id === skill.owner_id) {
if (currentUser.role === 'admin' || currentUser.id === skill.owner_id) {
result.permission = 'owner';

@@ -58,2 +59,6 @@ } else {

if (canViewSkillWebhook(currentUser, result.permission)) {
result.webhook_url = skill.webhook_url || null;
}
return result;

@@ -168,3 +173,3 @@ }

const { skill_id } = request.params;
const { name, description } = request.body || {};
const { name, description, webhook_url: webhookUrlRaw } = request.body || {};

@@ -179,3 +184,23 @@ if (!SkillModel.exists(skill_id)) {

const updated = SkillModel.update(skill_id, name, description);
const parsed = parseWebhookUrlField(webhookUrlRaw);
if (!parsed.ok) {
return reply.code(400).send({ detail: parsed.detail });
}
const prev = SkillModel.findById(skill_id);
const webhookColumn = parsed.omit ? undefined : parsed.value;
const updated = SkillModel.update(skill_id, name, description, webhookColumn);
const metaChanged =
(name !== undefined && String(name) !== String(prev.name)) ||
(description !== undefined && String(description ?? '') !== String(prev.description ?? ''));
if (metaChanged) {
notifySkillWebhook(
updated,
'skill.updated',
{ kind: 'metadata', name: updated.name, description: updated.description },
request.user
);
}
return formatSkill(updated, request.user);

@@ -209,2 +234,8 @@ });

SkillModel.updateLatestVersion(skill_id, version);
notifySkillWebhook(
SkillModel.findById(skill_id),
'skill.updated',
{ kind: 'head', latest_version: version },
request.user
);
return { ok: true, skill_id, latest_version: version };

@@ -234,2 +265,8 @@ });

const updated = VersionModel.update(versionRecord.id, description, changelog);
notifySkillWebhook(
SkillModel.findById(skill_id),
'skill.updated',
{ kind: 'version_metadata', version },
request.user
);
return formatVersion(updated);

@@ -236,0 +273,0 @@ });

@@ -8,2 +8,3 @@ const fs = require('fs');

const { canPublishSkill } = require('./permission');
const { notifySkillWebhook } = require('./skill-webhook');

@@ -55,13 +56,34 @@ /**

const zipPath = getZipPath(skill_id, version);
fs.writeFileSync(zipPath, zipBuffer);
try {
fs.writeFileSync(zipPath, zipBuffer, { flag: 'wx' });
} catch (error) {
if (error && error.code === 'EEXIST') {
return {
ok: false,
status: 409,
body: {
ok: false,
error: 'version_conflict',
detail: 'A version with the same timestamp already exists; please retry publish'
}
};
}
throw error;
}
const zipRelativePath = getZipRelativePath(skill_id, version);
const versionRecord = VersionModel.create(
skill_id,
version,
changelog || '',
zipRelativePath,
user.id,
description || ''
);
let versionRecord;
try {
versionRecord = VersionModel.create(
skill_id,
version,
changelog || '',
zipRelativePath,
user.id,
description || ''
);
} catch (error) {
fs.rmSync(zipPath, { force: true });
throw error;
}

@@ -71,2 +93,9 @@ SkillModel.updateLatestVersion(skill_id, version);

notifySkillWebhook(
SkillModel.findById(skill_id),
'skill.updated',
{ kind: 'version_published', version, created_at: versionRecord.created_at },
user
);
return {

@@ -73,0 +102,0 @@ ok: true,

@@ -12,4 +12,4 @@ <!DOCTYPE html>

<title>Skill Base</title>
<script type="module" crossorigin src="./assets/index-B-C8F7ej.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-D3enjG_f.css">
<script type="module" crossorigin src="./assets/index-HwIh4LJF.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-De_i-3yU.css">
</head>

@@ -16,0 +16,0 @@ <body>

Sorry, the diff of this file is too big to display

Sorry, the diff of this file is too big to display