feedkeeper 0.35.3 → 0.35.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/package.json +3 -3
  2. package/server/dist/api/auth.js +1 -1
  3. package/server/dist/api/feeds.js +1 -1
  4. package/server/dist/api/folders.js +11 -8
  5. package/server/dist/api/items.js +3 -3
  6. package/server/dist/api/onboarding.js +2 -1
  7. package/server/dist/api/v1/resources.js +14 -19
  8. package/server/dist/api/v1/serialize.js +1 -1
  9. package/server/dist/auth/middleware.js +2 -2
  10. package/server/dist/db/migrations/0041_sync_feed_metadata.sql +16 -0
  11. package/server/dist/feeds/archive.js +3 -2
  12. package/server/dist/feeds/articleContent.js +3 -3
  13. package/server/dist/feeds/content/index.js +2 -1
  14. package/server/dist/feeds/content/noise/lazyImages.js +23 -0
  15. package/server/dist/feeds/content/noise/videoPages.js +67 -0
  16. package/server/dist/feeds/content/patterns/snippet-metadata/de.js +3 -0
  17. package/server/dist/feeds/content/patterns/snippet-metadata/en.js +3 -0
  18. package/server/dist/feeds/content/patterns/snippet-metadata/index.js +10 -0
  19. package/server/dist/feeds/content/patterns/snippet-metadata/ja.js +3 -0
  20. package/server/dist/feeds/content/patterns/video-pages/de.js +3 -0
  21. package/server/dist/feeds/content/patterns/video-pages/en.js +3 -0
  22. package/server/dist/feeds/content/patterns/video-pages/ja.js +3 -0
  23. package/server/dist/feeds/contentNoise.js +21 -5
  24. package/server/dist/feeds/extractor.js +2 -1
  25. package/server/dist/feeds/feedIcon.js +4 -2
  26. package/server/dist/feeds/imageSources.js +69 -0
  27. package/server/dist/feeds/inbox.js +4 -9
  28. package/server/dist/feeds/opml.js +122 -27
  29. package/server/dist/feeds/repository.js +19 -7
  30. package/server/dist/mcp/items.js +1 -1
  31. package/server/dist/mcp/server.js +17 -3
  32. package/server/dist/server.js +1 -1
  33. package/server/dist/sync/changeLog.js +1 -0
  34. package/server/package.json +1 -1
  35. package/web/dist/assets/index-CilgZLF5.js +32 -0
  36. package/web/dist/index.html +1 -1
  37. package/web/dist/sw.js +1 -1
  38. package/web/dist/assets/index-CYi9R_Ds.js +0 -32
@@ -119,18 +119,13 @@ export async function saveToInbox(userId, input) {
119
119
  return { itemId, item, hasNote };
120
120
  }
