sync: update from private repo (6fcb0d0)
This commit is contained in:
+183
-47
@@ -100,6 +100,19 @@ function validateName(name: string): boolean {
|
||||
return VALID_PIECE_NAME.test(name);
|
||||
}
|
||||
|
||||
const VALID_SOURCES = new Set<string>(['builtin', 'user-custom', 'global-custom']);
|
||||
|
||||
/**
|
||||
* Returns true when `source` is absent (caller wants priority resolution).
|
||||
* Returns false when `source` is a known valid value.
|
||||
* Returns a 400 error string when `source` is present but unrecognised.
|
||||
*/
|
||||
function parseSourceParam(source: string | undefined): { valid: true; value: PieceSource | undefined } | { valid: false; error: string } {
|
||||
if (source === undefined) return { valid: true, value: undefined };
|
||||
if (VALID_SOURCES.has(source)) return { valid: true, value: source as PieceSource };
|
||||
return { valid: false, error: 'Invalid source' };
|
||||
}
|
||||
|
||||
export function findPieceFile(name: string, piecesDir: string, customPiecesDir?: string): { path: string; custom: boolean } | null {
|
||||
if (customPiecesDir) {
|
||||
const customPath = join(customPiecesDir, `${name}.yaml`);
|
||||
@@ -122,6 +135,9 @@ export interface PiecesApiOptions {
|
||||
userPiecesRootDir?: string;
|
||||
}
|
||||
|
||||
/** Canonical owner id for no-auth / legacy mode. Mirrors worker-bootstrap and piece-catalog default. */
|
||||
const LOCAL_OWNER = 'local';
|
||||
|
||||
type AuthedUser = { id: string; role?: string };
|
||||
|
||||
function getUser(req: Request): AuthedUser | undefined {
|
||||
@@ -137,6 +153,7 @@ function isAdminOrLegacy(user: AuthedUser | undefined): boolean {
|
||||
/**
|
||||
* Lookup priority for a given caller:
|
||||
* 1. Caller's own user-custom dir (overrides everything below).
|
||||
* No-auth callers use the 'local' owner id.
|
||||
* 2. Global custom dir (admin-managed, all users see).
|
||||
* 3. Built-in dir.
|
||||
*/
|
||||
@@ -145,9 +162,10 @@ function findPieceForCaller(
|
||||
user: AuthedUser | undefined,
|
||||
name: string,
|
||||
): { path: string; source: PieceSource; ownerId?: string } | null {
|
||||
if (opts.userPiecesRootDir && user) {
|
||||
const ucPath = join(userPiecesDir(opts.userPiecesRootDir, user.id), `${name}.yaml`);
|
||||
if (existsSync(ucPath)) return { path: ucPath, source: 'user-custom', ownerId: user.id };
|
||||
if (opts.userPiecesRootDir) {
|
||||
const ownerId = user?.id ?? LOCAL_OWNER;
|
||||
const ucPath = join(userPiecesDir(opts.userPiecesRootDir, ownerId), `${name}.yaml`);
|
||||
if (existsSync(ucPath)) return { path: ucPath, source: 'user-custom', ownerId };
|
||||
}
|
||||
if (opts.customPiecesDir) {
|
||||
const gcPath = join(opts.customPiecesDir, `${name}.yaml`);
|
||||
@@ -178,30 +196,31 @@ export function mountPiecesApi(
|
||||
app.get('/api/pieces', (req: Request, res: Response) => {
|
||||
try {
|
||||
const user = getUser(req);
|
||||
const seen = new Set<string>();
|
||||
const pieces: PieceSummary[] = [];
|
||||
|
||||
// Order matters: user-custom overrides global-custom, which overrides built-in.
|
||||
const sources: Array<{ dir: string; source: PieceSource; ownerId?: string }> = [];
|
||||
if (opts.userPiecesRootDir && user) {
|
||||
const ucDir = userPiecesDir(opts.userPiecesRootDir, user.id);
|
||||
if (existsSync(ucDir)) sources.push({ dir: ucDir, source: 'user-custom', ownerId: user.id });
|
||||
// Build custom sources (user-custom first, then global-custom).
|
||||
// Within custom umbrella: dedup by name so user-custom wins over global-custom.
|
||||
// Built-ins are ALWAYS emitted separately — they are never hidden by a same-named custom.
|
||||
const customSources: Array<{ dir: string; source: PieceSource; ownerId?: string }> = [];
|
||||
if (opts.userPiecesRootDir) {
|
||||
// No-auth callers use the 'local' owner id, mirroring worker and piece-catalog defaults.
|
||||
const ownerId = user?.id ?? LOCAL_OWNER;
|
||||
const ucDir = userPiecesDir(opts.userPiecesRootDir, ownerId);
|
||||
if (existsSync(ucDir)) customSources.push({ dir: ucDir, source: 'user-custom', ownerId });
|
||||
}
|
||||
if (opts.customPiecesDir && existsSync(opts.customPiecesDir)) {
|
||||
sources.push({ dir: opts.customPiecesDir, source: 'global-custom' });
|
||||
}
|
||||
if (existsSync(opts.piecesDir)) {
|
||||
sources.push({ dir: opts.piecesDir, source: 'builtin' });
|
||||
customSources.push({ dir: opts.customPiecesDir, source: 'global-custom' });
|
||||
}
|
||||
|
||||
for (const { dir, source, ownerId } of sources) {
|
||||
// Emit custom pieces (dedup within custom umbrella only).
|
||||
const seenCustom = new Set<string>();
|
||||
for (const { dir, source, ownerId } of customSources) {
|
||||
for (const f of listPieceFiles(dir)) {
|
||||
try {
|
||||
const p = loadPieceFile(f);
|
||||
const name = p.name ?? f.replace(/.*\//, '').replace('.yaml', '');
|
||||
if (seen.has(name)) continue;
|
||||
seen.add(name);
|
||||
// Drift is meaningful only for global-custom that shadows a built-in.
|
||||
if (seenCustom.has(name)) continue;
|
||||
seenCustom.add(name);
|
||||
let drift: DriftStatus | undefined;
|
||||
if (source === 'global-custom' && existsSync(opts.piecesDir)) {
|
||||
const builtinPath = join(opts.piecesDir, `${name}.yaml`);
|
||||
@@ -212,7 +231,7 @@ export function mountPiecesApi(
|
||||
description: p.description,
|
||||
triggers: p.triggers,
|
||||
requiredMcp: Array.isArray(p.required_mcp) ? p.required_mcp.filter((v: unknown): v is string => typeof v === 'string') : undefined,
|
||||
custom: source !== 'builtin',
|
||||
custom: true,
|
||||
source,
|
||||
ownerId,
|
||||
drift,
|
||||
@@ -222,6 +241,27 @@ export function mountPiecesApi(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Always emit ALL built-ins (never hidden by custom pieces of the same name).
|
||||
if (existsSync(opts.piecesDir)) {
|
||||
for (const f of listPieceFiles(opts.piecesDir)) {
|
||||
try {
|
||||
const p = loadPieceFile(f);
|
||||
const name = p.name ?? f.replace(/.*\//, '').replace('.yaml', '');
|
||||
pieces.push({
|
||||
name,
|
||||
description: p.description,
|
||||
triggers: p.triggers,
|
||||
requiredMcp: Array.isArray(p.required_mcp) ? p.required_mcp.filter((v: unknown): v is string => typeof v === 'string') : undefined,
|
||||
custom: false,
|
||||
source: 'builtin',
|
||||
});
|
||||
} catch {
|
||||
// skip malformed piece files
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ pieces });
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: `Failed to list pieces: ${e}` });
|
||||
@@ -232,7 +272,31 @@ export function mountPiecesApi(
|
||||
if (!validateName(req.params.name)) { res.status(400).json({ error: 'Invalid piece name' }); return; }
|
||||
try {
|
||||
const user = getUser(req);
|
||||
const found = findPieceForCaller(opts, user, req.params.name);
|
||||
const sourceParsed = parseSourceParam(req.query.source as string | undefined);
|
||||
if (!sourceParsed.valid) { res.status(400).json({ ok: false, error: sourceParsed.error }); return; }
|
||||
const requestedSource = sourceParsed.value;
|
||||
|
||||
let found: { path: string; source: PieceSource; ownerId?: string } | null = null;
|
||||
|
||||
if (requestedSource === 'builtin') {
|
||||
const biPath = join(opts.piecesDir, `${req.params.name}.yaml`);
|
||||
if (existsSync(biPath)) found = { path: biPath, source: 'builtin' };
|
||||
} else if (requestedSource === 'user-custom') {
|
||||
if (opts.userPiecesRootDir) {
|
||||
const ownerId = user?.id ?? LOCAL_OWNER;
|
||||
const ucPath = join(userPiecesDir(opts.userPiecesRootDir, ownerId), `${req.params.name}.yaml`);
|
||||
if (existsSync(ucPath)) found = { path: ucPath, source: 'user-custom', ownerId };
|
||||
}
|
||||
} else if (requestedSource === 'global-custom') {
|
||||
if (opts.customPiecesDir) {
|
||||
const gcPath = join(opts.customPiecesDir, `${req.params.name}.yaml`);
|
||||
if (existsSync(gcPath)) found = { path: gcPath, source: 'global-custom' };
|
||||
}
|
||||
} else {
|
||||
// No source param: priority resolution (user-custom > global-custom > builtin).
|
||||
found = findPieceForCaller(opts, user, req.params.name);
|
||||
}
|
||||
|
||||
if (!found) { res.status(404).json({ error: 'Piece not found' }); return; }
|
||||
const piece = loadPieceFile(found.path);
|
||||
res.json({
|
||||
@@ -253,7 +317,29 @@ export function mountPiecesApi(
|
||||
if (!validateName(req.params.name)) { res.status(400).json({ error: 'Invalid piece name' }); return; }
|
||||
try {
|
||||
const user = getUser(req);
|
||||
const found = findPieceForCaller(opts, user, req.params.name);
|
||||
const sourceParsed = parseSourceParam(req.query.source as string | undefined);
|
||||
if (!sourceParsed.valid) { res.status(400).json({ ok: false, error: sourceParsed.error }); return; }
|
||||
const requestedSource = sourceParsed.value;
|
||||
|
||||
let found: { path: string; source: PieceSource; ownerId?: string } | null = null;
|
||||
if (requestedSource === 'builtin') {
|
||||
const biPath = join(opts.piecesDir, `${req.params.name}.yaml`);
|
||||
if (existsSync(biPath)) found = { path: biPath, source: 'builtin' };
|
||||
} else if (requestedSource === 'user-custom') {
|
||||
if (opts.userPiecesRootDir) {
|
||||
const ownerId = user?.id ?? LOCAL_OWNER;
|
||||
const ucPath = join(userPiecesDir(opts.userPiecesRootDir, ownerId), `${req.params.name}.yaml`);
|
||||
if (existsSync(ucPath)) found = { path: ucPath, source: 'user-custom', ownerId };
|
||||
}
|
||||
} else if (requestedSource === 'global-custom') {
|
||||
if (opts.customPiecesDir) {
|
||||
const gcPath = join(opts.customPiecesDir, `${req.params.name}.yaml`);
|
||||
if (existsSync(gcPath)) found = { path: gcPath, source: 'global-custom' };
|
||||
}
|
||||
} else {
|
||||
found = findPieceForCaller(opts, user, req.params.name);
|
||||
}
|
||||
|
||||
if (!found) { res.status(404).json({ error: 'Piece not found' }); return; }
|
||||
|
||||
// Authz: built-in / global-custom → admin (or legacy no-auth); user-custom → owner (or admin).
|
||||
@@ -262,7 +348,7 @@ export function mountPiecesApi(
|
||||
res.status(403).json({ ok: false, error: 'Only admins can modify built-in or global-custom pieces' });
|
||||
return;
|
||||
}
|
||||
} else if (found.ownerId !== user?.id && !isAdminOrLegacy(user)) {
|
||||
} else if (found.ownerId !== (user?.id ?? LOCAL_OWNER) && !isAdminOrLegacy(user)) {
|
||||
// Different user's user-custom — and not admin. Should be unreachable since
|
||||
// findPieceForCaller scopes user-custom to the caller, but guard anyway.
|
||||
res.status(403).json({ ok: false, error: "Cannot modify another user's custom piece" });
|
||||
@@ -293,33 +379,55 @@ export function mountPiecesApi(
|
||||
if (error) { res.status(400).json({ ok: false, error }); return; }
|
||||
|
||||
const user = getUser(req);
|
||||
const adminOrLegacy = isAdminOrLegacy(user);
|
||||
|
||||
// Determine destination dir:
|
||||
// - admin / legacy → preserve existing behavior (write to piecesDir).
|
||||
// - non-admin user → write to their user-custom dir.
|
||||
// POST always creates in user-custom dir. "+" is always Create Custom.
|
||||
// Admins edit built-ins via PUT on existing ones, not by POST-creating new built-ins.
|
||||
// No-auth / legacy with userPiecesRootDir: use the 'local' owner id so pieces are
|
||||
// user-custom (deletable) and never pollute piecesDir.
|
||||
// No-auth / legacy WITHOUT userPiecesRootDir: fall back to global customPiecesDir or piecesDir.
|
||||
let destDir: string;
|
||||
if (adminOrLegacy) {
|
||||
destDir = opts.piecesDir;
|
||||
} else {
|
||||
if (!opts.userPiecesRootDir) {
|
||||
res.status(503).json({ ok: false, error: 'User pieces directory not configured on this server' });
|
||||
return;
|
||||
}
|
||||
destDir = userPiecesDir(opts.userPiecesRootDir, user!.id);
|
||||
let createdSource: PieceSource;
|
||||
if (opts.userPiecesRootDir) {
|
||||
// Both authenticated and no-auth go to user-custom dir when userPiecesRootDir is set.
|
||||
// Authenticated users: 503 guard below is only needed when root is NOT set.
|
||||
const ownerId = user?.id ?? LOCAL_OWNER;
|
||||
destDir = userPiecesDir(opts.userPiecesRootDir, ownerId);
|
||||
mkdirSync(destDir, { recursive: true });
|
||||
createdSource = 'user-custom';
|
||||
} else if (user) {
|
||||
// Authenticated caller but userPiecesRootDir is not configured — cannot safely write.
|
||||
res.status(503).json({ ok: false, error: 'User piece storage not configured' });
|
||||
return;
|
||||
} else if (opts.customPiecesDir) {
|
||||
// Legacy (no-auth) with global custom dir configured.
|
||||
destDir = opts.customPiecesDir;
|
||||
mkdirSync(destDir, { recursive: true });
|
||||
createdSource = 'global-custom';
|
||||
} else {
|
||||
// Pure legacy single-user: fall back to piecesDir (existing behavior).
|
||||
destDir = opts.piecesDir;
|
||||
createdSource = 'builtin';
|
||||
}
|
||||
|
||||
// Reject if any visible-to-caller piece with this name already exists
|
||||
// (built-in, global-custom, or caller's user-custom).
|
||||
// Always reject if the name collides with a built-in — Custom and Default are
|
||||
// separate namespaces. (This also covers the admin case, since admins should
|
||||
// use PUT to update an existing built-in, not POST to create a duplicate.)
|
||||
const builtinPath = join(opts.piecesDir, `${req.body.name}.yaml`);
|
||||
if (existsSync(builtinPath) && destDir !== opts.piecesDir) {
|
||||
res.status(409).json({ ok: false, error: `"${req.body.name}" は組み込み (built-in) Piece と同名です。Custom Piece には別名を付けてください。` });
|
||||
return;
|
||||
}
|
||||
|
||||
// Reject if the caller's visible piece with this name already exists
|
||||
// (global-custom or caller's own user-custom).
|
||||
if (findPieceForCaller(opts, user, req.body.name)) {
|
||||
res.status(409).json({ ok: false, error: 'Piece already exists' }); return;
|
||||
}
|
||||
|
||||
const filePath = join(destDir, `${req.body.name}.yaml`);
|
||||
writeFileSync(filePath, stringify(req.body, { lineWidth: 120 }), 'utf-8');
|
||||
logger.info(`[pieces-api] created piece=${req.body.name} dest=${destDir} actor=${user?.id ?? 'legacy'}`);
|
||||
res.status(201).json({ ok: true });
|
||||
logger.info(`[pieces-api] created piece=${req.body.name} dest=${destDir} source=${createdSource} actor=${user?.id ?? 'legacy'}`);
|
||||
res.status(201).json({ ok: true, source: createdSource });
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: `Failed to create piece: ${e}` });
|
||||
}
|
||||
@@ -327,23 +435,51 @@ export function mountPiecesApi(
|
||||
|
||||
app.delete('/api/pieces/:name', (req: Request, res: Response) => {
|
||||
if (!validateName(req.params.name)) { res.status(400).json({ error: 'Invalid piece name' }); return; }
|
||||
if (req.params.name === 'general' || req.params.name === 'chat') {
|
||||
res.status(403).json({ ok: false, error: 'Cannot delete general piece' }); return;
|
||||
}
|
||||
try {
|
||||
const user = getUser(req);
|
||||
const found = findPieceForCaller(opts, user, req.params.name);
|
||||
const sourceParsed = parseSourceParam(req.query.source as string | undefined);
|
||||
if (!sourceParsed.valid) { res.status(400).json({ ok: false, error: sourceParsed.error }); return; }
|
||||
const requestedSource = sourceParsed.value;
|
||||
|
||||
let found: { path: string; source: PieceSource; ownerId?: string } | null = null;
|
||||
if (requestedSource === 'builtin') {
|
||||
const biPath = join(opts.piecesDir, `${req.params.name}.yaml`);
|
||||
if (existsSync(biPath)) found = { path: biPath, source: 'builtin' };
|
||||
} else if (requestedSource === 'user-custom') {
|
||||
if (opts.userPiecesRootDir) {
|
||||
const ownerId = user?.id ?? LOCAL_OWNER;
|
||||
const ucPath = join(userPiecesDir(opts.userPiecesRootDir, ownerId), `${req.params.name}.yaml`);
|
||||
if (existsSync(ucPath)) found = { path: ucPath, source: 'user-custom', ownerId };
|
||||
}
|
||||
} else if (requestedSource === 'global-custom') {
|
||||
if (opts.customPiecesDir) {
|
||||
const gcPath = join(opts.customPiecesDir, `${req.params.name}.yaml`);
|
||||
if (existsSync(gcPath)) found = { path: gcPath, source: 'global-custom' };
|
||||
}
|
||||
} else {
|
||||
found = findPieceForCaller(opts, user, req.params.name);
|
||||
}
|
||||
|
||||
if (!found) { res.status(404).json({ error: 'Piece not found' }); return; }
|
||||
|
||||
// Authz mirrors PUT: built-in / global-custom → admin; user-custom → owner.
|
||||
if (found.source !== 'user-custom') {
|
||||
// Built-in (Default) pieces are non-deletable for everyone — including admins.
|
||||
// This covers general/chat and all other built-ins. Admins may still EDIT
|
||||
// built-ins via PUT; only deletion is prohibited.
|
||||
if (found.source === 'builtin') {
|
||||
res.status(403).json({ ok: false, error: 'Cannot delete a built-in (Default) piece' }); return;
|
||||
}
|
||||
|
||||
// Authz for non-builtin sources: global-custom → admin; user-custom → owner.
|
||||
if (found.source === 'global-custom') {
|
||||
if (!isAdminOrLegacy(user)) {
|
||||
res.status(403).json({ ok: false, error: 'Only admins can delete built-in or global-custom pieces' });
|
||||
res.status(403).json({ ok: false, error: 'Only admins can delete global-custom pieces' });
|
||||
return;
|
||||
}
|
||||
} else if (found.source === 'user-custom') {
|
||||
if (found.ownerId !== (user?.id ?? LOCAL_OWNER) && !isAdminOrLegacy(user)) {
|
||||
res.status(403).json({ ok: false, error: "Cannot delete another user's custom piece" });
|
||||
return;
|
||||
}
|
||||
} else if (found.ownerId !== user?.id && !isAdminOrLegacy(user)) {
|
||||
res.status(403).json({ ok: false, error: "Cannot delete another user's custom piece" });
|
||||
return;
|
||||
}
|
||||
|
||||
unlinkSync(found.path);
|
||||
|
||||
Reference in New Issue
Block a user