Add Bookmarks example plugin

Adds packages/plugins/examples/plugin-bookmarks-example, a new first-party
"core surface" example that exercises plugin database namespaces, local
folders, scoped API routes, and the page/sidebar/dashboard/settings UI
slots end-to-end. Registers the example as a bundled plugin in
server/src/routes/plugins.ts and excludes its workspace importer in
pnpm-workspace.yaml so PRs don't have to commit lockfile entries for it.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-05-07 12:48:34 -05:00
1 parent e400315cbf
commit 3995e9fb24
14 files changed
+1422

No files matched your search

@@ -0,0 +1,102 @@
import type { PaperclipPluginManifestV1 } from "@paperclipai/plugin-sdk";
export const PLUGIN_ID = "paperclipai.plugin-bookmarks-example";
export const BOOKMARKS_FOLDER_KEY = "bookmarks-root";
const manifest: PaperclipPluginManifestV1 = {
id: PLUGIN_ID,
apiVersion: 1,
version: "0.1.0",
displayName: "Bookmarks (Example)",
description:
"First-party example plugin: company-scoped bookmark library backed by a plugin database namespace and a local markdown folder. Demonstrates scoped API routes, local folders, dashboard widget, and a plugin page.",
author: "Paperclip",
categories: ["workspace", "ui"],
capabilities: [
"api.routes.register",
"database.namespace.migrate",
"database.namespace.read",
"database.namespace.write",
"local.folders",
"companies.read",
"ui.dashboardWidget.register",
"ui.page.register",
"ui.sidebar.register",
"instance.settings.register",
],
entrypoints: {
worker: "./dist/worker.js",
ui: "./dist/ui",
},
database: {
namespaceSlug: "bookmarks",
migrationsDir: "migrations",
},
localFolders: [
{
folderKey: BOOKMARKS_FOLDER_KEY,
displayName: "Bookmarks root",
description:
"Company-scoped local folder that stores one markdown file per bookmark. Each file uses YAML frontmatter for url, title, tags, and notes.",
access: "readWrite",
requiredDirectories: ["bookmarks"],
},
],
apiRoutes: [
{
routeKey: "list",
method: "GET",
path: "/bookmarks",
auth: "board-or-agent",
capability: "api.routes.register",
companyResolution: { from: "query", key: "companyId" },
},
{
routeKey: "create",
method: "POST",
path: "/bookmarks",
auth: "board-or-agent",
capability: "api.routes.register",
companyResolution: { from: "body", key: "companyId" },
},
{
routeKey: "delete",
method: "DELETE",
path: "/bookmarks/:slug",
auth: "board-or-agent",
capability: "api.routes.register",
companyResolution: { from: "query", key: "companyId" },
},
],
ui: {
slots: [
{
type: "page",
id: "bookmarks-page",
displayName: "Bookmarks",
exportName: "BookmarksPage",
routePath: "bookmarks",
},
{
type: "sidebar",
id: "bookmarks-sidebar-link",
displayName: "Bookmarks",
exportName: "BookmarksSidebarLink",
},
{
type: "dashboardWidget",
id: "bookmarks-widget",
displayName: "Bookmarks",
exportName: "BookmarksDashboardWidget",
},
{
type: "settingsPage",
id: "bookmarks-settings",
displayName: "Bookmarks",
exportName: "BookmarksSettingsPage",
},
],
},
};
export default manifest;
@@ -0,0 +1,359 @@
import {
usePluginAction,
usePluginData,
type PluginPageProps,
type PluginSettingsPageProps,
type PluginSidebarProps,
type PluginWidgetProps,
} from "@paperclipai/plugin-sdk/ui";
import { useMemo, useState, type CSSProperties, type FormEvent } from "react";
interface BookmarkRecord {
id: string;
companyId: string;
slug: string;
url: string;
title: string;
notes: string;
tags: string[];
filePath: string;
createdAt: string;
updatedAt: string;
}
interface BookmarkListResult {
databaseNamespace: string;
bookmarks: BookmarkRecord[];
}
const containerStyle: CSSProperties = {
display: "grid",
gap: 16,
font: "13px system-ui, sans-serif",
color: "#111827",
padding: 16,
maxWidth: 760,
};
const cardStyle: CSSProperties = {
border: "1px solid #e5e7eb",
borderRadius: 8,
padding: 12,
background: "#fff",
};
const inputStyle: CSSProperties = {
border: "1px solid #d1d5db",
borderRadius: 6,
padding: "6px 8px",
font: "inherit",
width: "100%",
};
const buttonStyle: CSSProperties = {
border: "1px solid #1f2937",
background: "#111827",
color: "#fff",
borderRadius: 6,
padding: "6px 10px",
font: "inherit",
cursor: "pointer",
};
const subtleButtonStyle: CSSProperties = {
border: "1px solid #d1d5db",
background: "#fff",
color: "#111827",
borderRadius: 6,
padding: "4px 8px",
font: "inherit",
cursor: "pointer",
};
const tagStyle: CSSProperties = {
display: "inline-block",
border: "1px solid #cbd5f5",
background: "#eef2ff",
color: "#3730a3",
borderRadius: 999,
padding: "1px 8px",
marginRight: 4,
fontSize: 11,
};
function parseTagInput(raw: string): string[] {
return raw
.split(/[\s,]+/)
.map((entry) => entry.trim())
.filter(Boolean);
}
function formatDate(value: string): string {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return date.toLocaleString();
}
function BookmarksList({
result,
onDelete,
loading,
}: {
result: BookmarkListResult | null;
onDelete: (slug: string) => Promise<void>;
loading: boolean;
}) {
if (loading) return <div>Loading bookmarks…</div>;
if (!result) return null;
if (result.bookmarks.length === 0) {
return <div style={{ color: "#6b7280" }}>No bookmarks yet.</div>;
}
return (
<div style={{ display: "grid", gap: 10 }}>
{result.bookmarks.map((bookmark) => (
<div key={bookmark.id} style={cardStyle}>
<div style={{ display: "flex", justifyContent: "space-between", gap: 12 }}>
<a
href={bookmark.url}
target="_blank"
rel="noreferrer"
style={{ color: "#1d4ed8", fontWeight: 600 }}
>
{bookmark.title}
</a>
<button
type="button"
style={subtleButtonStyle}
onClick={() => {
void onDelete(bookmark.slug);
}}
>
Delete
</button>
</div>
<div style={{ color: "#6b7280", fontSize: 12, wordBreak: "break-all" }}>{bookmark.url}</div>
{bookmark.tags.length > 0 ? (
<div style={{ marginTop: 6 }}>
{bookmark.tags.map((tag) => (
<span key={tag} style={tagStyle}>
{tag}
</span>
))}
</div>
) : null}
{bookmark.notes ? (
<div style={{ marginTop: 6, whiteSpace: "pre-wrap" }}>{bookmark.notes}</div>
) : null}
<div style={{ marginTop: 6, color: "#9ca3af", fontSize: 11 }}>
Added {formatDate(bookmark.createdAt)} · {bookmark.filePath}
</div>
</div>
))}
</div>
);
}
function BookmarksSurface({ companyId }: { companyId: string }) {
const [search, setSearch] = useState("");
const [tag, setTag] = useState("");
const [url, setUrl] = useState("");
const [title, setTitle] = useState("");
const [tagsRaw, setTagsRaw] = useState("");
const [notes, setNotes] = useState("");
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const listParams = useMemo(
() => ({ companyId, search: search.trim() || null, tag: tag.trim() || null }),
[companyId, search, tag],
);
const { data, loading, error, refresh } = usePluginData<BookmarkListResult>("list", listParams);
const create = usePluginAction("create");
const remove = usePluginAction("delete");
async function handleCreate(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setErrorMessage(null);
try {
await create({
companyId,
url,
title: title || null,
notes: notes || null,
tags: parseTagInput(tagsRaw),
});
setUrl("");
setTitle("");
setNotes("");
setTagsRaw("");
refresh();
} catch (cause) {
setErrorMessage(cause instanceof Error ? cause.message : "Failed to add bookmark");
}
}
async function handleDelete(slug: string) {
setErrorMessage(null);
try {
await remove({ companyId, slug });
refresh();
} catch (cause) {
setErrorMessage(cause instanceof Error ? cause.message : "Failed to delete bookmark");
}
}
return (
<div style={containerStyle}>
<header style={{ display: "grid", gap: 4 }}>
<h1 style={{ fontSize: 20, margin: 0 }}>Bookmarks</h1>
<div style={{ color: "#6b7280" }}>
Company-scoped bookmark library backed by a plugin database namespace and a local markdown
folder.
</div>
</header>
<form onSubmit={handleCreate} style={{ ...cardStyle, display: "grid", gap: 8 }}>
<strong>Add bookmark</strong>
<input
style={inputStyle}
required
type="url"
placeholder="https://example.com"
value={url}
onChange={(event) => setUrl(event.target.value)}
/>
<input
style={inputStyle}
placeholder="Title (defaults to URL)"
value={title}
onChange={(event) => setTitle(event.target.value)}
/>
<input
style={inputStyle}
placeholder="Tags (comma or space separated)"
value={tagsRaw}
onChange={(event) => setTagsRaw(event.target.value)}
/>
<textarea
style={{ ...inputStyle, minHeight: 60, resize: "vertical" }}
placeholder="Notes (optional)"
value={notes}
onChange={(event) => setNotes(event.target.value)}
/>
<div>
<button type="submit" style={buttonStyle}>
Save
</button>
</div>
{errorMessage ? <div style={{ color: "#b91c1c" }}>{errorMessage}</div> : null}
</form>
<div style={{ ...cardStyle, display: "grid", gap: 8 }}>
<strong>Search</strong>
<div style={{ display: "flex", gap: 8 }}>
<input
style={inputStyle}
placeholder="Search title, url, or notes"
value={search}
onChange={(event) => setSearch(event.target.value)}
/>
<input
style={{ ...inputStyle, maxWidth: 180 }}
placeholder="Filter by tag"
value={tag}
onChange={(event) => setTag(event.target.value)}
/>
</div>
</div>
{error ? <div style={{ color: "#b91c1c" }}>{error.message}</div> : null}
<BookmarksList result={data ?? null} onDelete={handleDelete} loading={loading} />
</div>
);
}
function MissingCompanyNotice({ surface }: { surface: string }) {
return (
<div style={containerStyle}>
<div style={cardStyle}>
<strong>{surface}</strong>
<div style={{ color: "#6b7280", marginTop: 4 }}>
Open this plugin from a company workspace — bookmarks are scoped per company.
</div>
</div>
</div>
);
}
export function BookmarksPage({ context }: PluginPageProps) {
if (!context.companyId) return <MissingCompanyNotice surface="Bookmarks" />;
return <BookmarksSurface companyId={context.companyId} />;
}
export function BookmarksSettingsPage({ context }: PluginSettingsPageProps) {
const companyId = context.companyId ?? "";
const { data, loading, error } = usePluginData<BookmarkListResult>(
"list",
companyId ? { companyId, limit: 1 } : undefined,
);
if (!companyId) return <MissingCompanyNotice surface="Bookmarks settings" />;
return (
<div style={containerStyle}>
<h1 style={{ fontSize: 18, margin: 0 }}>Bookmarks settings</h1>
<div style={cardStyle}>
<div style={{ display: "grid", gap: 4 }}>
<div>
<strong>Database namespace:</strong>{" "}
<code>{loading ? "…" : (data?.databaseNamespace ?? "not configured")}</code>
</div>
<div>
Configure the <code>bookmarks-root</code> local folder under the plugin Folders settings
so new bookmarks can be persisted to disk in addition to the database namespace.
</div>
{error ? <div style={{ color: "#b91c1c" }}>{error.message}</div> : null}
</div>
</div>
</div>
);
}
export function BookmarksDashboardWidget({ context }: PluginWidgetProps) {
const companyId = context.companyId;
const { data, loading, error } = usePluginData<BookmarkListResult>(
"list",
companyId ? { companyId, limit: 5 } : undefined,
);
if (!companyId) return null;
if (loading) return <div style={{ font: "12px system-ui, sans-serif" }}>Loading bookmarks…</div>;
if (error) return <div style={{ color: "#b91c1c" }}>Bookmarks error: {error.message}</div>;
const bookmarks = data?.bookmarks ?? [];
return (
<div style={{ display: "grid", gap: 6, font: "13px system-ui, sans-serif" }}>
<strong>Recent bookmarks</strong>
{bookmarks.length === 0 ? (
<div style={{ color: "#6b7280" }}>No bookmarks yet.</div>
) : (
<ul style={{ margin: 0, paddingLeft: 18 }}>
{bookmarks.map((bookmark) => (
<li key={bookmark.id}>
<a
href={bookmark.url}
target="_blank"
rel="noreferrer"
style={{ color: "#1d4ed8" }}
>
{bookmark.title}
</a>
</li>
))}
</ul>
)}
</div>
);
}
export function BookmarksSidebarLink(_props: PluginSidebarProps) {
return <span>Bookmarks</span>;
}
@@ -0,0 +1,411 @@
import { randomUUID } from "node:crypto";
import {
definePlugin,
runWorker,
type PluginApiRequestInput,
type PluginApiResponse,
type PluginContext,
} from "@paperclipai/plugin-sdk";
import { BOOKMARKS_FOLDER_KEY } from "./manifest.js";
export interface BookmarkRecord {
id: string;
companyId: string;
slug: string;
url: string;
title: string;
notes: string;
tags: string[];
filePath: string;
createdAt: string;
updatedAt: string;
}
export interface BookmarkInput {
url: string;
title?: string | null;
notes?: string | null;
tags?: string[] | null;
slug?: string | null;
}
export interface BookmarkListResult {
databaseNamespace: string;
bookmarks: BookmarkRecord[];
}
const SLUG_RESERVED = new Set(["", ".", "..", "_"]);
const MAX_TAGS = 16;
const MAX_TAG_LENGTH = 32;
const MAX_TITLE_LENGTH = 200;
const MAX_NOTES_LENGTH = 4000;
const MAX_BOOKMARKS_PER_LIST = 200;
function tableName(namespace: string): string {
return `${namespace}.bookmarks`;
}
function asNonEmptyString(value: unknown): string | null {
if (typeof value !== "string") return null;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
function normalizeUrl(value: unknown): string {
const raw = asNonEmptyString(value);
if (!raw) {
throw new Error("`url` is required");
}
let parsed: URL;
try {
parsed = new URL(raw);
} catch {
throw new Error("`url` must be a valid absolute URL");
}
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
throw new Error("`url` must use http or https");
}
return parsed.toString();
}
function normalizeTitle(value: unknown, fallback: string): string {
const raw = asNonEmptyString(value);
const candidate = raw ?? fallback;
return candidate.length > MAX_TITLE_LENGTH ? candidate.slice(0, MAX_TITLE_LENGTH) : candidate;
}
function normalizeNotes(value: unknown): string {
if (value == null) return "";
if (typeof value !== "string") {
throw new Error("`notes` must be a string when provided");
}
if (value.length > MAX_NOTES_LENGTH) {
throw new Error(`\`notes\` must be ${MAX_NOTES_LENGTH} characters or fewer`);
}
return value;
}
function normalizeTags(value: unknown): string[] {
if (value == null) return [];
if (!Array.isArray(value)) {
throw new Error("`tags` must be an array of strings when provided");
}
const seen = new Set<string>();
const tags: string[] = [];
for (const entry of value) {
if (typeof entry !== "string") {
throw new Error("`tags` must contain only strings");
}
const cleaned = entry.trim().toLowerCase();
if (!cleaned) continue;
if (cleaned.length > MAX_TAG_LENGTH) {
throw new Error(`tag "${entry}" exceeds ${MAX_TAG_LENGTH} characters`);
}
if (!/^[a-z0-9][a-z0-9_-]*$/.test(cleaned)) {
throw new Error(`tag "${entry}" must use lowercase letters, digits, hyphens, or underscores`);
}
if (seen.has(cleaned)) continue;
seen.add(cleaned);
tags.push(cleaned);
if (tags.length > MAX_TAGS) {
throw new Error(`bookmarks accept at most ${MAX_TAGS} tags`);
}
}
return tags;
}
export function deriveSlugCandidate(url: string, title: string | null): string {
const titleSource = title?.trim();
if (titleSource) {
const slug = slugify(titleSource);
if (slug) return slug;
}
try {
const parsed = new URL(url);
const base = `${parsed.hostname}${parsed.pathname}`;
const slug = slugify(base);
if (slug) return slug;
} catch {
// fall through
}
return "bookmark";
}
function slugify(input: string): string {
return input
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 60);
}
function normalizeSlug(value: unknown, fallback: string): string {
const raw = asNonEmptyString(value);
if (!raw) {
if (SLUG_RESERVED.has(fallback)) return "bookmark";
return fallback;
}
const cleaned = slugify(raw);
if (!cleaned || SLUG_RESERVED.has(cleaned)) {
throw new Error("`slug` must contain at least one alphanumeric character");
}
return cleaned;
}
function bookmarkFilePath(slug: string): string {
return `bookmarks/${slug}.md`;
}
export function renderBookmarkMarkdown(bookmark: BookmarkRecord): string {
const frontmatter = [
"---",
`slug: ${bookmark.slug}`,
`url: ${bookmark.url}`,
`title: ${escapeFrontmatterValue(bookmark.title)}`,
`tags: [${bookmark.tags.map((tag) => JSON.stringify(tag)).join(", ")}]`,
`created_at: ${bookmark.createdAt}`,
`updated_at: ${bookmark.updatedAt}`,
"---",
"",
].join("\n");
if (bookmark.notes.length === 0) return frontmatter;
return `${frontmatter}\n${bookmark.notes.trimEnd()}\n`;
}
function escapeFrontmatterValue(value: string): string {
if (/^[A-Za-z0-9 _.,:'/()&-]+$/.test(value) && !value.startsWith(" ")) {
return value;
}
return JSON.stringify(value);
}
interface BookmarkRow {
id: string;
company_id: string;
slug: string;
url: string;
title: string;
notes: string;
tags: string[];
file_path: string;
created_at: string | Date;
updated_at: string | Date;
}
function rowToBookmark(row: BookmarkRow): BookmarkRecord {
return {
id: row.id,
companyId: row.company_id,
slug: row.slug,
url: row.url,
title: row.title,
notes: row.notes,
tags: Array.isArray(row.tags) ? [...row.tags] : [],
filePath: row.file_path,
createdAt: typeof row.created_at === "string" ? row.created_at : row.created_at.toISOString(),
updatedAt: typeof row.updated_at === "string" ? row.updated_at : row.updated_at.toISOString(),
};
}
async function listBookmarks(
ctx: PluginContext,
companyId: string,
search: string | null,
tag: string | null,
limit: number,
): Promise<BookmarkRecord[]> {
const params: unknown[] = [companyId];
let where = "company_id = $1";
if (search) {
params.push(`%${search.toLowerCase()}%`);
const idx = params.length;
where += ` AND (lower(title) LIKE $${idx} OR lower(url) LIKE $${idx} OR lower(notes) LIKE $${idx})`;
}
if (tag) {
params.push(tag);
where += ` AND $${params.length} = ANY(tags)`;
}
params.push(Math.min(Math.max(limit, 1), MAX_BOOKMARKS_PER_LIST));
const rows = await ctx.db.query<BookmarkRow>(
`SELECT id, company_id, slug, url, title, notes, tags, file_path, created_at, updated_at
FROM ${tableName(ctx.db.namespace)}
WHERE ${where}
ORDER BY created_at DESC
LIMIT $${params.length}`,
params,
);
return rows.map(rowToBookmark);
}
export async function createBookmark(
ctx: PluginContext,
companyId: string,
input: BookmarkInput,
): Promise<BookmarkRecord> {
const url = normalizeUrl(input.url);
const inputTitle = asNonEmptyString(input.title);
const title = inputTitle ? normalizeTitle(inputTitle, url) : url;
const notes = normalizeNotes(input.notes);
const tags = normalizeTags(input.tags);
const fallbackSlug = deriveSlugCandidate(url, inputTitle);
const slug = input.slug != null
? normalizeSlug(input.slug, fallbackSlug)
: (SLUG_RESERVED.has(fallbackSlug) ? "bookmark" : fallbackSlug);
const id = randomUUID();
const filePath = bookmarkFilePath(slug);
const now = new Date().toISOString();
const insert = await ctx.db.execute(
`INSERT INTO ${tableName(ctx.db.namespace)}
(id, company_id, slug, url, title, notes, tags, file_path, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $9)
ON CONFLICT (company_id, slug) DO NOTHING`,
[id, companyId, slug, url, title, notes, tags, filePath, now],
);
if (insert.rowCount === 0) {
throw new Error(`Bookmark with slug "${slug}" already exists`);
}
const bookmark: BookmarkRecord = {
id,
companyId,
slug,
url,
title,
notes,
tags,
filePath,
createdAt: now,
updatedAt: now,
};
try {
await ctx.localFolders.writeTextAtomic(
companyId,
BOOKMARKS_FOLDER_KEY,
filePath,
renderBookmarkMarkdown(bookmark),
);
} catch (error) {
ctx.logger.warn("Failed to write bookmark markdown to local folder; row kept", {
slug,
error: error instanceof Error ? error.message : String(error),
});
}
return bookmark;
}
export async function deleteBookmark(
ctx: PluginContext,
companyId: string,
slug: string,
): Promise<{ deleted: boolean; slug: string }> {
const result = await ctx.db.execute(
`DELETE FROM ${tableName(ctx.db.namespace)} WHERE company_id = $1 AND slug = $2`,
[companyId, slug],
);
return { deleted: result.rowCount > 0, slug };
}
let listHandler: ((companyId: string, search: string | null, tag: string | null, limit: number) => Promise<BookmarkListResult>) | null = null;
let createHandler: ((companyId: string, input: BookmarkInput) => Promise<BookmarkRecord>) | null = null;
let deleteHandler: ((companyId: string, slug: string) => Promise<{ deleted: boolean; slug: string }>) | null = null;
const plugin = definePlugin({
async setup(ctx) {
listHandler = (companyId, search, tag, limit) =>
listBookmarks(ctx, companyId, search, tag, limit).then((bookmarks) => ({
databaseNamespace: ctx.db.namespace,
bookmarks,
}));
createHandler = (companyId, input) => createBookmark(ctx, companyId, input);
deleteHandler = (companyId, slug) => deleteBookmark(ctx, companyId, slug);
ctx.data.register("list", async (params) => {
const companyId = asNonEmptyString(params.companyId);
if (!companyId) throw new Error("companyId is required");
const search = asNonEmptyString(params.search)?.toLowerCase() ?? null;
const tag = asNonEmptyString(params.tag)?.toLowerCase() ?? null;
const limit = typeof params.limit === "number" ? params.limit : 50;
if (!listHandler) throw new Error("Bookmarks plugin not ready");
return listHandler(companyId, search, tag, limit);
});
ctx.actions.register("create", async (params) => {
const companyId = asNonEmptyString(params.companyId);
if (!companyId) throw new Error("companyId is required");
if (!createHandler) throw new Error("Bookmarks plugin not ready");
return createHandler(companyId, {
url: typeof params.url === "string" ? params.url : "",
title: typeof params.title === "string" ? params.title : null,
notes: typeof params.notes === "string" ? params.notes : null,
tags: Array.isArray(params.tags) ? (params.tags as unknown[] as string[]) : null,
slug: typeof params.slug === "string" ? params.slug : null,
});
});
ctx.actions.register("delete", async (params) => {
const companyId = asNonEmptyString(params.companyId);
const slug = asNonEmptyString(params.slug);
if (!companyId || !slug) throw new Error("companyId and slug are required");
if (!deleteHandler) throw new Error("Bookmarks plugin not ready");
return deleteHandler(companyId, slug);
});
},
async onApiRequest(input: PluginApiRequestInput): Promise<PluginApiResponse> {
switch (input.routeKey) {
case "list": {
if (!listHandler) return { status: 503, body: { error: "Bookmarks plugin not ready" } };
const search = asNonEmptyString(input.query.search)?.toLowerCase() ?? null;
const tag = asNonEmptyString(input.query.tag)?.toLowerCase() ?? null;
const limitRaw = asNonEmptyString(input.query.limit);
const parsedLimit = limitRaw ? Number.parseInt(limitRaw, 10) : 50;
const limit = Number.isFinite(parsedLimit) ? parsedLimit : 50;
return { body: await listHandler(input.companyId, search, tag, limit) };
}
case "create": {
if (!createHandler) return { status: 503, body: { error: "Bookmarks plugin not ready" } };
const body = (input.body ?? {}) as Record<string, unknown>;
try {
const bookmark = await createHandler(input.companyId, {
url: typeof body.url === "string" ? body.url : "",
title: typeof body.title === "string" ? body.title : null,
notes: typeof body.notes === "string" ? body.notes : null,
tags: Array.isArray(body.tags) ? (body.tags as unknown[] as string[]) : null,
slug: typeof body.slug === "string" ? body.slug : null,
});
return { status: 201, body: bookmark };
} catch (error) {
return {
status: 400,
body: { error: error instanceof Error ? error.message : "Invalid bookmark" },
};
}
}
case "delete": {
if (!deleteHandler) return { status: 503, body: { error: "Bookmarks plugin not ready" } };
const slug = input.params.slug;
if (!slug) return { status: 400, body: { error: "slug is required" } };
const result = await deleteHandler(input.companyId, slug);
return { status: result.deleted ? 200 : 404, body: result };
}
default:
return { status: 404, body: { error: `Unknown bookmarks route: ${input.routeKey}` } };
}
},
async onHealth() {
return {
status: "ok",
message: "Bookmarks plugin worker is running",
details: {
surfaces: ["scoped-api-route", "database-namespace", "local-folder", "page", "dashboard-widget"],
},
};
},
});
export default plugin;
runWorker(plugin, import.meta.url);