feedkeeper 0.0.0-stage → 0.23.0

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 (137) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +22 -2
  3. package/package.json +57 -4
  4. package/server/dist/api/archive.js +19 -0
  5. package/server/dist/api/auth.js +162 -0
  6. package/server/dist/api/feeds.js +207 -0
  7. package/server/dist/api/filters.js +32 -0
  8. package/server/dist/api/folders.js +53 -0
  9. package/server/dist/api/index.js +37 -0
  10. package/server/dist/api/items.js +129 -0
  11. package/server/dist/api/oauth.js +14 -0
  12. package/server/dist/api/onboarding.js +30 -0
  13. package/server/dist/api/push.js +51 -0
  14. package/server/dist/api/system.js +42 -0
  15. package/server/dist/api/tokens.js +28 -0
  16. package/server/dist/api/v1/index.js +178 -0
  17. package/server/dist/api/v1/resources.js +653 -0
  18. package/server/dist/api/v1/serialize.js +48 -0
  19. package/server/dist/app.js +100 -0
  20. package/server/dist/auth/avatar.js +13 -0
  21. package/server/dist/auth/capabilities.js +53 -0
  22. package/server/dist/auth/middleware.js +77 -0
  23. package/server/dist/auth/pairing.js +36 -0
  24. package/server/dist/auth/password.js +9 -0
  25. package/server/dist/auth/plan.js +58 -0
  26. package/server/dist/auth/security.js +117 -0
  27. package/server/dist/auth/securityRoutes.js +249 -0
  28. package/server/dist/auth/tokens.js +64 -0
  29. package/server/dist/auth/users.js +54 -0
  30. package/server/dist/backup-cli.js +101 -0
  31. package/server/dist/cli.js +135 -0
  32. package/server/dist/clientAddress.js +26 -0
  33. package/server/dist/config.js +51 -0
  34. package/server/dist/db/index.js +40 -0
  35. package/server/dist/db/migrations/0001_init.sql +70 -0
  36. package/server/dist/db/migrations/0002_feed_health.sql +5 -0
  37. package/server/dist/db/migrations/0003_retention.sql +13 -0
  38. package/server/dist/db/migrations/0004_bookmarks_and_filters.sql +20 -0
  39. package/server/dist/db/migrations/0005_folders.sql +15 -0
  40. package/server/dist/db/migrations/0006_feed_position.sql +5 -0
  41. package/server/dist/db/migrations/0007_item_image_url.sql +2 -0
  42. package/server/dist/db/migrations/0008_item_content_html.sql +2 -0
  43. package/server/dist/db/migrations/0009_item_full_content_html.sql +1 -0
  44. package/server/dist/db/migrations/0010_token_scope.sql +3 -0
  45. package/server/dist/db/migrations/0011_user_avatars.sql +8 -0
  46. package/server/dist/db/migrations/0012_full_text_settings.sql +5 -0
  47. package/server/dist/db/migrations/0013_feed_icons.sql +4 -0
  48. package/server/dist/db/migrations/0014_bookmark_archive.sql +16 -0
  49. package/server/dist/db/migrations/0015_push.sql +13 -0
  50. package/server/dist/db/migrations/0016_feed_badge.sql +2 -0
  51. package/server/dist/db/migrations/0017_devices.sql +13 -0
  52. package/server/dist/db/migrations/0018_change_log.sql +166 -0
  53. package/server/dist/db/migrations/0019_native_extensions.sql +111 -0
  54. package/server/dist/db/migrations/0020_fts.sql +32 -0
  55. package/server/dist/db/migrations/0021_note_revisions.sql +33 -0
  56. package/server/dist/db/migrations/0022_high_resolution_icons.sql +2 -0
  57. package/server/dist/db/migrations/0023_server_editions.sql +81 -0
  58. package/server/dist/db/migrations/0024_inbox.sql +5 -0
  59. package/server/dist/db/migrations/0025_edition_overview.sql +2 -0
  60. package/server/dist/db/migrations/0026_article_content.sql +43 -0
  61. package/server/dist/db/migrations/0027_oauth.sql +38 -0
  62. package/server/dist/db/migrations/0028_user_intro.sql +2 -0
  63. package/server/dist/db/migrations/0029_account_security.sql +35 -0
  64. package/server/dist/db/migrations/0030_feed_poll_jobs.sql +15 -0
  65. package/server/dist/db/migrations/0031_push_outbox.sql +19 -0
  66. package/server/dist/feedkeeper.js +38 -0
  67. package/server/dist/feeds/archive.js +334 -0
  68. package/server/dist/feeds/articleContent.js +26 -0
  69. package/server/dist/feeds/cleanup.js +179 -0
  70. package/server/dist/feeds/discovery.js +153 -0
  71. package/server/dist/feeds/extractor.js +208 -0
  72. package/server/dist/feeds/feedIcon.js +171 -0
  73. package/server/dist/feeds/fetcher.js +217 -0
  74. package/server/dist/feeds/fullText.js +91 -0
  75. package/server/dist/feeds/fullTextQueue.js +159 -0
  76. package/server/dist/feeds/icon.js +97 -0
  77. package/server/dist/feeds/imageCache.js +266 -0
  78. package/server/dist/feeds/imageCacheSettings.js +19 -0
  79. package/server/dist/feeds/imageTypes.js +16 -0
  80. package/server/dist/feeds/inbox.js +128 -0
  81. package/server/dist/feeds/opml.js +190 -0
  82. package/server/dist/feeds/pollQueue.js +301 -0
  83. package/server/dist/feeds/pollSettings.js +23 -0
  84. package/server/dist/feeds/poller.js +222 -0
  85. package/server/dist/feeds/repository.js +674 -0
  86. package/server/dist/feeds/requestGate.js +99 -0
  87. package/server/dist/feeds/service.js +162 -0
  88. package/server/dist/feeds/ssrfGuard.js +98 -0
  89. package/server/dist/feeds/text.js +35 -0
  90. package/server/dist/index.js +15 -0
  91. package/server/dist/mcp/digest.js +73 -0
  92. package/server/dist/mcp/http.js +46 -0
  93. package/server/dist/mcp/items.js +73 -0
  94. package/server/dist/mcp/promptsAndResources.js +43 -0
  95. package/server/dist/mcp/server.js +547 -0
  96. package/server/dist/native/editionSelection.js +127 -0
  97. package/server/dist/native/editionText.js +15 -0
  98. package/server/dist/native/editions.js +218 -0
  99. package/server/dist/native/preferences.js +66 -0
  100. package/server/dist/native/requests.js +35 -0
  101. package/server/dist/oauth/constants.js +6 -0
  102. package/server/dist/oauth/host.js +8 -0
  103. package/server/dist/oauth/pages.js +148 -0
  104. package/server/dist/oauth/routes.js +361 -0
  105. package/server/dist/oauth/store.js +104 -0
  106. package/server/dist/push.js +147 -0
  107. package/server/dist/pushOutbox.js +164 -0
  108. package/server/dist/security-recover-cli.js +29 -0
  109. package/server/dist/server.js +108 -0
  110. package/server/dist/setup-cli.js +97 -0
  111. package/server/dist/sync/changeLog.js +170 -0
  112. package/server/dist/sync/mutations.js +144 -0
  113. package/server/dist/trustProxy.js +14 -0
  114. package/server/dist/version.js +6 -0
  115. package/server/package.json +6 -0
  116. package/web/dist/app-icon-180.png +0 -0
  117. package/web/dist/app-icon-192.png +0 -0
  118. package/web/dist/app-icon-512.png +0 -0
  119. package/web/dist/assets/index-B4e2oqbo.css +2 -0
  120. package/web/dist/assets/index-xx6TyOwB.js +32 -0
  121. package/web/dist/assets/inter-latin-400-normal-C38fXH4l.woff2 +0 -0
  122. package/web/dist/assets/inter-latin-400-normal-CyCys3Eg.woff +0 -0
  123. package/web/dist/assets/inter-latin-500-normal-BL9OpVg8.woff +0 -0
  124. package/web/dist/assets/inter-latin-500-normal-Cerq10X2.woff2 +0 -0
  125. package/web/dist/assets/inter-latin-600-normal-CiBQ2DWP.woff +0 -0
  126. package/web/dist/assets/inter-latin-600-normal-LgqL8muc.woff2 +0 -0
  127. package/web/dist/assets/manrope-latin-600-normal-4f0koTD-.woff2 +0 -0
  128. package/web/dist/assets/manrope-latin-600-normal-BqgrALkZ.woff +0 -0
  129. package/web/dist/assets/manrope-latin-700-normal-BZp_XxE4.woff2 +0 -0
  130. package/web/dist/assets/manrope-latin-700-normal-DGRFkw-m.woff +0 -0
  131. package/web/dist/favicon-32.png +0 -0
  132. package/web/dist/index.html +40 -0
  133. package/web/dist/logo.svg +50 -0
  134. package/web/dist/manifest.webmanifest +64 -0
  135. package/web/dist/robots.txt +3 -0
  136. package/web/dist/sw.js +198 -0
  137. package/web/dist/theme-init.js +10 -0
