From fd2250b52df2c14266f9c0601249b2ec98ed296e Mon Sep 17 00:00:00 2001 From: tianma Date: Fri, 26 Jun 2026 23:52:16 +0800 Subject: [PATCH] feat: add agent tokens and MCP CLI access --- apps/api/src/index.ts | 990 ++++++++++++++++++++++++++++++++- apps/web/src/app/App.tsx | 389 ++++++++++++- apps/web/src/lib/api.ts | 49 ++ package.json | 1 + packages/shared/src/schemas.ts | 12 + packages/shared/src/types.ts | 21 + scripts/edgeever.mjs | 172 ++++++ 7 files changed, 1627 insertions(+), 7 deletions(-) create mode 100644 scripts/edgeever.mjs diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 4fbdeb4a..bcdfa9f4 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -3,14 +3,18 @@ import { docToMarkdown, docToText, emptyDoc, + ApiTokenCreateSchema, LoginSchema, markdownToDoc, MemoCreateSchema, MemoUpdateSchema, MergeMemosSchema, normalizeTags, + TagRenameSchema, NotebookCreateSchema, NotebookUpdateSchema, + type ApiToken, + type CreatedApiToken, type MemoDetail, type MemoRevision, type MemoSummary, @@ -18,6 +22,7 @@ import { type Resource, type ResourceListItem, type ResourceStorageSummary, + type TagSummary, type TiptapDoc, } from "@edgeever/shared"; import { zValidator } from "@hono/zod-validator"; @@ -41,6 +46,7 @@ type AuthContext = { actorId: string | null; username: string; displayName: string | null; + scopes: string[]; sessionId?: string; tokenId?: string; }; @@ -116,7 +122,23 @@ type ApiTokenRow = { id: string; name: string; scopes_json: string; + last_used_at: string | null; expires_at: string | null; + is_revoked: number; + created_at: string; +}; + +type TagSummaryRow = { + name: string; + memo_count: number; + updated_at: string | null; +}; + +type MemoTagUpdateRow = { + id: string; + title: string | null; + tags_json: string; + content_text: string; }; type ResourceRow = { @@ -161,6 +183,19 @@ const DEFAULT_SESSION_TTL_DAYS = 30; const DEFAULT_R2_BUCKET_NAME = "edgeever-resources"; const MAX_IMAGE_UPLOAD_BYTES = 10 * 1024 * 1024; const REVISION_SNAPSHOT_INTERVAL_MS = 5 * 60 * 1000; +const API_TOKEN_BYTES = 32; +const API_TOKEN_PREFIX = "eev"; +const ALL_TOKEN_SCOPES = [ + "read:notebooks", + "write:notebooks", + "read:memos", + "write:memos", + "read:resources", + "write:resources", + "read:tags", + "write:tags", +] as const; +type TokenScope = (typeof ALL_TOKEN_SCOPES)[number]; const SUPPORTED_IMAGE_MIME_TYPES = new Set([ "image/png", "image/jpeg", @@ -181,6 +216,16 @@ app.use( }) ); +app.use( + "/mcp", + cors({ + origin: ["http://127.0.0.1:5173", "http://localhost:5173"], + allowHeaders: ["Content-Type", "Authorization"], + allowMethods: ["GET", "POST", "OPTIONS"], + credentials: true, + }) +); + app.get("/api/health", (c) => c.json({ ok: true, @@ -279,6 +324,7 @@ app.use("/api/v1/*", async (c, next) => { actorId: null, username: "owner", displayName: "Owner", + scopes: [], }); await next(); return; @@ -294,7 +340,91 @@ app.use("/api/v1/*", async (c, next) => { await next(); }); +app.get("/api/v1/api-tokens", async (c) => { + const userOnly = requireUser(c); + + if (userOnly) { + return userOnly; + } + + const rows = await c.env.DB.prepare( + `SELECT id, name, scopes_json, last_used_at, expires_at, is_revoked, created_at + FROM api_tokens + ORDER BY is_revoked ASC, created_at DESC + LIMIT 200` + ).all(); + + return c.json({ + apiTokens: rows.results.map(mapApiToken), + availableScopes: ALL_TOKEN_SCOPES, + }); +}); + +app.post("/api/v1/api-tokens", zValidator("json", ApiTokenCreateSchema), async (c) => { + const userOnly = requireUser(c); + + if (userOnly) { + return userOnly; + } + + const input = c.req.valid("json"); + const scopes = normalizeTokenScopes(input.scopes); + + if (!scopes) { + return badRequest(c, "Token scope is not supported."); + } + + const id = createId("tok"); + const token = `${API_TOKEN_PREFIX}_${randomToken(API_TOKEN_BYTES)}`; + const now = isoNow(); + const actor = getAuditActor(c); + + await c.env.DB.batch([ + c.env.DB.prepare( + `INSERT INTO api_tokens (id, name, token_hash, scopes_json, expires_at, created_at) + VALUES (?, ?, ?, ?, ?, ?)` + ).bind(id, input.name, await sha256(token), JSON.stringify(scopes), input.expiresAt ?? null, now), + auditStatement(c.env.DB, actor.actorType, actor.actorId, "api_token.create", "api_token", id, { + name: input.name, + scopes, + expiresAt: input.expiresAt ?? null, + }), + ]); + + const row = await getApiTokenRow(c.env.DB, id); + + if (!row) { + return notFound(c, "API token not found"); + } + + return c.json({ token, apiToken: mapApiToken(row) } satisfies CreatedApiToken, 201); +}); + +app.delete("/api/v1/api-tokens/:id", async (c) => { + const userOnly = requireUser(c); + + if (userOnly) { + return userOnly; + } + + const id = c.req.param("id"); + const actor = getAuditActor(c); + + await c.env.DB.batch([ + c.env.DB.prepare(`UPDATE api_tokens SET is_revoked = 1 WHERE id = ?`).bind(id), + auditStatement(c.env.DB, actor.actorType, actor.actorId, "api_token.revoke", "api_token", id, {}), + ]); + + return c.json({ ok: true }); +}); + app.get("/api/v1/notebooks", async (c) => { + const denied = requireScopes(c, "read:notebooks"); + + if (denied) { + return denied; + } + const rows = await c.env.DB.prepare( `SELECT id, parent_id, name, slug, icon, color, sort_order, created_at, updated_at FROM notebooks @@ -306,6 +436,12 @@ app.get("/api/v1/notebooks", async (c) => { }); app.post("/api/v1/notebooks", zValidator("json", NotebookCreateSchema), async (c) => { + const denied = requireScopes(c, "write:notebooks"); + + if (denied) { + return denied; + } + const input = c.req.valid("json"); const actor = getAuditActor(c); const id = createId("nb"); @@ -325,6 +461,12 @@ app.post("/api/v1/notebooks", zValidator("json", NotebookCreateSchema), async (c }); app.patch("/api/v1/notebooks/:id", zValidator("json", NotebookUpdateSchema), async (c) => { + const denied = requireScopes(c, "write:notebooks"); + + if (denied) { + return denied; + } + const id = c.req.param("id"); const input = c.req.valid("json"); const actor = getAuditActor(c); @@ -352,9 +494,37 @@ app.patch("/api/v1/notebooks/:id", zValidator("json", NotebookUpdateSchema), asy }); app.delete("/api/v1/notebooks/:id", async (c) => { + const denied = requireScopes(c, "write:notebooks"); + + if (denied) { + return denied; + } + const id = c.req.param("id"); const actor = getAuditActor(c); const now = isoNow(); + const current = await getNotebook(c.env.DB, id); + + if (!current) { + return notFound(c, "Notebook not found"); + } + + if (id === "nb_inbox") { + return badRequest(c, "Inbox cannot be deleted."); + } + + const [childCount, memoCount] = await Promise.all([ + c.env.DB.prepare(`SELECT COUNT(*) AS count FROM notebooks WHERE parent_id = ? AND is_deleted = 0`) + .bind(id) + .first<{ count: number }>(), + c.env.DB.prepare(`SELECT COUNT(*) AS count FROM memos WHERE notebook_id = ? AND is_deleted = 0`) + .bind(id) + .first<{ count: number }>(), + ]); + + if ((childCount?.count ?? 0) > 0 || (memoCount?.count ?? 0) > 0) { + return conflict(c, "notebook_not_empty", "Move or delete child notebooks and memos before deleting this notebook."); + } await c.env.DB.prepare( `UPDATE notebooks @@ -368,7 +538,54 @@ app.delete("/api/v1/notebooks/:id", async (c) => { return c.json({ ok: true }); }); +app.get("/api/v1/tags", async (c) => { + const denied = requireScopes(c, "read:tags"); + + if (denied) { + return denied; + } + + return c.json({ tags: await listTagSummaries(c.env.DB) }); +}); + +app.patch("/api/v1/tags/:tag", zValidator("json", TagRenameSchema), async (c) => { + const denied = requireScopes(c, "write:tags"); + + if (denied) { + return denied; + } + + const oldTag = decodeTagParam(c.req.param("tag")); + const input = c.req.valid("json"); + const actor = getAuditActor(c); + const actorLabel = getActorLabel(c); + const updated = await updateTagAcrossMemos(c.env.DB, oldTag, input.name, actor, actorLabel); + + return c.json({ ok: true, updated }); +}); + +app.delete("/api/v1/tags/:tag", async (c) => { + const denied = requireScopes(c, "write:tags"); + + if (denied) { + return denied; + } + + const tag = decodeTagParam(c.req.param("tag")); + const actor = getAuditActor(c); + const actorLabel = getActorLabel(c); + const updated = await updateTagAcrossMemos(c.env.DB, tag, null, actor, actorLabel); + + return c.json({ ok: true, updated }); +}); + app.get("/api/v1/memos", async (c) => { + const denied = requireScopes(c, "read:memos"); + + if (denied) { + return denied; + } + const notebookId = c.req.query("notebookId"); const q = c.req.query("q")?.trim(); const includeTrash = c.req.query("trash") === "1"; @@ -434,6 +651,12 @@ app.get("/api/v1/memos", async (c) => { }); app.post("/api/v1/memos", zValidator("json", MemoCreateSchema), async (c) => { + const denied = requireScopes(c, "write:memos"); + + if (denied) { + return denied; + } + const input = c.req.valid("json"); const actor = getAuditActor(c); const actorLabel = getActorLabel(c); @@ -471,6 +694,12 @@ app.post("/api/v1/memos", zValidator("json", MemoCreateSchema), async (c) => { }); app.get("/api/v1/memos/:id", async (c) => { + const denied = requireScopes(c, "read:memos"); + + if (denied) { + return denied; + } + const includeDeleted = c.req.query("includeDeleted") === "1"; const memo = await getMemoDetail(c.env.DB, c.req.param("id"), includeDeleted); @@ -482,6 +711,12 @@ app.get("/api/v1/memos/:id", async (c) => { }); app.get("/api/v1/memos/:id/revisions", async (c) => { + const denied = requireScopes(c, "read:memos"); + + if (denied) { + return denied; + } + const memoId = c.req.param("id"); const memo = await getMemoDetail(c.env.DB, memoId); @@ -505,6 +740,12 @@ app.get("/api/v1/memos/:id/revisions", async (c) => { }); app.post("/api/v1/memos/:id/revisions/:revisionId/restore", async (c) => { + const denied = requireScopes(c, "write:memos"); + + if (denied) { + return denied; + } + const memoId = c.req.param("id"); const revisionId = c.req.param("revisionId"); const actor = getAuditActor(c); @@ -560,6 +801,12 @@ app.post("/api/v1/memos/:id/revisions/:revisionId/restore", async (c) => { }); app.get("/api/v1/resources", async (c) => { + const denied = requireScopes(c, "read:resources"); + + if (denied) { + return denied; + } + const limit = clampNumber(Number(c.req.query("limit") ?? 500), 1, 500); const [rows, stats] = await Promise.all([ c.env.DB.prepare( @@ -592,6 +839,12 @@ app.get("/api/v1/resources", async (c) => { }); app.post("/api/v1/memos/:id/resources", async (c) => { + const denied = requireScopes(c, "write:resources"); + + if (denied) { + return denied; + } + const memoId = c.req.param("id"); const memo = await getMemoDetail(c.env.DB, memoId); @@ -694,6 +947,12 @@ app.post("/api/v1/memos/:id/resources", async (c) => { }); app.get("/api/v1/resources/:id/blob", async (c) => { + const denied = requireScopes(c, "read:resources"); + + if (denied) { + return denied; + } + const resource = await getResourceRow(c.env.DB, c.req.param("id")); if (!resource) { @@ -718,6 +977,12 @@ app.get("/api/v1/resources/:id/blob", async (c) => { }); app.patch("/api/v1/memos/:id", zValidator("json", MemoUpdateSchema), async (c) => { + const denied = requireScopes(c, "write:memos"); + + if (denied) { + return denied; + } + const id = c.req.param("id"); const input = c.req.valid("json"); const actor = getAuditActor(c); @@ -791,6 +1056,12 @@ app.patch("/api/v1/memos/:id", zValidator("json", MemoUpdateSchema), async (c) = }); app.delete("/api/v1/memos/:id", async (c) => { + const denied = requireScopes(c, "write:memos"); + + if (denied) { + return denied; + } + const id = c.req.param("id"); const actor = getAuditActor(c); const permanent = c.req.query("permanent") === "1"; @@ -835,6 +1106,12 @@ app.delete("/api/v1/memos/:id", async (c) => { }); app.post("/api/v1/memos/:id/restore", async (c) => { + const denied = requireScopes(c, "write:memos"); + + if (denied) { + return denied; + } + const id = c.req.param("id"); const actor = getAuditActor(c); const current = await getMemoDetailRow(c.env.DB, id, true); @@ -864,6 +1141,12 @@ app.post("/api/v1/memos/:id/restore", async (c) => { }); app.post("/api/v1/memos/merge", zValidator("json", MergeMemosSchema), async (c) => { + const denied = requireScopes(c, "write:memos"); + + if (denied) { + return denied; + } + const input = c.req.valid("json"); const actor = getAuditActor(c); const actorLabel = getActorLabel(c); @@ -956,15 +1239,90 @@ app.post("/api/v1/memos/merge", zValidator("json", MergeMemosSchema), async (c) return c.json({ memo: await getMemoDetail(c.env.DB, newMemoId) }, 201); }); -app.all("/mcp", (c) => +app.get("/mcp", (c) => c.json({ name: "EdgeEver MCP endpoint", - status: "planned", - message: "Remote MCP will be wired to the same memo and notebook services as the REST API.", + status: "ready", + transport: "streamable-http-jsonrpc", + auth: "Authorization: Bearer ", restBasePath: "/api/v1", }) ); +app.post("/mcp", async (c) => { + let request: JsonRpcRequest; + + try { + request = (await c.req.json()) as JsonRpcRequest; + } catch { + return c.json(jsonRpcError(null, -32700, "Parse error"), 400); + } + + if (!request || request.jsonrpc !== "2.0" || typeof request.method !== "string") { + return c.json(jsonRpcError(getJsonRpcId(request), -32600, "Invalid Request"), 400); + } + + if (request.method === "notifications/initialized" && request.id === undefined) { + return new Response(null, { status: 204 }); + } + + if (request.method === "initialize") { + return c.json( + jsonRpcResult(request.id ?? null, { + protocolVersion: "2024-11-05", + capabilities: { + tools: {}, + }, + serverInfo: { + name: "edgeever", + version: "0.1.0", + }, + }) + ); + } + + const auth = await authenticateRequest(c, true); + + if (!auth) { + return c.json(jsonRpcError(request.id ?? null, -32001, "Authentication required"), 401); + } + + c.set("auth", auth); + + if (request.method === "tools/list") { + return c.json( + jsonRpcResult(request.id ?? null, { + tools: MCP_TOOLS, + }) + ); + } + + if (request.method === "tools/call") { + const params = asRecord(request.params); + const name = typeof params.name === "string" ? params.name : ""; + const args = asRecord(params.arguments); + + try { + const result = await callMcpTool(c, auth, name, args); + return c.json( + jsonRpcResult(request.id ?? null, { + content: [ + { + type: "text", + text: JSON.stringify(result, null, 2), + }, + ], + }) + ); + } catch (error) { + const message = error instanceof Error ? error.message : "Tool call failed"; + return c.json(jsonRpcError(request.id ?? null, -32000, message), 400); + } + } + + return c.json(jsonRpcError(request.id ?? null, -32601, "Method not found"), 404); +}); + app.notFound((c) => c.json( { @@ -979,6 +1337,213 @@ app.notFound((c) => export default app; +type JsonRpcRequest = { + jsonrpc?: string; + id?: string | number | null; + method?: string; + params?: unknown; +}; + +type JsonRpcId = string | number | null; + +const MCP_TOOLS = [ + { + name: "search_memos", + description: "Search active EdgeEver memos by text, tag, or notebook.", + inputSchema: { + type: "object", + properties: { + query: { type: "string" }, + notebookId: { type: "string" }, + limit: { type: "number", minimum: 1, maximum: 50 }, + }, + }, + }, + { + name: "get_memo", + description: "Read a memo with Markdown content.", + inputSchema: { + type: "object", + required: ["memoId"], + properties: { + memoId: { type: "string" }, + }, + }, + }, + { + name: "create_memo", + description: "Create a memo in a notebook.", + inputSchema: { + type: "object", + required: ["notebookId"], + properties: { + notebookId: { type: "string" }, + title: { type: "string" }, + contentMarkdown: { type: "string" }, + tags: { type: "array", items: { type: "string" } }, + }, + }, + }, + { + name: "update_memo", + description: "Update memo title, Markdown, tags, or notebook.", + inputSchema: { + type: "object", + required: ["memoId"], + properties: { + memoId: { type: "string" }, + title: { type: "string" }, + contentMarkdown: { type: "string" }, + tags: { type: "array", items: { type: "string" } }, + notebookId: { type: "string" }, + expectedRevision: { type: "number" }, + }, + }, + }, + { + name: "list_notebooks", + description: "List active notebooks.", + inputSchema: { + type: "object", + properties: {}, + }, + }, + { + name: "list_tags", + description: "List tags and memo counts.", + inputSchema: { + type: "object", + properties: {}, + }, + }, +]; + +const callMcpTool = async ( + c: AppContext, + auth: AuthContext, + name: string, + args: Record +) => { + switch (name) { + case "search_memos": { + assertScope(auth, "read:memos"); + return { + memos: await searchMemoSummaries(c.env.DB, { + query: getOptionalString(args.query), + notebookId: getOptionalString(args.notebookId), + limit: clampNumber(Number(args.limit ?? 20), 1, 50), + }), + }; + } + case "get_memo": { + assertScope(auth, "read:memos"); + const memoId = getRequiredString(args.memoId, "memoId"); + const memo = await getMemoDetail(c.env.DB, memoId); + + if (!memo) { + throw new Error("Memo not found"); + } + + return { memo }; + } + case "create_memo": { + assertScope(auth, "write:memos"); + const notebookId = getRequiredString(args.notebookId, "notebookId"); + const actor = getAuditActor(c); + const actorLabel = getActorLabel(c); + const memo = await createMemoRecord(c.env.DB, { + notebookId, + title: getOptionalString(args.title) ?? undefined, + contentMarkdown: getOptionalString(args.contentMarkdown) ?? "", + tags: getOptionalStringArray(args.tags), + }, actor, actorLabel); + + return { memo }; + } + case "update_memo": { + assertScope(auth, "write:memos"); + const memoId = getRequiredString(args.memoId, "memoId"); + const actor = getAuditActor(c); + const actorLabel = getActorLabel(c); + const result = await updateMemoRecord( + c.env.DB, + memoId, + { + expectedRevision: + typeof args.expectedRevision === "number" && Number.isInteger(args.expectedRevision) + ? args.expectedRevision + : undefined, + notebookId: getOptionalString(args.notebookId) ?? undefined, + title: getOptionalString(args.title) ?? undefined, + contentMarkdown: getOptionalString(args.contentMarkdown) ?? undefined, + tags: Array.isArray(args.tags) ? getOptionalStringArray(args.tags) : undefined, + }, + actor, + actorLabel + ); + + if ("error" in result) { + throw new Error(result.message); + } + + return { memo: result.memo }; + } + case "list_notebooks": { + assertScope(auth, "read:notebooks"); + return { notebooks: await listNotebooks(c.env.DB) }; + } + case "list_tags": { + assertScope(auth, "read:tags"); + return { tags: await listTagSummaries(c.env.DB) }; + } + default: + throw new Error(`Unknown tool: ${name}`); + } +}; + +const jsonRpcResult = (id: JsonRpcId, result: unknown) => ({ + jsonrpc: "2.0", + id, + result, +}); + +const jsonRpcError = (id: JsonRpcId, code: number, message: string, data?: unknown) => ({ + jsonrpc: "2.0", + id, + error: { + code, + message, + ...(data === undefined ? {} : { data }), + }, +}); + +const getJsonRpcId = (request: unknown): JsonRpcId => { + if (!request || typeof request !== "object" || !("id" in request)) { + return null; + } + + const id = (request as { id?: unknown }).id; + return typeof id === "string" || typeof id === "number" || id === null ? id : null; +}; + +const asRecord = (value: unknown): Record => + value && typeof value === "object" && !Array.isArray(value) ? (value as Record) : {}; + +const getOptionalString = (value: unknown) => (typeof value === "string" && value.trim() ? value.trim() : null); + +const getRequiredString = (value: unknown, name: string) => { + const parsed = getOptionalString(value); + + if (!parsed) { + throw new Error(`${name} is required`); + } + + return parsed; +}; + +const getOptionalStringArray = (value: unknown) => + Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : []; + const isAuthRequired = async (env: Bindings) => { if (env.EDGE_EVER_AUTH_PASSWORD_HASH?.trim()) { return true; @@ -1096,7 +1661,7 @@ const authenticateBearerToken = async (c: AppContext, touch: boolean): Promise { return auth?.actorId ? `${auth.actorType}:${auth.actorId}` : auth?.username ?? "user"; }; +const requireUser = (c: AppContext) => { + const auth = c.get("auth"); + + if (auth?.kind === "user") { + return null; + } + + return forbidden(c, "Only an interactive user session can manage this resource."); +}; + +const requireScopes = (c: AppContext, ...scopes: TokenScope[]) => { + const auth = c.get("auth"); + + if (!auth) { + return unauthorized(c, "Authentication required."); + } + + if (hasScopes(auth, scopes)) { + return null; + } + + return forbidden(c, `Missing required scope: ${scopes.join(", ")}`); +}; + +const assertScope = (auth: AuthContext, scope: TokenScope) => { + if (!hasScopes(auth, [scope])) { + throw new Error(`Missing required scope: ${scope}`); + } +}; + +const hasScopes = (auth: AuthContext, scopes: TokenScope[]) => { + if (auth.kind === "user") { + return true; + } + + return scopes.every((scope) => auth.scopes.includes(scope)); +}; + +const normalizeTokenScopes = (scopes: string[]) => { + const normalized = Array.from(new Set(scopes.map((scope) => scope.trim()).filter(Boolean))); + + if (normalized.some((scope) => !isTokenScope(scope))) { + return null; + } + + return normalized as TokenScope[]; +}; + +const isTokenScope = (scope: string): scope is TokenScope => + (ALL_TOKEN_SCOPES as readonly string[]).includes(scope); + const getSessionMaxAge = (env: Bindings) => { const days = clampNumber(Number(env.EDGE_EVER_SESSION_TTL_DAYS ?? DEFAULT_SESSION_TTL_DAYS), 1, 90); return days * 24 * 60 * 60; @@ -1365,6 +1983,212 @@ const mapResourceStorageSummary = (row: ResourceStatsRow | null): ResourceStorag attachmentCount: row?.attachment_count ?? 0, }); +const mapApiToken = (row: ApiTokenRow): ApiToken => ({ + id: row.id, + name: row.name, + scopes: parseJsonArray(row.scopes_json), + lastUsedAt: row.last_used_at, + expiresAt: row.expires_at, + isRevoked: Boolean(row.is_revoked), + createdAt: row.created_at, +}); + +const mapTagSummary = (row: TagSummaryRow): TagSummary => ({ + name: row.name, + memoCount: row.memo_count, + updatedAt: row.updated_at, +}); + +const getApiTokenRow = async (db: D1Database, id: string): Promise => + db + .prepare( + `SELECT id, name, scopes_json, last_used_at, expires_at, is_revoked, created_at + FROM api_tokens + WHERE id = ?` + ) + .bind(id) + .first(); + +const listNotebooks = async (db: D1Database): Promise => { + const rows = await db + .prepare( + `SELECT id, parent_id, name, slug, icon, color, sort_order, created_at, updated_at + FROM notebooks + WHERE is_deleted = 0 + ORDER BY parent_id IS NOT NULL, sort_order ASC, name ASC` + ) + .all(); + + return rows.results.map(mapNotebook); +}; + +const listTagSummaries = async (db: D1Database): Promise => { + const rows = await db + .prepare( + `SELECT json_each.value AS name, + COUNT(DISTINCT m.id) AS memo_count, + MAX(m.updated_at) AS updated_at + FROM memos m, json_each(m.tags_json) + WHERE m.is_deleted = 0 + AND trim(json_each.value) <> '' + GROUP BY json_each.value + ORDER BY lower(json_each.value) ASC` + ) + .all(); + + return rows.results + .filter((row) => typeof row.name === "string" && row.name.trim()) + .map(mapTagSummary); +}; + +const updateTagAcrossMemos = async ( + db: D1Database, + oldTag: string, + nextTag: string | null, + actor: { actorType: "user" | "agent"; actorId: string | null }, + actorLabel: string +) => { + const normalizedOld = normalizeTags([oldTag])[0]; + const normalizedNext = nextTag === null ? null : normalizeTags([nextTag])[0]; + + if (!normalizedOld || normalizedOld === normalizedNext) { + return 0; + } + + const rows = await db + .prepare( + `SELECT m.id, m.title, m.tags_json, c.content_text + FROM memos m + INNER JOIN memo_contents c ON c.memo_id = m.id + WHERE m.is_deleted = 0 + AND EXISTS ( + SELECT 1 + FROM json_each(m.tags_json) + WHERE json_each.value = ? + )` + ) + .bind(normalizedOld) + .all(); + + const now = isoNow(); + const statements: D1PreparedStatement[] = []; + let updated = 0; + + for (const row of rows.results) { + const currentTags = parseJsonArray(row.tags_json); + + if (!currentTags.includes(normalizedOld)) { + continue; + } + + const nextTags = normalizeTags( + currentTags.flatMap((tag) => { + if (tag !== normalizedOld) { + return [tag]; + } + + return normalizedNext ? [normalizedNext] : []; + }) + ); + + statements.push( + db + .prepare( + `UPDATE memos + SET tags_json = ?, updated_by = ?, updated_at = ? + WHERE id = ? AND is_deleted = 0` + ) + .bind(JSON.stringify(nextTags), actorLabel, now, row.id), + db.prepare(`DELETE FROM memos_fts WHERE memo_id = ?`).bind(row.id), + db + .prepare( + `INSERT INTO memos_fts (memo_id, title, content_text, tags) + VALUES (?, ?, ?, ?)` + ) + .bind(row.id, row.title, row.content_text, nextTags.join(" ")), + auditStatement(db, actor.actorType, actor.actorId, normalizedNext ? "tag.rename" : "tag.delete", "memo", row.id, { + from: normalizedOld, + to: normalizedNext, + }) + ); + updated += 1; + } + + if (statements.length > 0) { + await db.batch(statements); + } + + return updated; +}; + +const searchMemoSummaries = async ( + db: D1Database, + options: { query?: string | null; notebookId?: string | null; limit: number } +): Promise => { + const q = options.query?.trim(); + const notebookId = options.notebookId?.trim() || null; + const limit = clampNumber(options.limit, 1, 100); + + if (q) { + const ftsQuery = toFtsQuery(q); + const likeQuery = `%${escapeLike(q)}%`; + + if (ftsQuery) { + const rows = await db + .prepare( + `WITH raw_matches(memo_id, rank) AS ( + SELECT memo_id, bm25(memos_fts) + FROM memos_fts + WHERE memos_fts MATCH ? + + UNION ALL + + SELECT m.id, 100.0 + FROM memos m + INNER JOIN memo_contents c ON c.memo_id = m.id + WHERE m.title LIKE ? ESCAPE '\\' + OR c.content_text LIKE ? ESCAPE '\\' + OR m.tags_json LIKE ? ESCAPE '\\' + ), + search_matches AS ( + SELECT memo_id, MIN(rank) AS rank + FROM raw_matches + GROUP BY memo_id + ) + SELECT m.id, m.notebook_id, m.title, m.excerpt, m.tags_json, m.is_pinned, + m.is_archived, m.is_deleted, m.created_at, m.updated_at, m.deleted_at, c.revision + FROM search_matches s + INNER JOIN memos m ON m.id = s.memo_id + INNER JOIN memo_contents c ON c.memo_id = m.id + WHERE m.is_deleted = 0 + AND (? IS NULL OR m.notebook_id = ?) + ORDER BY s.rank ASC, m.is_pinned DESC, m.updated_at DESC + LIMIT ?` + ) + .bind(ftsQuery, likeQuery, likeQuery, likeQuery, notebookId, notebookId, limit) + .all(); + + return rows.results.map(mapMemoSummary); + } + } + + const rows = await db + .prepare( + `SELECT m.id, m.notebook_id, m.title, m.excerpt, m.tags_json, m.is_pinned, + m.is_archived, m.is_deleted, m.created_at, m.updated_at, m.deleted_at, c.revision + FROM memos m + INNER JOIN memo_contents c ON c.memo_id = m.id + WHERE m.is_deleted = 0 + AND (? IS NULL OR m.notebook_id = ?) + ORDER BY m.is_pinned DESC, m.updated_at DESC + LIMIT ?` + ) + .bind(notebookId, notebookId, limit) + .all(); + + return rows.results.map(mapMemoSummary); +}; + const getNotebook = async (db: D1Database, id: string): Promise => { const row = await db .prepare( @@ -1401,6 +2225,134 @@ const getMemoDetail = async (db: D1Database, id: string, includeDeleted = false) return row ? mapMemoDetail(row) : null; }; +const createMemoRecord = async ( + db: D1Database, + input: { notebookId: string; title?: string; contentMarkdown?: string; tags?: string[] }, + actor: { actorType: "user" | "agent"; actorId: string | null }, + actorLabel: string +): Promise => { + const tags = normalizeTags(input.tags); + const contentMarkdown = input.contentMarkdown ?? ""; + const contentJson = markdownToDoc(contentMarkdown); + const contentText = docToText(contentJson); + const title = input.title || deriveTitle(contentText); + const excerpt = createExcerpt(contentText || title || ""); + const contentHash = await sha256(contentMarkdown + JSON.stringify(contentJson)); + const id = createId("memo"); + const now = isoNow(); + + await db.batch([ + db + .prepare( + `INSERT INTO memos ( + id, notebook_id, title, excerpt, tags_json, created_by, updated_by, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)` + ) + .bind(id, input.notebookId, title, excerpt, JSON.stringify(tags), actorLabel, actorLabel, now, now), + db + .prepare( + `INSERT INTO memo_contents ( + memo_id, content_json, content_markdown, content_text, content_hash, revision, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, 0, ?, ?)` + ) + .bind(id, JSON.stringify(contentJson), contentMarkdown, contentText, contentHash, now, now), + db + .prepare( + `INSERT INTO memos_fts (memo_id, title, content_text, tags) + VALUES (?, ?, ?, ?)` + ) + .bind(id, title, contentText, tags.join(" ")), + auditStatement(db, actor.actorType, actor.actorId, "memo.create", "memo", id, { + notebookId: input.notebookId, + }), + ]); + + const memo = await getMemoDetail(db, id); + + if (!memo) { + throw new Error("Memo was created but could not be read."); + } + + return memo; +}; + +const updateMemoRecord = async ( + db: D1Database, + id: string, + input: { + expectedRevision?: number; + notebookId?: string; + title?: string; + contentMarkdown?: string; + tags?: string[]; + }, + actor: { actorType: "user" | "agent"; actorId: string | null }, + actorLabel: string +): Promise<{ memo: MemoDetail; error?: never; message?: never } | { error: string; message: string }> => { + const current = await getMemoDetailRow(db, id); + + if (!current) { + return { error: "not_found", message: "Memo not found" }; + } + + if (input.expectedRevision !== undefined && input.expectedRevision !== current.revision) { + return { error: "revision_conflict", message: "Memo was updated elsewhere. Reload before saving." }; + } + + const currentContentJson = parseDoc(current.content_json); + const contentJson = input.contentMarkdown !== undefined ? markdownToDoc(input.contentMarkdown) : currentContentJson; + const contentMarkdown = + input.contentMarkdown !== undefined ? input.contentMarkdown : docToMarkdown(contentJson); + const contentText = docToText(contentJson); + const title = input.title ?? current.title ?? deriveTitle(contentText); + const tags = input.tags === undefined ? parseJsonArray(current.tags_json) : normalizeTags(input.tags); + const excerpt = createExcerpt(contentText || title || ""); + const notebookId = input.notebookId ?? current.notebook_id; + const nextRevision = current.revision + 1; + const contentHash = await sha256(contentMarkdown + JSON.stringify(contentJson)); + const now = isoNow(); + const revisionStatements = (await shouldSnapshotMemoRevision(db, current, title, JSON.stringify(tags), contentHash, now)) + ? [createMemoRevisionStatement(db, current, actorLabel, now)] + : []; + + await db.batch([ + ...revisionStatements, + db + .prepare( + `UPDATE memos + SET notebook_id = ?, title = ?, excerpt = ?, tags_json = ?, updated_by = ?, updated_at = ? + WHERE id = ? AND is_deleted = 0` + ) + .bind(notebookId, title, excerpt, JSON.stringify(tags), actorLabel, now, id), + db + .prepare( + `UPDATE memo_contents + SET content_json = ?, content_markdown = ?, content_text = ?, content_hash = ?, + revision = ?, updated_at = ? + WHERE memo_id = ?` + ) + .bind(JSON.stringify(contentJson), contentMarkdown, contentText, contentHash, nextRevision, now, id), + db.prepare(`DELETE FROM memos_fts WHERE memo_id = ?`).bind(id), + db + .prepare( + `INSERT INTO memos_fts (memo_id, title, content_text, tags) + VALUES (?, ?, ?, ?)` + ) + .bind(id, title, contentText, tags.join(" ")), + auditStatement(db, actor.actorType, actor.actorId, "memo.update", "memo", id, { + revision: nextRevision, + }), + ]); + + const memo = await getMemoDetail(db, id); + + if (!memo) { + return { error: "not_found", message: "Memo not found after update" }; + } + + return { memo }; +}; + const getMemoRevisionRow = async ( db: D1Database, memoId: string, @@ -1647,6 +2599,14 @@ const contentDispositionInline = (filename: string | null) => { return `inline; filename="${fallback}"; filename*=UTF-8''${encodeURIComponent(filename)}`; }; +const decodeTagParam = (value: string) => { + try { + return decodeURIComponent(value).trim(); + } catch { + return value.trim(); + } +}; + const notFound = (c: Context, message: string) => c.json( { @@ -1669,6 +2629,17 @@ const badRequest = (c: Context, message: string) => 400 ); +const conflict = (c: Context, code: string, message: string) => + c.json( + { + error: { + code, + message, + }, + }, + 409 + ); + const unauthorized = (c: Context, message: string) => c.json( { @@ -1679,3 +2650,14 @@ const unauthorized = (c: Context, message: string) => }, 401 ); + +const forbidden = (c: Context, message: string) => + c.json( + { + error: { + code: "forbidden", + message, + }, + }, + 403 + ); diff --git a/apps/web/src/app/App.tsx b/apps/web/src/app/App.tsx index d32e2446..b4e37d15 100644 --- a/apps/web/src/app/App.tsx +++ b/apps/web/src/app/App.tsx @@ -1,10 +1,12 @@ import { docToMarkdown, + type ApiToken, type AuthSession, type AuthUser, type MemoDetail, type MemoSummary, type Notebook, + type TagSummary, type TiptapDoc, } from "@edgeever/shared"; import Image from "@tiptap/extension-image"; @@ -25,16 +27,19 @@ import { History, ImageIcon, Inbox, + KeyRound, LayoutList, LockKeyhole, LogOut, Merge, MoreHorizontal, PanelLeft, + Pencil, Plus, RotateCcw, Save, Search, + ShieldCheck, Sparkles, Tags, Trash2, @@ -139,6 +144,8 @@ const WorkspaceApp = ({ const [multiSelectKeyDown, setMultiSelectKeyDown] = useState(false); const [imageCompressionEnabled, setImageCompressionEnabled] = useState(readImageCompressionPreference); const [assetsOpen, setAssetsOpen] = useState(false); + const [tagsOpen, setTagsOpen] = useState(false); + const [settingsOpen, setSettingsOpen] = useState(false); const [search, setSearch] = useState(""); const notebooksQuery = useQuery({ @@ -221,6 +228,27 @@ const WorkspaceApp = ({ }, }); + const updateNotebookMutation = useMutation({ + mutationFn: ({ notebookId, name }: { notebookId: string; name: string }) => + api.updateNotebook(notebookId, { name }), + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: ["notebooks"] }); + }, + }); + + const deleteNotebookMutation = useMutation({ + mutationFn: api.deleteNotebook, + onSuccess: async (_data, notebookId) => { + if (selectedNotebookId === notebookId) { + setSelectedNotebookId(null); + setSelectedMemoId(null); + } + + await queryClient.invalidateQueries({ queryKey: ["notebooks"] }); + await queryClient.invalidateQueries({ queryKey: ["memos"] }); + }, + }); + const createMemoMutation = useMutation({ mutationFn: api.createMemo, onSuccess: async (data) => { @@ -276,6 +304,29 @@ const WorkspaceApp = ({ createNotebookMutation.mutate({ name: name.trim(), parentId: parentId ?? null }); }; + const handleRenameNotebook = (notebook: Notebook) => { + const name = window.prompt("重命名笔记本", notebook.name); + + if (!name?.trim() || name.trim() === notebook.name) { + return; + } + + updateNotebookMutation.mutate({ notebookId: notebook.id, name: name.trim() }); + }; + + const handleDeleteNotebook = (notebook: Notebook) => { + if (notebook.slug === "inbox") { + window.alert("Inbox 不能删除。"); + return; + } + + if (!window.confirm(`删除笔记本「${notebook.name}」?请先清空其中的笔记和子笔记本。`)) { + return; + } + + deleteNotebookMutation.mutate(notebook.id); + }; + const handleCreateMemo = () => { if (!selectedNotebookId || memoView === "trash") { return; @@ -323,12 +374,16 @@ const WorkspaceApp = ({ setActivePane("memos"); }} onCreateNotebook={handleCreateNotebook} + onRenameNotebook={handleRenameNotebook} + onDeleteNotebook={handleDeleteNotebook} onBackToList={() => setActivePane("memos")} onLogout={onLogout} isLoggingOut={isLoggingOut} imageCompressionEnabled={imageCompressionEnabled} onImageCompressionChange={setImageCompressionEnabled} onOpenAssets={() => setAssetsOpen(true)} + onOpenTags={() => setTagsOpen(true)} + onOpenSettings={() => setSettingsOpen(true)} onOpenTrash={() => { setMemoView("trash"); setSelectedMemoIds(new Set()); @@ -399,6 +454,8 @@ const WorkspaceApp = ({ {assetsOpen ? setAssetsOpen(false)} /> : null} + {tagsOpen ? setTagsOpen(false)} /> : null} + {settingsOpen ? setSettingsOpen(false)} /> : null} ); }; @@ -516,12 +573,16 @@ const NotebookPane = ({ isLoading, onSelect, onCreateNotebook, + onRenameNotebook, + onDeleteNotebook, onBackToList, onLogout, isLoggingOut, imageCompressionEnabled, onImageCompressionChange, onOpenAssets, + onOpenTags, + onOpenSettings, onOpenTrash, }: { authRequired: boolean; @@ -531,12 +592,16 @@ const NotebookPane = ({ isLoading: boolean; onSelect: (notebookId: string) => void; onCreateNotebook: (parentId?: string | null) => void; + onRenameNotebook: (notebook: Notebook) => void; + onDeleteNotebook: (notebook: Notebook) => void; onBackToList: () => void; onLogout: () => void; isLoggingOut: boolean; imageCompressionEnabled: boolean; onImageCompressionChange: (enabled: boolean) => void; onOpenAssets: () => void; + onOpenTags: () => void; + onOpenSettings: () => void; onOpenTrash: () => void; }) => { const tree = useMemo(() => buildNotebookTree(notebooks), [notebooks]); @@ -577,6 +642,8 @@ const NotebookPane = ({ selectedNotebookId={selectedNotebookId} onSelect={onSelect} onCreateNotebook={onCreateNotebook} + onRenameNotebook={onRenameNotebook} + onDeleteNotebook={onDeleteNotebook} /> ))} @@ -595,7 +662,7 @@ const NotebookPane = ({ />
- - {authRequired ? ( @@ -624,16 +691,21 @@ const NotebookTreeItem = ({ selectedNotebookId, onSelect, onCreateNotebook, + onRenameNotebook, + onDeleteNotebook, }: { node: NotebookNode; depth: number; selectedNotebookId: string | null; onSelect: (notebookId: string) => void; onCreateNotebook: (parentId?: string | null) => void; + onRenameNotebook: (notebook: Notebook) => void; + onDeleteNotebook: (notebook: Notebook) => void; }) => { const [open, setOpen] = useState(true); const hasChildren = node.children.length > 0; const selected = node.id === selectedNotebookId; + const isInbox = node.slug === "inbox"; return (
@@ -665,10 +737,38 @@ const NotebookTreeItem = ({ selected ? "hover:bg-emerald-200" : "hover:bg-emerald-100" )} title="新建子笔记本" - onClick={() => onCreateNotebook(node.id)} + onClick={(event) => { + event.stopPropagation(); + onCreateNotebook(node.id); + }} > + + {!isInbox ? ( + + ) : null}
{hasChildren && open ? ( @@ -681,6 +781,8 @@ const NotebookTreeItem = ({ selectedNotebookId={selectedNotebookId} onSelect={onSelect} onCreateNotebook={onCreateNotebook} + onRenameNotebook={onRenameNotebook} + onDeleteNotebook={onDeleteNotebook} /> ))}
@@ -1024,6 +1126,287 @@ const AssetsDialog = ({ onClose }: { onClose: () => void }) => { ); }; +const TagsDialog = ({ onClose }: { onClose: () => void }) => { + const queryClient = useQueryClient(); + const tagsQuery = useQuery({ + queryKey: ["tags"], + queryFn: () => api.listTags(), + }); + const renameMutation = useMutation({ + mutationFn: ({ tag, name }: { tag: string; name: string }) => api.renameTag(tag, name), + onSuccess: async () => { + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ["tags"] }), + queryClient.invalidateQueries({ queryKey: ["memos"] }), + queryClient.invalidateQueries({ queryKey: ["memo"] }), + ]); + }, + }); + const deleteMutation = useMutation({ + mutationFn: api.deleteTag, + onSuccess: async () => { + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ["tags"] }), + queryClient.invalidateQueries({ queryKey: ["memos"] }), + queryClient.invalidateQueries({ queryKey: ["memo"] }), + ]); + }, + }); + const tags = tagsQuery.data?.tags ?? []; + + const handleRename = (tag: TagSummary) => { + const name = window.prompt("重命名标签", tag.name); + + if (!name?.trim() || name.trim() === tag.name) { + return; + } + + renameMutation.mutate({ tag: tag.name, name: name.trim() }); + }; + + const handleDelete = (tag: TagSummary) => { + if (!window.confirm(`从 ${tag.memoCount} 条笔记中移除标签 #${tag.name}?`)) { + return; + } + + deleteMutation.mutate(tag.name); + }; + + return ( +
+
+
+
+
+ + 标签 +
+
{tags.length} tags
+
+ +
+ +
+ {tagsQuery.isLoading ? ( +
加载中
+ ) : tags.length === 0 ? ( +
+ 暂无标签 +
+ ) : ( +
+ {tags.map((tag) => ( +
+ + #{tag.name} + + {tag.memoCount} memos{tag.updatedAt ? ` · ${formatDateTime(tag.updatedAt)}` : ""} + + + + +
+ ))} +
+ )} +
+
+
+ ); +}; + +const DEFAULT_TOKEN_SCOPES = ["read:notebooks", "read:memos", "read:tags"]; + +const SettingsDialog = ({ onClose }: { onClose: () => void }) => { + const queryClient = useQueryClient(); + const [name, setName] = useState("Local Agent"); + const [selectedScopes, setSelectedScopes] = useState>(() => new Set(DEFAULT_TOKEN_SCOPES)); + const [createdToken, setCreatedToken] = useState<{ token: string; apiToken: ApiToken } | null>(null); + const tokensQuery = useQuery({ + queryKey: ["api-tokens"], + queryFn: () => api.listApiTokens(), + }); + const availableScopes = tokensQuery.data?.availableScopes ?? [ + "read:notebooks", + "write:notebooks", + "read:memos", + "write:memos", + "read:resources", + "write:resources", + "read:tags", + "write:tags", + ]; + const createMutation = useMutation({ + mutationFn: api.createApiToken, + onSuccess: async (data) => { + setCreatedToken(data); + setName(""); + setSelectedScopes(new Set(DEFAULT_TOKEN_SCOPES)); + await queryClient.invalidateQueries({ queryKey: ["api-tokens"] }); + }, + }); + const revokeMutation = useMutation({ + mutationFn: api.revokeApiToken, + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: ["api-tokens"] }); + }, + }); + const tokens = tokensQuery.data?.apiTokens ?? []; + + const toggleScope = (scope: string) => { + setSelectedScopes((current) => { + const next = new Set(current); + + if (next.has(scope)) { + next.delete(scope); + } else { + next.add(scope); + } + + return next; + }); + }; + + const handleSubmit = (event: FormEvent) => { + event.preventDefault(); + const scopes = Array.from(selectedScopes); + + if (!name.trim() || scopes.length === 0) { + return; + } + + createMutation.mutate({ name: name.trim(), scopes }); + }; + + return ( +
+
+
+
+
+ + 设置 +
+
API Token / MCP / CLI
+
+ +
+ +
+ {createdToken ? ( +
+
+ + Token 已生成 +
+
+ + +
+
明文 Token 只显示这一次。
+
+ ) : null} + +
+
+ setName(event.target.value)} + placeholder="Token 名称" + /> + +
+
+ {availableScopes.map((scope) => ( + + ))} +
+
+ +
+ {tokensQuery.isLoading ? ( +
加载中
+ ) : tokens.length === 0 ? ( +
+ 暂无 API Token +
+ ) : ( + tokens.map((token) => ( +
+ + {token.name} + + {token.scopes.join(", ") || "no scopes"} + + + {token.lastUsedAt ? `Last used ${formatDateTime(token.lastUsedAt)}` : "Never used"} + + + +
+ )) + )} +
+
+
+
+ ); +}; + const formatBytes = (bytes: number) => { if (!Number.isFinite(bytes) || bytes <= 0) { return "0 B"; diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 07b5f14a..c4af6638 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -1,5 +1,7 @@ import type { AuthSession, + ApiToken, + CreatedApiToken, MemoDetail, MemoRevision, MemoSummary, @@ -7,6 +9,7 @@ import type { Resource, ResourceListItem, ResourceStorageSummary, + TagSummary, TiptapDoc, } from "@edgeever/shared"; @@ -27,6 +30,15 @@ type ListResourcesResponse = { summary: ResourceStorageSummary; }; +type ListTagsResponse = { + tags: TagSummary[]; +}; + +type ListApiTokensResponse = { + apiTokens: ApiToken[]; + availableScopes: string[]; +}; + type MemoResponse = { memo: MemoDetail; }; @@ -105,6 +117,43 @@ export const api = { body: JSON.stringify(payload), }), + updateNotebook: (notebookId: string, payload: { name?: string; parentId?: string | null; sortOrder?: number }) => + request(`/api/v1/notebooks/${notebookId}`, { + method: "PATCH", + body: JSON.stringify(payload), + }), + + deleteNotebook: (notebookId: string) => + request<{ ok: true }>(`/api/v1/notebooks/${notebookId}`, { + method: "DELETE", + }), + + listTags: () => request("/api/v1/tags"), + + renameTag: (tag: string, name: string) => + request<{ ok: true; updated: number }>(`/api/v1/tags/${encodeURIComponent(tag)}`, { + method: "PATCH", + body: JSON.stringify({ name }), + }), + + deleteTag: (tag: string) => + request<{ ok: true; updated: number }>(`/api/v1/tags/${encodeURIComponent(tag)}`, { + method: "DELETE", + }), + + listApiTokens: () => request("/api/v1/api-tokens"), + + createApiToken: (payload: { name: string; scopes: string[]; expiresAt?: string | null }) => + request("/api/v1/api-tokens", { + method: "POST", + body: JSON.stringify(payload), + }), + + revokeApiToken: (tokenId: string) => + request<{ ok: true }>(`/api/v1/api-tokens/${tokenId}`, { + method: "DELETE", + }), + listMemos: (params: { notebookId?: string | null; q?: string; trash?: boolean }) => { const search = new URLSearchParams(); diff --git a/package.json b/package.json index 467005c7..15822d57 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "db:migrate:local": "bun scripts/run-wrangler.mjs d1 migrations apply DB --local", "db:migrate:remote": "bun scripts/run-wrangler.mjs d1 migrations apply DB --remote", "auth:hash": "bun scripts/hash-password.mjs", + "cli": "bun scripts/edgeever.mjs", "deploy": "bun run build && bun run db:migrate:remote && bun scripts/run-wrangler.mjs deploy", "deploy:tianma": "EDGE_EVER_INSTANCE=tianma bun run deploy", "deploy:demo": "EDGE_EVER_INSTANCE=demo bun run deploy", diff --git a/packages/shared/src/schemas.ts b/packages/shared/src/schemas.ts index be432be3..d6cf2ac4 100644 --- a/packages/shared/src/schemas.ts +++ b/packages/shared/src/schemas.ts @@ -38,9 +38,21 @@ export const LoginSchema = z.object({ password: z.string().min(1).max(512), }); +export const ApiTokenCreateSchema = z.object({ + name: z.string().trim().min(1).max(80), + scopes: z.array(z.string().trim().min(1).max(80)).min(1).max(32), + expiresAt: z.string().datetime().nullable().optional(), +}); + +export const TagRenameSchema = z.object({ + name: z.string().trim().min(1).max(80), +}); + export type NotebookCreateInput = z.infer; export type NotebookUpdateInput = z.infer; export type MemoCreateInput = z.infer; export type MemoUpdateInput = z.infer; export type MergeMemosInput = z.infer; export type LoginInput = z.infer; +export type ApiTokenCreateInput = z.infer; +export type TagRenameInput = z.infer; diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index 58e2973c..d0a26f66 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -80,6 +80,27 @@ export type ResourceStorageSummary = { attachmentCount: number; }; +export type ApiToken = { + id: string; + name: string; + scopes: string[]; + lastUsedAt: string | null; + expiresAt: string | null; + isRevoked: boolean; + createdAt: string; +}; + +export type CreatedApiToken = { + token: string; + apiToken: ApiToken; +}; + +export type TagSummary = { + name: string; + memoCount: number; + updatedAt: string | null; +}; + export type AuthUser = { id: string; username: string; diff --git a/scripts/edgeever.mjs b/scripts/edgeever.mjs new file mode 100644 index 00000000..a9d66b65 --- /dev/null +++ b/scripts/edgeever.mjs @@ -0,0 +1,172 @@ +#!/usr/bin/env bun + +import { readFile } from "node:fs/promises"; + +const baseUrl = (process.env.EDGEEVER_URL || "http://127.0.0.1:8787").replace(/\/+$/, ""); +const token = process.env.EDGEEVER_TOKEN; +const [, , command, ...argv] = process.argv; + +const usage = `EdgeEver CLI + +Usage: + EDGEEVER_TOKEN=... bun run cli -- notebooks + EDGEEVER_TOKEN=... bun run cli -- tags + EDGEEVER_TOKEN=... bun run cli -- search + EDGEEVER_TOKEN=... bun run cli -- get + EDGEEVER_TOKEN=... bun run cli -- create --notebook [--title ] [--body <markdown> | --body-file <path>] [--tags a,b] + EDGEEVER_TOKEN=... bun run cli -- update <memoId> [--title <title>] [--body <markdown> | --body-file <path>] [--tags a,b] [--notebook <id>] +`; + +if (!command || command === "help" || command === "--help" || command === "-h") { + console.log(usage); + process.exit(0); +} + +if (!token) { + console.error("EDGEEVER_TOKEN is required."); + process.exit(1); +} + +const main = async () => { + switch (command) { + case "notebooks": + return printJson(await request("/api/v1/notebooks")); + case "tags": + return printJson(await request("/api/v1/tags")); + case "search": { + const query = argv.join(" ").trim(); + const params = new URLSearchParams(); + + if (query) { + params.set("q", query); + } + + return printJson(await request(`/api/v1/memos?${params.toString()}`)); + } + case "get": { + const memoId = argv[0]; + requireValue(memoId, "memoId"); + return printJson(await request(`/api/v1/memos/${encodeURIComponent(memoId)}`)); + } + case "create": { + const options = parseOptions(argv); + const notebookId = requireValue(options.notebook, "--notebook"); + const body = await readBodyOption(options); + return printJson( + await request("/api/v1/memos", { + method: "POST", + body: { + notebookId, + title: options.title, + contentMarkdown: body, + tags: parseTags(options.tags), + }, + }) + ); + } + case "update": { + const memoId = argv[0]; + requireValue(memoId, "memoId"); + const options = parseOptions(argv.slice(1)); + const body = options.body || options["body-file"] ? await readBodyOption(options) : undefined; + const payload = removeUndefined({ + title: options.title, + notebookId: options.notebook, + contentMarkdown: body, + tags: options.tags === undefined ? undefined : parseTags(options.tags), + }); + + return printJson( + await request(`/api/v1/memos/${encodeURIComponent(memoId)}`, { + method: "PATCH", + body: payload, + }) + ); + } + default: + console.error(`Unknown command: ${command}\n`); + console.error(usage); + process.exit(1); + } +}; + +const request = async (path, init = {}) => { + const response = await fetch(`${baseUrl}${path}`, { + method: init.method || "GET", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + body: init.body === undefined ? undefined : JSON.stringify(init.body), + }); + + const body = await response.json().catch(() => null); + + if (!response.ok) { + const message = body?.error?.message || response.statusText; + throw new Error(`${response.status} ${message}`); + } + + return body; +}; + +const parseOptions = (args) => { + const options = {}; + + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + + if (!arg.startsWith("--")) { + continue; + } + + const key = arg.slice(2); + const next = args[index + 1]; + + if (!next || next.startsWith("--")) { + options[key] = "true"; + continue; + } + + options[key] = next; + index += 1; + } + + return options; +}; + +const readBodyOption = async (options) => { + if (options["body-file"]) { + return readFile(options["body-file"], "utf8"); + } + + return options.body || ""; +}; + +const parseTags = (value) => + value + ? value + .split(/[,,\s]+/) + .map((tag) => tag.trim().replace(/^#/, "")) + .filter(Boolean) + : []; + +const removeUndefined = (value) => + Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined)); + +const requireValue = (value, name) => { + if (!value) { + throw new Error(`${name} is required.`); + } + + return value; +}; + +const printJson = (value) => { + console.log(JSON.stringify(value, null, 2)); +}; + +main().catch((error) => { + console.error(error instanceof Error ? error.message : error); + process.exit(1); +});