121
121
  export function deleteInboxItem(userId, itemId) {
122
- const inbox = ensureUserInbox(userId);
122
+ const inbox = findFeedByUrl(`feedkeeper://inbox/${userId}`);
123
123
  const item = findItemById(itemId);
124
- if (!item || item.feed_id !== inbox.id) {
124
+ if (!inbox || !item || item.feed_id !== inbox.id) {
125
125
  return false;
126
126
  }
127
- db.transaction(() => {
128
- db.prepare("DELETE FROM item_bookmarks WHERE item_id = ?").run(itemId);
129
- db.prepare("DELETE FROM item_reads WHERE item_id = ?").run(itemId);
130
- db.prepare("DELETE FROM item_progress WHERE item_id = ?").run(itemId);
131
- db.prepare("DELETE FROM item_notes WHERE item_id = ?").run(itemId);
132
- db.prepare("DELETE FROM items WHERE id = ?").run(itemId);
133
- })();
127
+ // Cascades run after the parent disappears, so state/note triggers do not announce stale updates.
128
+ db.prepare("DELETE FROM items WHERE id = ?").run(itemId);
134
129
  pruneArchive();
135
130
  return true;
136
131
  }
@@ -1,11 +1,13 @@
1
1
  import { XMLParser } from "fast-xml-parser";
2
2
  import { z } from "zod";
3
3
  import { config } from "../config.js";
4
- import { listSubscriptionsForUser, findFeedByUrl, createFeed, isUserSubscribed, subscribe, createFolder, } from "./repository.js";
5
- import { SsrfBlockedError } from "./ssrfGuard.js";
4
+ import { listSubscriptionsForUser, findFeedByUrl, createFeed, seedFeedMetadata, isUserSubscribed, subscribe, createFolder, } from "./repository.js";
5
+ import { SsrfBlockedError, normalizeUrlCandidate } from "./ssrfGuard.js";
6
6
  import { assertPublicFeedUrl } from "./blockedHosts.js";
7
7
  import { requestFeedPoll } from "./poller.js";
8
- import { decodeEntities } from "./text.js";
8
+ import { decodeEntities, plainTitle } from "./text.js";
9
+ const MAX_FOLDER_NAME_LENGTH = 100;
10
+ const MAX_FEED_LABEL_LENGTH = 200;
9
11
  function escapeXml(unsafe) {
10
12
  return unsafe.replace(/[<>&'"]/g, (char) => {
11
13
  switch (char) {
@@ -26,9 +28,10 @@ function escapeXml(unsafe) {
26
28
  }
27
29
  function feedToOutlineXml(s, indent = " ") {
28
30
  const text = escapeXml(s.label || s.title || s.url);
31
+ const title = escapeXml(s.title || s.label || s.url);
29
32
  const xmlUrl = escapeXml(s.url);
30
33
  const htmlUrl = s.site_url ? ` htmlUrl="${escapeXml(s.site_url)}"` : "";
31
- return `${indent}<outline text="${text}" title="${text}" type="rss" xmlUrl="${xmlUrl}"${htmlUrl} />`;
34
+ return `${indent}<outline text="${text}" title="${title}" type="rss" xmlUrl="${xmlUrl}"${htmlUrl} />`;
32
35
  }
33
36
  /**
34
37
  * Generates standard OPML 2.0 XML representation of all subscribed feeds for a user.
@@ -74,7 +77,26 @@ ${outlines}
74
77
  `;
75
78
  }
76
79
  /**
77
- * Recursively extracts outline elements containing an xmlUrl attribute from parsed OPML.
80
+ * Reads an attribute from a parsed XML node case-insensitively in priority order.
81
+ */
82
+ function getAttr(record, ...names) {
83
+ const entries = Object.entries(record);
84
+ for (const name of names) {
85
+ const target = `@_${name.toLowerCase()}`;
86
+ for (const [key, value] of entries) {
87
+ if (key.toLowerCase() === target) {
88
+ if (typeof value === "string")
89
+ return value;
90
+ if (typeof value === "number" || typeof value === "boolean")
91
+ return String(value);
92
+ }
93
+ }
94
+ }
95
+ return undefined;
96
+ }
97
+ /**
98
+ * Recursively extracts outline elements containing a feed URL from parsed OPML.
99
+ * Nested subfolders are flattened to the top level so each subfolder becomes its own folder.
78
100
  */
79
101
  function extractOutlines(node, items = [], currentFolder) {
80
102
  if (!node || typeof node !== "object")
@@ -86,19 +108,30 @@ function extractOutlines(node, items = [], currentFolder) {
86
108
  return items;
87
109
  }
88
110
  const record = node;
89
- const xmlUrl = (typeof record["@_xmlUrl"] === "string" ? record["@_xmlUrl"] : undefined) ??
90
- (typeof record["@_xmlurl"] === "string" ? record["@_xmlurl"] : undefined);
91
- const rawLabel = (typeof record["@_text"] === "string" ? record["@_text"] : undefined) ??
92
- (typeof record["@_title"] === "string" ? record["@_title"] : undefined);
93
- if (xmlUrl) {
94
- const label = rawLabel?.trim() ? decodeEntities(rawLabel.trim()) : undefined;
95
- items.push({ url: xmlUrl.trim(), label, folder: currentFolder });
111
+ const hasChildren = Boolean(record.outline);
112
+ // Leaf outlines without xmlUrl may use url="..." in older OPML 1.0 exports.
113
+ const rawXmlUrl = getAttr(record, "xmlUrl") ?? (!hasChildren ? getAttr(record, "url") : undefined);
114
+ const rawText = getAttr(record, "text");
115
+ const rawTitle = getAttr(record, "title");
116
+ const rawHtmlUrl = getAttr(record, "htmlUrl");
117
+ const label = plainTitle(rawText ?? rawTitle)?.slice(0, MAX_FEED_LABEL_LENGTH);
118
+ const title = plainTitle(rawTitle ?? rawText)?.slice(0, MAX_FEED_LABEL_LENGTH);
119
+ const siteUrl = rawHtmlUrl?.trim() ? decodeEntities(rawHtmlUrl.trim()) : undefined;
120
+ let childFolder = currentFolder;
121
+ if (rawXmlUrl && rawXmlUrl.trim()) {
122
+ const url = decodeEntities(rawXmlUrl.trim());
123
+ const item = { url, label, folder: currentFolder };
124
+ if (title && title !== label)
125
+ item.title = title;
126
+ if (siteUrl)
127
+ item.siteUrl = siteUrl;
128
+ items.push(item);
96
129
  }
97
- else if (rawLabel && record.outline) {
98
- currentFolder = decodeEntities(rawLabel.trim());
130
+ else if (label && hasChildren) {
131
+ childFolder = label.slice(0, MAX_FOLDER_NAME_LENGTH);
99
132
  }
100
- if (record.outline) {
101
- extractOutlines(record.outline, items, currentFolder);
133
+ if (hasChildren) {
134
+ extractOutlines(record.outline, items, childFolder);
102
135
  }
103
136
  return items;
104
137
  }
@@ -107,22 +140,77 @@ export const MAX_OPML_FEEDS = 2000;
107
140
  * Parses an OPML document string and returns a list of feed URLs and their labels.
108
141
  */
109
142
  export function parseOpml(xmlContent) {
143
+ const cleanedXml = xmlContent.replace(/^\uFEFF/, "").trim();
144
+ if (!cleanedXml) {
145
+ throw new Error("invalid_opml_format");
146
+ }
110
147
  const parser = new XMLParser({
111
148
  ignoreAttributes: false,
112
149
  attributeNamePrefix: "@_",
113
150
  trimValues: true,
114
151
  });
115
- const parsed = parser.parse(xmlContent);
116
- if (!parsed || !parsed.opml || !parsed.opml.body) {
152
+ const parsed = parser.parse(cleanedXml);
153
+ if (!parsed || typeof parsed.opml !== "object" || parsed.opml === null || !("body" in parsed.opml)) {
117
154
  throw new Error("invalid_opml_format");
118
155
  }
119
- const feeds = extractOutlines(parsed.opml.body.outline);
156
+ const body = parsed.opml.body;
157
+ const outlineRoot = body && typeof body === "object" ? body.outline : undefined;
158
+ const feeds = extractOutlines(outlineRoot);
120
159
  if (feeds.length > MAX_OPML_FEEDS) {
121
160
  throw new Error("too_many_opml_feeds");
122
161
  }
123
162
  return feeds;
124
163
  }
125
164
  const urlSchema = z.string().url();
165
+ function normalizeOpmlUrl(rawUrl) {
166
+ const trimmed = rawUrl.trim();
167
+ if (/^feeds?:https?:\/\//i.test(trimmed)) {
168
+ return trimmed.replace(/^feeds?:/i, "");
169
+ }
170
+ if (/^feeds?:\/\//i.test(trimmed)) {
171
+ const rest = trimmed.replace(/^feeds?:\/\//i, "");
172
+ return /^https?:\/\//i.test(rest) ? rest : `https://${rest}`;
173
+ }
174
+ if (trimmed.startsWith("//"))
175
+ return `https:${trimmed}`;
176
+ return trimmed;
177
+ }
178
+ function sanitizeHttpUrl(raw) {
179
+ if (!raw)
180
+ return null;
181
+ const trimmed = raw.trim();
182
+ if (!trimmed)
183
+ return null;
184
+ try {
185
+ const parsed = new URL(trimmed.startsWith("//") ? `https:${trimmed}` : trimmed);
186
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:")
187
+ return null;
188
+ return parsed.toString();
189
+ }
190
+ catch {
191
+ return null;
192
+ }
193
+ }
194
+ function isUrlFallbackLabel(label, rawUrl, normalizedUrl) {
195
+ if (!label)
196
+ return true;
197
+ const trimmed = label.trim();
198
+ if (!trimmed)
199
+ return true;
200
+ if (trimmed === rawUrl || trimmed === normalizedUrl)
201
+ return true;
202
+ try {
203
+ return new URL(normalizeOpmlUrl(trimmed)).toString() === normalizedUrl;
204
+ }
205
+ catch {
206
+ try {
207
+ return new URL(normalizeUrlCandidate(trimmed)).toString() === normalizedUrl;
208
+ }
209
+ catch {
210
+ return false;
211
+ }
212
+ }
213
+ }
126
214
  /**
127
215
  * Imports feeds from an OPML document string for the given user.
128
216
  */
@@ -139,7 +227,8 @@ export async function importOpmlFeeds(userId, xmlContent) {
139
227
  for (const item of feedItems) {
140
228
  if (!item.url)
141
229
  continue;
142
- const urlCheck = urlSchema.safeParse(item.url);
230
+ const candidateUrl = normalizeOpmlUrl(item.url);
231
+ const urlCheck = urlSchema.safeParse(candidateUrl);
143
232
  if (!urlCheck.success) {
144
233
  result.failed++;
145
234
  result.errors.push({ url: item.url, reason: "invalid_url_format" });
@@ -147,7 +236,7 @@ export async function importOpmlFeeds(userId, xmlContent) {
147
236
  }
148
237
  let normalizedUrl;
149
238
  try {
150
- const validated = await assertPublicFeedUrl(item.url);
239
+ const validated = await assertPublicFeedUrl(candidateUrl);
151
240
  normalizedUrl = validated.toString();
152
241
  }
153
242
  catch (error) {
@@ -161,22 +250,28 @@ export async function importOpmlFeeds(userId, xmlContent) {
161
250
  if (!feed) {
162
251
  feed = createFeed(normalizedUrl, Math.max(config.minPollIntervalMinutes, 15));
163
252
  }
253
+ const rawTitle = item.title ?? item.label;
254
+ const seedTitle = isUrlFallbackLabel(rawTitle, item.url, normalizedUrl) ? null : (rawTitle ?? null);
255
+ const seedSiteUrl = sanitizeHttpUrl(item.siteUrl);
256
+ seedFeedMetadata(feed.id, { title: seedTitle, siteUrl: seedSiteUrl });
164
257
  if (isUserSubscribed(userId, feed.id)) {
165
258
  result.skipped++;
166
259
  continue;
167
260
  }
168
261
  let folderId = null;
169
- if (item.folder) {
170
- if (folderCache.has(item.folder)) {
171
- folderId = folderCache.get(item.folder);
262
+ const folderName = item.folder?.trim().slice(0, MAX_FOLDER_NAME_LENGTH);
263
+ if (folderName) {
264
+ if (folderCache.has(folderName)) {
265
+ folderId = folderCache.get(folderName);
172
266
  }
173
267
  else {
174
- const folder = createFolder(userId, item.folder);
268
+ const folder = createFolder(userId, folderName);
175
269
  folderId = folder.id;
176
- folderCache.set(item.folder, folder.id);
270
+ folderCache.set(folderName, folder.id);
177
271
  }
178
272
  }
179
- subscribe(userId, feed.id, item.label ?? null, folderId);
273
+ const customLabel = isUrlFallbackLabel(item.label, item.url, normalizedUrl) ? null : (item.label ?? null);
274
+ subscribe(userId, feed.id, customLabel, folderId);
180
275
  result.imported++;
181
276
  feedsToPoll.push(feed);
182
277
  }
@@ -16,6 +16,14 @@ export function createFeed(url, pollIntervalMinutes) {
16
16
  .run(url, pollIntervalMinutes);
17
17
  return findFeedById(Number(result.lastInsertRowid));
18
18
  }
19
+ export function seedFeedMetadata(feedId, data) {
20
+ if (!data.title && !data.siteUrl)
21
+ return;
22
+ db.prepare(`UPDATE feeds SET
23
+ title = COALESCE(NULLIF(TRIM(title), ''), ?),
24
+ site_url = COALESCE(NULLIF(TRIM(site_url), ''), ?)
25
+ WHERE id = ?`).run(data.title?.trim() || null, data.siteUrl?.trim() || null, feedId);
26
+ }
19
27
  export function subscribe(userId, feedId, label, folderId) {
20
28
  const maxPosRow = db
21
29
  .prepare("SELECT MAX(position) AS max_pos FROM subscriptions WHERE user_id = ?")
@@ -170,7 +178,7 @@ export function upsertItems(feedId, items) {
170
178
  refreshLead(Number(result.lastInsertRowid));
171
179
  }
172
180
  else {
173
- const previous = db.prepare("SELECT id, title, link, image_url, content_snippet, content_html, full_content_html, lead_version FROM items WHERE feed_id = ? AND guid = ?").get(feedId, row.guid);
181
+ const previous = db.prepare("SELECT id, title, link, image_url, content_snippet, content_html, full_content_html, published_at, author, lead_version FROM items WHERE feed_id = ? AND guid = ?").get(feedId, row.guid);
174
182
  const next = row.contentHtml;
175
183
  // Some publishers replace complete feed bodies with short teasers on a later poll.
176
184
  const retained = previous?.content_html && next && next.length < previous.content_html.length * 0.9 &&
@@ -185,9 +193,9 @@ export function upsertItems(feedId, items) {
185
193
  (row.link !== undefined && row.link !== previous.link) ||
186
194
  (!keptPrevious && row.contentSnippet !== undefined && row.contentSnippet !== previous.content_snippet) ||
187
195
  (retained !== null && retained !== previous.content_html) ||
188
- (row.publishedAt !== undefined && row.publishedAt !== null) ||
196
+ (row.publishedAt !== undefined && row.publishedAt !== null && row.publishedAt !== previous.published_at) ||
189
197
  (nextImage !== previous.image_url) ||
190
- (row.author !== undefined);
198
+ (row.author !== undefined && row.author !== null && row.author !== previous.author);
191
199
  if (changed) {
192
200
  update.run(row.title ?? null, row.link ?? null, nextSnippet, retained, row.publishedAt ?? null, nextImage, row.author ?? null, feedId, row.guid);
193
201
  const leadChanged = (retained !== null && retained !== previous.content_html) ||
@@ -210,15 +218,17 @@ export function upsertItems(feedId, items) {
210
218
  return insertMany(items);
211
219
  }
212
220
  export function listItemsForUser(userId, opts = {}) {
213
- // Saved articles stay visible without a subscription (e.g. after unsubscribing).
221
+ // Saved and annotated articles stay visible after unsubscribing.
214
222
  const conditions = [];
223
+ const params = [];
215
224
  if (opts.bookmarkedOnly) {
216
225
  conditions.push("b.item_id IS NOT NULL");
217
226
  }
218
227
  else {
219
- conditions.push("(s.id IS NOT NULL OR b.item_id IS NOT NULL)");
228
+ conditions.push(`(s.id IS NOT NULL OR b.item_id IS NOT NULL OR
229
+ EXISTS (SELECT 1 FROM item_notes access_note WHERE access_note.item_id = i.id AND access_note.user_id = ?))`);
230
+ params.push(userId);
220
231
  }
221
- const params = [];
222
232
  // Keep the stored item available to direct reads and sync; deliberate saves/notes override stream filtering.
223
233
  conditions.push(`(i.is_empty_gallery = 0 OR b.item_id IS NOT NULL OR
224
234
  EXISTS (SELECT 1 FROM item_notes gallery_note WHERE gallery_note.item_id = i.id AND gallery_note.user_id = ?))`);
@@ -410,7 +420,9 @@ export function findFolderById(userId, id) {
410
420
  }
411
421
  export function createFolder(userId, name, iconSymbol) {
412
422
  const trimmed = name.trim();
413
- db.prepare("INSERT INTO folders (user_id, name, icon_symbol) VALUES (?, ?, ?) ON CONFLICT (user_id, name) DO NOTHING").run(userId, trimmed, iconSymbol ?? null);
423
+ db.prepare(`INSERT INTO folders (user_id, name, icon_symbol) VALUES (?, ?, ?)
424
+ ON CONFLICT (user_id, name) DO UPDATE SET icon_symbol = excluded.icon_symbol
425
+ WHERE ? AND folders.icon_symbol IS NOT excluded.icon_symbol`).run(userId, trimmed, iconSymbol ?? null, Number(iconSymbol !== undefined));
414
426
  return findFolderByName(userId, trimmed);
415
427
  }
416
428
  export function updateFolder(userId, id, updates) {
@@ -51,7 +51,7 @@ export function getItemForMcp(userId, itemId, maxChars = MAX_CONTENT_LENGTH, off
51
51
  return null;
52
52
  if (revision !== undefined && revision !== item.content_revision)
53
53
  throw new Error("content_revision_conflict");
54
- const content = readerHtml(item.full_content_html ?? item.content_html, item.title, item.author);
54
+ const content = readerHtml(item.full_content_html ?? item.content_html, item.title, item.author, item.link);
55
55
  const body = format === "text" ? sourceText(item) : content ?? "";
56
56
  let end = Math.min(offset + Math.min(maxChars, MAX_CONTENT_LENGTH), body.length);
57
57
  if (end < body.length && /[\uD800-\uDBFF]/.test(body[end - 1]))
@@ -42,7 +42,11 @@ function selectedItemIds({ itemId, itemIds }) {
42
42
  // instance is created per request (see mcp/http.ts) so tool handlers can
43
43
  // safely close over `userId` without leaking data between users.
44
44
  export function createMcpServerForUser(userId, scope) {
45
- const server = new McpServer({ name: "feedkeeper", version: APP_VERSION });
45
+ const server = new McpServer({ name: "feedkeeper", version: APP_VERSION }, {
46
+ instructions: "FeedKeeper is a personal RSS reader, article archiver, and news curation service. " +
47
+ "Use it to read, search, and triage RSS/Atom feeds, fetch full article text, manage reading lists, " +
48
+ "and read or publish curated newspaper editions.",
49
+ });
46
50
  server.registerTool("list_feeds", {
47
51
  title: "List subscribed feeds",
48
52
  description: "Lists all RSS/Atom feeds the current user is subscribed to, including unread counts and polling health.",
@@ -64,6 +68,8 @@ export function createMcpServerForUser(userId, scope) {
64
68
  folderId: z.number().int().positive().optional().describe("Optional folder/category ID"),
65
69
  },
66
70
  }, async ({ url, label, folderId }) => {
71
+ if (!hasUserCapability(userId, "sync"))
72
+ return { ...textResult(capabilityDenial(userId, "sync")), isError: true };
67
73
  try {
68
74
  const subscription = await subscribeToFeed(userId, url, label ?? null, folderId ?? null);
69
75
  return textResult(subscription);
@@ -90,6 +96,8 @@ export function createMcpServerForUser(userId, scope) {
90
96
  url: mcpUrlSchema.describe("The website URL to discover feeds from"),
91
97
  },
92
98
  }, async ({ url }) => {
99
+ if (!hasUserCapability(userId, "sync"))
100
+ return { ...textResult(capabilityDenial(userId, "sync")), isError: true };
93
101
  try {
94
102
  const feeds = await discoverFeeds(url);
95
103
  return textResult({ feeds });
@@ -116,8 +124,8 @@ export function createMcpServerForUser(userId, scope) {
116
124
  });
117
125
  if (scope === "write")
118
126
  server.registerTool("save_to_inbox", {
119
- title: "Save article or note to Inbox",
120
- description: "Saves a web article URL, raw HTML, text clipping, or markdown note directly into the user's Universal Inbox.",
127
+ title: "Save article or clipping to Inbox",
128
+ description: "Saves a web article URL, raw HTML, text clipping, or note directly into the user's Universal Inbox.",
121
129
  inputSchema: {
122
130
  url: mcpUrlSchema.optional().describe("Web page URL to fetch, extract, and save"),
123
131
  title: z.string().max(500).optional().describe("Optional custom title"),
@@ -189,6 +197,8 @@ export function createMcpServerForUser(userId, scope) {
189
197
  badge: z.boolean().optional().describe("Count this feed's unread articles in the number on the app icon"),
190
198
  },
191
199
  }, async ({ feedId, url, label, pollIntervalMinutes, fullText, notify, badge }) => {
200
+ if (url && !hasUserCapability(userId, "sync"))
201
+ return { ...textResult(capabilityDenial(userId, "sync")), isError: true };
192
202
  try {
193
203
  let targetFeedId = feedId;
194
204
  let previousItemsKept;
@@ -425,6 +435,8 @@ export function createMcpServerForUser(userId, scope) {
425
435
  opml: z.string().min(1).describe("The OPML XML content to import"),
426
436
  },
427
437
  }, async ({ opml }) => {
438
+ if (!hasUserCapability(userId, "sync"))
439
+ return { ...textResult(capabilityDenial(userId, "sync")), isError: true };
428
440
  try {
429
441
  const result = await importOpmlFeeds(userId, opml);
430
442
  return textResult(result);
@@ -441,6 +453,8 @@ export function createMcpServerForUser(userId, scope) {
441
453
  feedId: z.number().int().positive().describe("The ID of the feed to refresh"),
442
454
  },
443
455
  }, async ({ feedId }) => {
456
+ if (!hasUserCapability(userId, "sync"))
457
+ return { ...textResult(capabilityDenial(userId, "sync")), isError: true };
444
458
  const feed = findFeedById(feedId);
445
459
  if (!feed || !isUserSubscribed(userId, feedId)) {
446
460
  return errorResult("Feed not found or not subscribed");
@@ -62,7 +62,7 @@ app.use(cors({
62
62
  credentials: true,
63
63
  }));
64
64
  app.use(express.json({ limit: "1mb" }));
65
- app.use(express.text({ type: ["application/xml", "text/xml", "text/plain"], limit: "5mb" }));
65
+ app.use(express.text({ type: ["application/xml", "text/xml", "text/x-opml", "application/opml+xml", "text/plain"], limit: "5mb" }));
66
66
  app.use(cookieSession({
67
67
  name: "feedkeeper.sid",
68
68
  secret: config.sessionSecret,
@@ -20,6 +20,7 @@ export function serializeSubscription(subscription) {
20
20
  notify: Boolean(subscription.notify),
21
21
  badge: Boolean(subscription.badge),
22
22
  isInbox: Boolean(subscription.is_system_inbox),
23
+ lastError: subscription.last_error,
23
24
  iconHash: subscription.icon_hash ?? null,
24
25
  iconUrl: subscription.icon_hash
25
26
  ? `/api/v1/subscriptions/${subscription.id}/icon?v=${subscription.icon_hash}`
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@feedkeeper/server",
3
- "version": "0.35.3",
3
+ "version": "0.35.5",
4
4
  "private": true,
5
5
  "type": "module"
6
6
  }