@@ -0,0 +1,99 @@
1
+ import { readPollSettings } from "./pollSettings.js";
2
+ export class PollDeferredError extends Error {
3
+ retryAt;
4
+ host;
5
+ constructor(retryAt, host) {
6
+ super("poll_deferred");
7
+ this.retryAt = retryAt;
8
+ this.host = host;
9
+ }
10
+ }
11
+ export class RequestQueueFullError extends Error {
12
+ constructor() { super("request_queue_full"); }
13
+ }
14
+ export class PollShutdownError extends Error {
15
+ constructor() { super("poll_shutdown"); }
16
+ }
17
+ /** Hostname, deliberately excluding port and scheme; redirects acquire their destination too. */
18
+ export function sourceHost(url) {
19
+ return new URL(url).hostname.toLowerCase().replace(/\.$/, "");
20
+ }
21
+ /** Shared by feed downloads, discovery, probes and icons. Never holds a permit over a redirect or delay. */
22
+ export class RequestGate {
23
+ settings;
24
+ running = 0;
25
+ hosts = new Map();
26
+ waiting = [];
27
+ stopped = false;
28
+ cooldown = () => 0;
29
+ defer = () => { };
30
+ // Integrations can load their .env after importing core modules. Freeze limits on first use,
31
+ // after startup configuration, rather than capturing defaults during module evaluation.
32
+ constructor(settings) {
33
+ this.settings = settings;
34
+ }
35
+ get limits() { return this.settings ??= readPollSettings(); }
36
+ get stats() { return { running: this.running, waiting: this.waiting.length }; }
37
+ acquire(host, signal) {
38
+ if (this.stopped)
39
+ return Promise.reject(new PollShutdownError());
40
+ if (signal.aborted)
41
+ return Promise.reject(signal.reason);
42
+ const until = this.cooldown(host);
43
+ if (until > Date.now())
44
+ return Promise.reject(new PollDeferredError(until, host));
45
+ if (this.waiting.length >= this.limits.maxWaiters)
46
+ return Promise.reject(new RequestQueueFullError());
47
+ return new Promise((resolve, reject) => {
48
+ const waiter = { host, signal, resolve, reject, abort: () => {
49
+ this.waiting = this.waiting.filter(entry => entry !== waiter);
50
+ reject(signal.reason);
51
+ } };
52
+ signal.addEventListener("abort", waiter.abort, { once: true });
53
+ this.waiting.push(waiter);
54
+ this.pump();
55
+ });
56
+ }
57
+ pump() {
58
+ for (let i = 0; i < this.waiting.length;) {
59
+ const waiter = this.waiting[i];
60
+ const until = this.cooldown(waiter.host);
61
+ if (until > Date.now()) {
62
+ this.waiting.splice(i, 1);
63
+ waiter.signal.removeEventListener("abort", waiter.abort);
64
+ waiter.reject(new PollDeferredError(until, waiter.host));
65
+ continue;
66
+ }
67
+ if (this.running >= this.limits.concurrency || (this.hosts.get(waiter.host) ?? 0) >= this.limits.hostConcurrency) {
68
+ i++;
69
+ continue;
70
+ }
71
+ this.waiting.splice(i, 1);
72
+ waiter.signal.removeEventListener("abort", waiter.abort);
73
+ this.running++;
74
+ this.hosts.set(waiter.host, (this.hosts.get(waiter.host) ?? 0) + 1);
75
+ let released = false;
76
+ waiter.resolve(() => {
77
+ if (released)
78
+ return;
79
+ released = true;
80
+ this.running--;
81
+ const count = this.hosts.get(waiter.host) - 1;
82
+ if (count)
83
+ this.hosts.set(waiter.host, count);
84
+ else
85
+ this.hosts.delete(waiter.host);
86
+ this.pump();
87
+ });
88
+ }
89
+ }
90
+ stop() {
91
+ this.stopped = true;
92
+ for (const waiter of this.waiting.splice(0)) {
93
+ waiter.signal.removeEventListener("abort", waiter.abort);
94
+ waiter.reject(new PollShutdownError());
95
+ }
96
+ }
97
+ }
98
+ export const feedRequests = new RequestGate();
99
+ //# sourceMappingURL=requestGate.js.map
@@ -0,0 +1,162 @@
1
+ import { config } from "../config.js";
2
+ import { createFeed, findFeedByUrl, isUserSubscribed, subscribe, unsubscribe as unsubscribeRepo, listSubscriptionsForUser, updateSubscriptionLabel, updateSubscriptionFolder, updateFeedPollInterval, updateSubscriptionFullTextMode, updateSubscriptionNotify, updateSubscriptionBadge, findFolderById, findFeedById, countFeedSubscribers, replaceFeedUrl, moveSubscription, } from "./repository.js";
3
+ import { pollFeed, probeFeed } from "./poller.js";
4
+ import { assertPublicHttpUrl } from "./ssrfGuard.js";
5
+ import { discoverFeeds } from "./discovery.js";
6
+ export class FeedError extends Error {
7
+ }
8
+ export class NoFeedsFoundError extends FeedError {
9
+ constructor() {
10
+ super("no_feeds_found");
11
+ this.name = "NoFeedsFoundError";
12
+ }
13
+ }
14
+ export class MultipleFeedsFoundError extends FeedError {
15
+ feeds;
16
+ constructor(feeds) {
17
+ super("multiple_feeds_found");
18
+ this.feeds = feeds;
19
+ this.name = "MultipleFeedsFoundError";
20
+ }
21
+ }
22
+ /** Turn a feed or website URL into a feed URL, using auto-discovery for website addresses. */
23
+ async function resolveFeedUrl(inputUrl) {
24
+ const validated = await assertPublicHttpUrl(inputUrl.trim());
25
+ const targetUrl = validated.toString();
26
+ // A known shared feed needs no second discovery download beside its queued poll.
27
+ if (findFeedByUrl(targetUrl))
28
+ return { url: targetUrl };
29
+ try {
30
+ const discovered = await discoverFeeds(targetUrl);
31
+ if (discovered.length === 1)
32
+ return { url: discovered[0].url };
33
+ if (discovered.length > 1) {
34
+ const exact = discovered.find((d) => d.url === targetUrl);
35
+ if (exact)
36
+ return { url: exact.url };
37
+ throw new MultipleFeedsFoundError(discovered);
38
+ }
39
+ }
40
+ catch (err) {
41
+ if (err instanceof FeedError)
42
+ throw err;
43
+ // Otherwise fall back to trying the given URL directly
44
+ }
45
+ return { url: targetUrl };
46
+ }
47
+ export async function subscribeToFeed(userId, inputUrl, label = null, folderId = null) {
48
+ if (folderId !== null && !findFolderById(userId, folderId)) {
49
+ throw new FeedError("folder_not_found");
50
+ }
51
+ const { url: targetUrl } = await resolveFeedUrl(inputUrl);
52
+ let feed = findFeedByUrl(targetUrl);
53
+ if (!feed) {
54
+ feed = createFeed(targetUrl, Math.max(config.minPollIntervalMinutes, 15));
55
+ }
56
+ if (isUserSubscribed(userId, feed.id)) {
57
+ throw new FeedError("already_subscribed");
58
+ }
59
+ subscribe(userId, feed.id, label, folderId);
60
+ // Fetch immediately so the user sees items right away instead of waiting
61
+ // for the next scheduler tick.
62
+ const pollResult = await pollFeed(feed);
63
+ // A feed that has never been read successfully and fails right away is not a feed
64
+ // (e.g. a homepage that announces itself as RSS); don't keep the subscription.
65
+ if (pollResult.error && !pollResult.deferred && !findFeedById(feed.id)?.last_success_at) {
66
+ unsubscribeRepo(userId, feed.id);
67
+ throw new NoFeedsFoundError();
68
+ }
69
+ const subscription = listSubscriptionsForUser(userId).find((s) => s.id === feed.id);
70
+ if (!subscription)
71
+ throw new FeedError("subscription_lookup_failed");
72
+ return subscription;
73
+ }
74
+ export function unsubscribeFromFeed(userId, feedId) {
75
+ if (!isUserSubscribed(userId, feedId)) {
76
+ throw new FeedError("not_subscribed");
77
+ }
78
+ const feed = findFeedById(feedId);
79
+ if (feed?.is_system_inbox) {
80
+ throw new FeedError("cannot_unsubscribe_inbox");
81
+ }
82
+ unsubscribeRepo(userId, feedId);
83
+ }
84
+ export function updateFeedSettings(userId, feedId, settings) {
85
+ if (!isUserSubscribed(userId, feedId)) {
86
+ throw new FeedError("not_subscribed");
87
+ }
88
+ if (settings.folderId != null && !findFolderById(userId, settings.folderId)) {
89
+ throw new FeedError("folder_not_found");
90
+ }
91
+ if (settings.label !== undefined) {
92
+ updateSubscriptionLabel(userId, feedId, settings.label);
93
+ }
94
+ if (settings.folderId !== undefined) {
95
+ updateSubscriptionFolder(userId, feedId, settings.folderId);
96
+ }
97
+ if (settings.fullTextMode !== undefined) {
98
+ updateSubscriptionFullTextMode(userId, feedId, settings.fullTextMode);
99
+ }
100
+ if (settings.notify !== undefined) {
101
+ updateSubscriptionNotify(userId, feedId, settings.notify);
102
+ }
103
+ if (settings.badge !== undefined) {
104
+ updateSubscriptionBadge(userId, feedId, settings.badge);
105
+ }
106
+ if (settings.pollIntervalMinutes !== undefined) {
107
+ // The poll interval is stored per feed (shared across every subscriber),
108
+ // so it's floored account-wide rather than trusting each caller's input.
109
+ const minutes = Math.max(settings.pollIntervalMinutes, config.minPollIntervalMinutes);
110
+ updateFeedPollInterval(feedId, minutes);
111
+ }
112
+ const subscription = listSubscriptionsForUser(userId).find((s) => s.id === feedId);
113
+ if (!subscription)
114
+ throw new FeedError("subscription_lookup_failed");
115
+ return subscription;
116
+ }
117
+ /**
118
+ * Switch a subscription to a new feed URL, e.g. after a site moved its feed. The new URL is
119
+ * fetched and parsed first, so a broken address never replaces a working one. When the user
120
+ * is the only subscriber, the feed is updated in place and keeps its items, read state and
121
+ * bookmarks; otherwise the subscription moves to a separate feed for the new URL.
122
+ */
123
+ export async function changeFeedUrl(userId, feedId, inputUrl) {
124
+ const current = findFeedById(feedId);
125
+ if (!current || !isUserSubscribed(userId, feedId))
126
+ throw new FeedError("not_subscribed");
127
+ const { url } = await resolveFeedUrl(inputUrl);
128
+ let target = feedId;
129
+ const lookup = () => {
130
+ const subscription = listSubscriptionsForUser(userId).find((s) => s.id === target);
131
+ if (!subscription)
132
+ throw new FeedError("subscription_lookup_failed");
133
+ return subscription;
134
+ };
135
+ if (url === current.url)
136
+ return { subscription: lookup(), previousItemsKept: true };
137
+ const existing = findFeedByUrl(url);
138
+ if (existing && isUserSubscribed(userId, existing.id))
139
+ throw new FeedError("already_subscribed");
140
+ try {
141
+ await probeFeed(url);
142
+ }
143
+ catch (error) {
144
+ throw new FeedError(`feed_unreachable: ${error instanceof Error ? error.message : String(error)}`);
145
+ }
146
+ let previousItemsKept;
147
+ if (!existing && countFeedSubscribers(feedId) === 1) {
148
+ replaceFeedUrl(feedId, url);
149
+ previousItemsKept = true;
150
+ }
151
+ else {
152
+ const next = existing ?? createFeed(url, current.poll_interval_minutes);
153
+ moveSubscription(userId, feedId, next.id);
154
+ target = next.id;
155
+ previousItemsKept = false;
156
+ }
157
+ const feed = findFeedById(target);
158
+ if (feed)
159
+ await pollFeed(feed);
160
+ return { subscription: lookup(), previousItemsKept };
161
+ }
162
+ //# sourceMappingURL=service.js.map
@@ -0,0 +1,98 @@
1
+ import { lookup } from "node:dns/promises";
2
+ import { Agent as HttpsAgent } from "node:https";
3
+ import ipaddr from "ipaddr.js";
4
+ import { Agent } from "undici";
5
+ const BENCHMARK_NETWORK = ipaddr.parse("198.18.0.0");
6
+ function privateFeedsAllowed() {
7
+ return process.env.ALLOW_PRIVATE_FEEDS === "true";
8
+ }
9
+ export class SsrfBlockedError extends Error {
10
+ constructor(url) {
11
+ super(`Refusing to fetch "${url}": resolves to a blocked internal address`);
12
+ this.name = "SsrfBlockedError";
13
+ }
14
+ }
15
+ export function normalizeUrlCandidate(rawUrl) {
16
+ const trimmed = rawUrl.trim();
17
+ if (!trimmed)
18
+ return trimmed;
19
+ if (/^https?:\/\//i.test(trimmed))
20
+ return trimmed;
21
+ if (trimmed.startsWith("//"))
22
+ return `https:${trimmed}`;
23
+ return `https://${trimmed}`;
24
+ }
25
+ export function isPublicIp(address) {
26
+ try {
27
+ const parsed = ipaddr.process(address.replace(/^\[|\]$/g, ""));
28
+ // The benchmarking range is reported as "unicast" by ipaddr.js.
29
+ if (parsed.kind() === "ipv4" && parsed.match(BENCHMARK_NETWORK, 15)) {
30
+ return false;
31
+ }
32
+ return parsed.range() === "unicast";
33
+ }
34
+ catch {
35
+ return false;
36
+ }
37
+ }
38
+ function assertPublicAddresses(addresses, url) {
39
+ if (addresses.length === 0 || addresses.some(({ address }) => !isPublicIp(address))) {
40
+ throw new SsrfBlockedError(url);
41
+ }
42
+ }
43
+ export async function assertPublicHttpUrl(rawUrl) {
44
+ let url;
45
+ try {
46
+ url = new URL(normalizeUrlCandidate(rawUrl));
47
+ }
48
+ catch {
49
+ throw new Error("Invalid URL");
50
+ }
51
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
52
+ throw new Error("Only http:// and https:// URLs are allowed");
53
+ }
54
+ if (url.username || url.password)
55
+ throw new Error("URLs with credentials are not allowed");
56
+ if (privateFeedsAllowed())
57
+ return url;
58
+ const hostname = url.hostname.replace(/^\[|\]$/g, "");
59
+ if (ipaddr.isValid(hostname)) {
60
+ if (!isPublicIp(hostname))
61
+ throw new SsrfBlockedError(rawUrl);
62
+ }
63
+ else {
64
+ if (hostname === "localhost" || hostname.endsWith(".localhost") || hostname.endsWith(".local")) {
65
+ throw new SsrfBlockedError(rawUrl);
66
+ }
67
+ assertPublicAddresses(await lookup(hostname, { all: true }), rawUrl);
68
+ }
69
+ return url;
70
+ }
71
+ // Validate the address actually used by the socket. A DNS answer can change
72
+ // between URL validation and connection, so the preflight check alone is insufficient.
73
+ const guardedLookup = (hostname, options, callback) => {
74
+ lookup(hostname, { ...options, all: true })
75
+ .then((addresses) => {
76
+ try {
77
+ assertPublicAddresses(addresses, hostname);
78
+ if (options.all)
79
+ callback(null, addresses);
80
+ else
81
+ callback(null, addresses[0].address, addresses[0].family);
82
+ }
83
+ catch (error) {
84
+ callback(error, "");
85
+ }
86
+ })
87
+ .catch((error) => callback(error, ""));
88
+ };
89
+ export function createPublicDispatcher() {
90
+ if (privateFeedsAllowed())
91
+ return new Agent();
92
+ return new Agent({ connect: { lookup: guardedLookup } });
93
+ }
94
+ /** The same guard for libraries that use Node's https module, such as web-push. */
95
+ export function createPublicHttpsAgent() {
96
+ return privateFeedsAllowed() ? new HttpsAgent() : new HttpsAgent({ lookup: guardedLookup });
97
+ }
98
+ //# sourceMappingURL=ssrfGuard.js.map
@@ -0,0 +1,35 @@
1
+ const NAMED_ENTITIES = {
2
+ amp: "&", lt: "<", gt: ">", quot: '"', apos: "'", nbsp: " ",
3
+ lsquo: "‘", rsquo: "’", ldquo: "“", rdquo: "”", sbquo: "‚", bdquo: "„",
4
+ laquo: "«", raquo: "»", ndash: "–", mdash: "—", hellip: "…", middot: "·",
5
+ bull: "•", euro: "€", copy: "©", reg: "®", trade: "™", deg: "°",
6
+ auml: "ä", ouml: "ö", uuml: "ü", Auml: "Ä", Ouml: "Ö", Uuml: "Ü", szlig: "ß",
7
+ };
8
+ const ENTITY = /&(#x[0-9a-f]+|#\d+|[a-z][a-z0-9]*);/gi;
9
+ function decodeOnce(text) {
10
+ return text.replace(ENTITY, (match, name) => {
11
+ if (name[0] === "#") {
12
+ const code = name[1] === "x" || name[1] === "X" ? parseInt(name.slice(2), 16) : parseInt(name.slice(1), 10);
13
+ return Number.isFinite(code) && code > 0 && code <= 0x10ffff ? String.fromCodePoint(code) : match;
14
+ }
15
+ return NAMED_ENTITIES[name] ?? match;
16
+ });
17
+ }
18
+ /** A title as one line of text: entities decoded, line breaks and runs of spaces collapsed to one space, trimmed. */
19
+ export function plainTitle(text) {
20
+ const cleaned = decodeEntities(text)?.replace(/\s+/g, " ").trim();
21
+ return cleaned || undefined;
22
+ }
23
+ export function decodeEntities(text) {
24
+ if (!text || !text.includes("&"))
25
+ return text;
26
+ let current = text;
27
+ for (let pass = 0; pass < 3; pass++) {
28
+ const next = decodeOnce(current);
29
+ if (next === current)
30
+ break;
31
+ current = next;
32
+ }
33
+ return current;
34
+ }
35
+ //# sourceMappingURL=text.js.map
@@ -0,0 +1,15 @@
1
+ export { createApp, startCoreSchedulers, DEFAULT_WEB_DIST } from "./app.js";
2
+ export * from "./auth/capabilities.js";
3
+ export * from "./auth/tokens.js";
4
+ export * from "./auth/middleware.js";
5
+ export * from "./auth/users.js";
6
+ export * from "./config.js";
7
+ export * from "./db/index.js";
8
+ export { apiRouter } from "./api/index.js";
9
+ export { mcpRouter } from "./mcp/http.js";
10
+ export { startPollingScheduler } from "./feeds/poller.js";
11
+ export { startCleanupScheduler } from "./feeds/cleanup.js";
12
+ export { repairEncodedText } from "./feeds/repository.js";
13
+ export { pruneArchive, scheduleMissingArchives } from "./feeds/archive.js";
14
+ export { startEditionScheduler } from "./native/editions.js";
15
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,73 @@
1
+ import { db } from "../db/index.js";
2
+ import { listFoldersForUser, listItemsForUser, listSubscriptionsForUser } from "../feeds/repository.js";
3
+ const DIGEST_FETCH_LIMIT = 200;
4
+ export function truncate(text, maxChars) {
5
+ if (!text)
6
+ return null;
7
+ const clean = text.replace(/\s+/g, " ").trim();
8
+ return clean.length > maxChars ? `${clean.slice(0, maxChars).trimEnd()}…` : clean;
9
+ }
10
+ /** Items grouped by feed with short snippets: one call instead of many, and far fewer tokens. */
11
+ export function buildDigest(userId, options) {
12
+ const maxItemsPerFeed = options.maxItemsPerFeed ?? 5;
13
+ const snippetChars = options.snippetChars ?? 200;
14
+ const since = options.since ?? (options.hours ? new Date(Date.now() - options.hours * 3_600_000).toISOString() : undefined);
15
+ const rows = listItemsForUser(userId, {
16
+ feedId: options.feedId,
17
+ folderId: options.folderId,
18
+ unreadOnly: options.unreadOnly ?? true,
19
+ publishedSince: since,
20
+ sortByAdded: true,
21
+ limit: DIGEST_FETCH_LIMIT,
22
+ });
23
+ const groups = new Map();
24
+ for (const row of rows) {
25
+ const group = groups.get(row.feed_id) ?? { feedId: row.feed_id, feed: row.feed_title, items: [] };
26
+ group.items.push(row);
27
+ groups.set(row.feed_id, group);
28
+ }
29
+ return {
30
+ generatedAt: new Date().toISOString(),
31
+ since: since ?? null,
32
+ totalItems: rows.length,
33
+ // More than the fetch limit matched; the oldest ones are not part of the digest.
34
+ truncated: rows.length === DIGEST_FETCH_LIMIT,
35
+ feeds: [...groups.values()].map((group) => ({
36
+ feedId: group.feedId,
37
+ feed: group.feed,
38
+ itemCount: group.items.length,
39
+ items: group.items.slice(0, maxItemsPerFeed).map((item) => ({
40
+ id: item.id,
41
+ title: item.title,
42
+ link: item.link,
43
+ publishedAt: item.published_at,
44
+ bookmarked: Boolean(item.bookmarked),
45
+ snippet: truncate(item.content_snippet, snippetChars),
46
+ })),
47
+ moreItems: Math.max(0, group.items.length - maxItemsPerFeed),
48
+ })),
49
+ };
50
+ }
51
+ /** A quick picture of the account: what is unread, where, and what needs attention. */
52
+ export function buildOverview(userId) {
53
+ const feeds = listSubscriptionsForUser(userId);
54
+ const failing = feeds.filter((feed) => feed.last_error || feed.consecutive_errors > 0);
55
+ const saved = db.prepare("SELECT COUNT(*) AS n FROM item_bookmarks WHERE user_id = ?").get(userId).n;
56
+ const newest = db
57
+ .prepare("SELECT MAX(i.created_at) AS at FROM items i JOIN subscriptions s ON s.feed_id = i.feed_id AND s.user_id = ?")
58
+ .get(userId)?.at ?? null;
59
+ return {
60
+ feeds: feeds.length,
61
+ unread: feeds.reduce((total, feed) => total + feed.unread_count, 0),
62
+ savedArticles: saved,
63
+ newestItemAddedAt: newest,
64
+ feedsWithErrors: failing.map((feed) => ({ feedId: feed.id, title: feed.label ?? feed.title ?? feed.url, lastError: feed.last_error, consecutiveErrors: feed.consecutive_errors })),
65
+ topUnreadFeeds: [...feeds]
66
+ .filter((feed) => feed.unread_count > 0)
67
+ .sort((a, b) => b.unread_count - a.unread_count)
68
+ .slice(0, 10)
69
+ .map((feed) => ({ feedId: feed.id, title: feed.label ?? feed.title ?? feed.url, unread: feed.unread_count })),
70
+ folders: listFoldersForUser(userId).map((folder) => ({ folderId: folder.id, name: folder.name, feeds: folder.feed_count, unread: folder.unread_count })),
71
+ };
72
+ }
73
+ //# sourceMappingURL=digest.js.map
@@ -0,0 +1,46 @@
1
+ import { Router } from "express";
2
+ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
3
+ import { requireBearerToken } from "../auth/middleware.js";
4
+ import { requireCapability } from "../auth/capabilities.js";
5
+ import { createMcpServerForUser } from "./server.js";
6
+ export const mcpRouter = Router();
7
+ // Stateless mode: a fresh McpServer + transport per request, scoped to the
8
+ // user resolved from the bearer token. This is what makes the same endpoint
9
+ // safely usable by many different users/clients from anywhere.
10
+ mcpRouter.post("/", requireBearerToken, requireCapability("mcp"), async (req, res) => {
11
+ try {
12
+ const server = createMcpServerForUser(req.user.id, req.tokenScope);
13
+ const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
14
+ res.on("close", () => {
15
+ transport.close();
16
+ server.close();
17
+ });
18
+ await server.connect(transport);
19
+ await transport.handleRequest(req, res, req.body);
20
+ }
21
+ catch (error) {
22
+ console.error("[mcp] request failed", error);
23
+ if (!res.headersSent) {
24
+ res.status(500).json({
25
+ jsonrpc: "2.0",
26
+ error: { code: -32603, message: "Internal server error" },
27
+ id: null,
28
+ });
29
+ }
30
+ }
31
+ });
32
+ mcpRouter.get("/", requireBearerToken, (_req, res) => {
33
+ res.status(405).set("Allow", "POST").json({
34
+ jsonrpc: "2.0",
35
+ error: { code: -32000, message: "Method not allowed. This server only supports stateless POST requests." },
36
+ id: null,
37
+ });
38
+ });
39
+ mcpRouter.delete("/", requireBearerToken, (_req, res) => {
40
+ res.status(405).set("Allow", "POST").json({
41
+ jsonrpc: "2.0",
42
+ error: { code: -32000, message: "Method not allowed." },
43
+ id: null,
44
+ });
45
+ });
46
+ //# sourceMappingURL=http.js.map
@@ -0,0 +1,73 @@
1
+ import { contentMetadata, sourceText } from "../feeds/articleContent.js";
2
+ import { z } from "zod";
3
+ import { findItemForUser, listItemsForUser } from "../feeds/repository.js";
4
+ const cursorSchema = z.object({
5
+ createdAt: z.string().datetime({ offset: true }),
6
+ id: z.number().int().positive(),
7
+ });
8
+ export function encodeCursor(item) {
9
+ return Buffer.from(JSON.stringify({ createdAt: item.created_at, id: item.id })).toString("base64url");
10
+ }
11
+ export function decodeCursor(value) {
12
+ if (value.length > 256 || !/^[A-Za-z0-9_-]+$/.test(value))
13
+ throw new Error("invalid_cursor");
14
+ const decoded = Buffer.from(value, "base64url");
15
+ if (decoded.toString("base64url") !== value)
16
+ throw new Error("invalid_cursor");
17
+ try {
18
+ return cursorSchema.parse(JSON.parse(decoded.toString("utf8")));
19
+ }
20
+ catch {
21
+ throw new Error("invalid_cursor");
22
+ }
23
+ }
24
+ export function listItemsPage(userId, options) {
25
+ const limit = options.limit ?? 50;
26
+ const rows = listItemsForUser(userId, {
27
+ feedId: options.feedId,
28
+ folderId: options.folderId,
29
+ unreadOnly: options.unreadOnly,
30
+ bookmarkedOnly: options.bookmarkedOnly,
31
+ before: options.before ? decodeCursor(options.before) : undefined,
32
+ after: options.after ? decodeCursor(options.after) : undefined,
33
+ publishedSince: options.since,
34
+ publishedUntil: options.until,
35
+ sortByAdded: true,
36
+ limit: limit + 1,
37
+ });
38
+ const hasMore = rows.length > limit;
39
+ const page = rows.slice(0, limit);
40
+ const items = page.map(({ content_html: _contentHtml, full_content_html: _fullContentHtml, ...item }) => item);
41
+ return {
42
+ items,
43
+ newestCursor: page[0] ? encodeCursor(page[0]) : null,
44
+ nextCursor: hasMore && page.length ? encodeCursor(page[page.length - 1]) : null,
45
+ };
46
+ }
47
+ export const MAX_CONTENT_LENGTH = 40_000;
48
+ export function getItemForMcp(userId, itemId, maxChars = MAX_CONTENT_LENGTH, offset = 0, revision, format = "html") {
49
+ const item = findItemForUser(userId, itemId);
50
+ if (!item)
51
+ return null;
52
+ if (revision !== undefined && revision !== item.content_revision)
53
+ throw new Error("content_revision_conflict");
54
+ const content = item.full_content_html ?? item.content_html;
55
+ const body = format === "text" ? sourceText(item) : content ?? "";
56
+ let end = Math.min(offset + Math.min(maxChars, MAX_CONTENT_LENGTH), body.length);
57
+ if (end < body.length && /[\uD800-\uDBFF]/.test(body[end - 1]))
58
+ end--;
59
+ if (end === offset && offset < body.length)
60
+ end = Math.min(offset + 2, body.length);
61
+ return {
62
+ ...item,
63
+ content_html: format === "html" ? body.slice(offset, end) : undefined,
64
+ content_truncated: end < body.length,
65
+ full_content_html: undefined,
66
+ content: contentMetadata(item),
67
+ ...(format === "text" ? { text: body.slice(offset, end) } : {}),
68
+ format,
69
+ offset,
70
+ nextOffset: end < body.length ? end : null,
71
+ };
72
+ }
73
+ //# sourceMappingURL=items.js.map
@@ -0,0 +1,43 @@
1
+ import { ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { z } from "zod";
3
+ import { generateOpml } from "../feeds/opml.js";
4
+ import { listItemsForUser, listSubscriptionsForUser } from "../feeds/repository.js";
5
+ import { buildDigest } from "./digest.js";
6
+ import { getItemForMcp } from "./items.js";
7
+ const userMessage = (text) => ({ messages: [{ role: "user", content: { type: "text", text } }] });
8
+ /** Ready-made prompts and readable resources, so MCP clients can offer FeedKeeper without custom instructions. */
9
+ export function registerPromptsAndResources(server, userId, scope) {
10
+ server.registerPrompt("daily_briefing", {
11
+ title: "Daily briefing",
12
+ description: "A short briefing of what is new in your feeds, grouped by topic.",
13
+ argsSchema: { hours: z.string().regex(/^\d{1,3}$/).optional().describe("How many hours to look back (default 24)") },
14
+ }, ({ hours }) => userMessage(`Use the FeedKeeper tool get_digest with hours=${hours ?? "24"} to see what is new. Write a briefing: group the articles by topic rather than by feed, lead with the most important stories, give each one a sentence, and mention the article ids so I can ask for more. Skip trivia. If something deserves a closer look, fetch it with get_item.`));
15
+ server.registerPrompt("catch_up_on_topic", {
16
+ title: "Catch up on a topic",
17
+ description: "Find and summarize what your feeds say about a topic.",
18
+ argsSchema: { topic: z.string().min(1).max(200).describe("The topic or search words") },
19
+ }, ({ topic }) => userMessage(`Search my FeedKeeper articles for "${topic}" with search_items (also check saved articles with bookmarkedOnly). Read the most relevant ones with get_item and summarize how the story developed, with the sources and dates. Say clearly if there is little or nothing.`));
20
+ server.registerPrompt("saved_reading_list", {
21
+ title: "Saved reading list",
22
+ description: "Summarize the articles you saved for later and suggest what to read first.",
23
+ }, () => userMessage("List my saved FeedKeeper articles with list_items (bookmarkedOnly=true). Give each a one-line summary, group them by theme, and suggest a reading order with rough reading effort."));
24
+ server.registerPrompt("triage_unread", {
25
+ title: "Triage unread articles",
26
+ description: scope === "write" ? "Go through unread articles, save the keepers and mark the rest as read." : "Go through unread articles and recommend what to keep.",
27
+ argsSchema: { feedId: z.string().regex(/^\d{1,9}$/).optional().describe("Only this feed (id)") },
28
+ }, ({ feedId }) => userMessage(`Look at my unread FeedKeeper articles${feedId ? ` of feed ${feedId}` : ""} with get_digest. Recommend which are worth reading, which to save for later and which to skip.${scope === "write" ? " Ask me before you save or mark anything, then use bookmark_item and mark_read for the ones I confirm." : ""}`));
29
+ server.registerResource("feeds", "feedkeeper://feeds", { title: "Subscribed feeds", description: "All subscriptions with unread counts and health.", mimeType: "application/json" }, (uri) => ({ contents: [{ uri: uri.href, mimeType: "application/json", text: JSON.stringify(listSubscriptionsForUser(userId), null, 2) }] }));
30
+ server.registerResource("opml", "feedkeeper://opml", { title: "Subscriptions as OPML", description: "The subscription list in OPML 2.0, for backup or migration.", mimeType: "text/x-opml" }, (uri) => ({ contents: [{ uri: uri.href, mimeType: "text/x-opml", text: generateOpml(userId) }] }));
31
+ server.registerResource("saved", "feedkeeper://saved", { title: "Saved articles", description: "The newest 100 saved articles, without their content.", mimeType: "application/json" }, (uri) => {
32
+ const items = listItemsForUser(userId, { bookmarkedOnly: true, limit: 100 }).map(({ content_html: _html, full_content_html: _full, ...item }) => item);
33
+ return { contents: [{ uri: uri.href, mimeType: "application/json", text: JSON.stringify(items, null, 2) }] };
34
+ });
35
+ server.registerResource("digest", "feedkeeper://digest", { title: "Unread digest", description: "Unread articles of the last 24 hours, grouped by feed.", mimeType: "application/json" }, (uri) => ({ contents: [{ uri: uri.href, mimeType: "application/json", text: JSON.stringify(buildDigest(userId, { hours: 24 }), null, 2) }] }));
36
+ server.registerResource("item", new ResourceTemplate("feedkeeper://items/{itemId}", { list: undefined }), { title: "Article", description: "One article with its stored content.", mimeType: "application/json" }, (uri, variables) => {
37
+ const item = getItemForMcp(userId, Number(variables.itemId));
38
+ if (!item)
39
+ throw new Error("item_not_found");
40
+ return { contents: [{ uri: uri.href, mimeType: "application/json", text: JSON.stringify(item, null, 2) }] };
41
+ });
42
+ }
43
+ //# sourceMappingURL=promptsAndResources.js.map