// Minimal reference WRITE support for the @pcbjam/shared lib protocol (0004). // // User libraries are created + written by the editor. The closed registry server // stores them per-user in Postgres+R2; this open reference server keeps them as // plain files under USER_LIBS_DIR, owner-namespaced, in the same per-lib layout // the read side uses: // // ///index.json { items: [...], type, name } // ///.kicad_sym a saved symbol body // ///.kicad_mod a saved footprint body // // No parsing: the editor sends fork-native bytes, stored as-is (decision: // user-saved bodies round-trip without a version shim). A user lib is a // kind-agnostic container (symbols + footprints can coexist). Owner comes from // OWNER_HEADER; absent ⇒ "default". import * as fs from "node:fs/promises"; import * as path from "node:path"; import type { Lib, LibItem } from "@pcbjam/shared"; import { extForKind } from "./libs.js"; export const DEFAULT_OWNER = "default"; // Write quotas: bound per-owner lib count and per-lib item count so an // unauthenticated caller can't create unlimited libs/items; per-body size is // capped by the server's bodyLimit (413). Overridable. const MAX_LIBS_PER_OWNER = Number(process.env.MAX_LIBS_PER_OWNER ?? 100); const MAX_ITEMS_PER_LIB = Number(process.env.MAX_ITEMS_PER_LIB ?? 1000); export interface UserLibsConfig { /** Root for owner-namespaced writable user libs; null ⇒ writes disabled. */ dir: string | null; } export function userLibsConfig(): UserLibsConfig { const dir = process.env.USER_LIBS_DIR ?? ".user-libs"; return { dir: path.resolve(process.cwd(), dir) }; } /** A safe path segment (owner slug, lib id, item name component). */ const SAFE = /^[A-Za-z0-9][A-Za-z0-9._+-]*$/; function safeSeg(s: string): string | null { return SAFE.test(s) ? s : null; } /** Derive a stable, filesystem-safe lib id from a display name. */ export function slugifyLibName(name: string): string { const slug = name .trim() .toLowerCase() .replace(/[^a-z0-9._+-]+/g, "-") .replace(/^-+|-+$/g, ""); return slug || "lib"; } interface IndexFile { type?: string; name?: string; description?: string | null; items?: { kind: string; name: string; description?: string | null; keywords?: string | null; }[]; } function libDir(cfg: UserLibsConfig, owner: string, lib: string): string | null { if (!cfg.dir) return null; const o = safeSeg(owner); const l = safeSeg(lib); if (!o || !l) return null; return path.join(cfg.dir, o, l); } async function readIndex(dir: string): Promise { try { return JSON.parse(await fs.readFile(path.join(dir, "index.json"), "utf8")); } catch { return null; } } async function writeIndex(dir: string, idx: IndexFile): Promise { await fs.writeFile(path.join(dir, "index.json"), JSON.stringify(idx, null, 2)); } export class UserLibError extends Error { constructor( public status: 400 | 404 | 409 | 429, message: string, ) { super(message); } } /** Count an owner's existing user libs (dirs with a readable index.json). */ async function ownerLibCount(cfg: UserLibsConfig, owner: string): Promise { return (await listUserLibs(cfg, owner)).length; } /** Create a user lib for an owner. Idempotent-ish: 409 if the id already exists. */ export async function createUserLib( cfg: UserLibsConfig, owner: string, name: string, ): Promise { const id = slugifyLibName(name); const dir = libDir(cfg, owner, id); if (!dir) throw new UserLibError(400, "user libs not configured or bad name"); if (await readIndex(dir)) { throw new UserLibError(409, `library "${id}" already exists`); } if ((await ownerLibCount(cfg, owner)) >= MAX_LIBS_PER_OWNER) { throw new UserLibError( 400, `library limit reached (max ${MAX_LIBS_PER_OWNER} per owner)`, ); } await fs.mkdir(dir, { recursive: true }); const idx: IndexFile = { type: "user", name, description: null, items: [] }; await writeIndex(dir, idx); return { id, name, type: "user", description: null, itemCount: 0 }; } /** List an owner's user libs. */ export async function listUserLibs( cfg: UserLibsConfig, owner: string, ): Promise { const o = cfg.dir && safeSeg(owner); if (!cfg.dir || !o) return []; const root = path.join(cfg.dir, o); let entries: import("node:fs").Dirent[]; try { entries = await fs.readdir(root, { withFileTypes: true }); } catch { return []; } const out: Lib[] = []; for (const e of entries) { if (!e.isDirectory() || e.name.startsWith(".")) continue; const idx = await readIndex(path.join(root, e.name)); if (!idx) continue; out.push({ id: e.name, name: idx.name ?? e.name, type: "user", description: idx.description ?? null, itemCount: idx.items?.length ?? 0, }); } return out; } /** List a user lib's items, or null if the lib isn't a user lib for this owner. */ export async function listUserItems( cfg: UserLibsConfig, owner: string, lib: string, ): Promise { const dir = libDir(cfg, owner, lib); if (!dir) return null; const idx = await readIndex(dir); if (!idx) return null; return (idx.items ?? []).map((i) => ({ kind: i.kind, name: i.name, description: i.description ?? null, keywords: i.keywords ?? null, })); } /** Resolve the on-disk body path for a user item (read), or null. */ export function userItemBodyPath( cfg: UserLibsConfig, owner: string, lib: string, kind: string, name: string, ): string | null { const dir = libDir(cfg, owner, lib); const ext = extForKind(kind); if (!dir || !ext || !safeSeg(name)) return null; return path.join(dir, `${name}${ext}`); } /** Write one item body (symbol or footprint) into a user lib + index it. */ export async function writeUserItem( cfg: UserLibsConfig, owner: string, lib: string, kind: string, name: string, body: string, ): Promise { const ext = extForKind(kind); if (!ext) throw new UserLibError(400, `unsupported kind "${kind}"`); const dir = libDir(cfg, owner, lib); if (!dir || !safeSeg(name)) throw new UserLibError(400, "bad lib or item name"); const idx = await readIndex(dir); if (!idx) throw new UserLibError(404, `user library "${lib}" not found`); const items = idx.items ?? []; const existing = items.find((i) => i.kind === kind && i.name === name); // Only NEW items grow the lib — overwrites of an existing item are always // allowed (the editor re-saves), so being at the cap never blocks edits. if (!existing && items.length >= MAX_ITEMS_PER_LIB) { throw new UserLibError( 429, `item limit reached (max ${MAX_ITEMS_PER_LIB} per library)`, ); } await fs.writeFile(path.join(dir, `${name}${ext}`), body, "utf8"); if (!existing) items.push({ kind, name }); idx.items = items; await writeIndex(dir, idx); return { kind, name, description: null, keywords: null }; }