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,127 @@
1
+ /** Remove only known tracking parameters; meaningful query parameters remain distinct. */
2
+ export function canonicalArticleUrl(value) {
3
+ if (!value)
4
+ return null;
5
+ try {
6
+ const url = new URL(value);
7
+ if (!["https:", "http:"].includes(url.protocol))
8
+ return null;
9
+ url.hash = "";
10
+ for (const key of [...url.searchParams.keys()]) {
11
+ if (/^utm_/i.test(key) || /^(fbclid|gclid|dclid|mc_cid|mc_eid)$/i.test(key))
12
+ url.searchParams.delete(key);
13
+ }
14
+ url.searchParams.sort();
15
+ url.pathname = url.pathname.replace(/\/+$/, "") || "/";
16
+ return url.toString();
17
+ }
18
+ catch {
19
+ return null;
20
+ }
21
+ }
22
+ function normalizedTitle(value) {
23
+ return (value ?? "").normalize("NFKD").replace(/\p{M}/gu, "").toLocaleLowerCase("en")
24
+ .replace(/[^\p{L}\p{N}]+/gu, " ").trim();
25
+ }
26
+ function duplicate(a, b) {
27
+ if (a.url && a.url === b.url)
28
+ return true;
29
+ if (a.title.length >= 25 && a.title === b.title)
30
+ return true;
31
+ if (Math.abs(a.published - b.published) > 48 * 3_600_000)
32
+ return false;
33
+ const words = a.words, otherWords = b.words;
34
+ if (words.size < 6 || otherWords.size < 6)
35
+ return false;
36
+ if (Math.min(words.size, otherWords.size) / Math.max(words.size, otherWords.size) < 0.9)
37
+ return false;
38
+ const shared = [...words].filter((word) => otherWords.has(word)).length;
39
+ return shared / (words.size + otherWords.size - shared) >= 0.9;
40
+ }
41
+ /** Selection is deterministic and independent of images and device opening counters.
42
+ * First favour new stories, then relax source caps to fill a small library fairly. */
43
+ export function selectEdition(candidates, settings) {
44
+ const chosen = [];
45
+ const chosenKeys = [];
46
+ const chosenIds = new Set();
47
+ // Normalize URLs and Unicode once, not for every pair on every selection pass.
48
+ const prepared = candidates.map((article) => {
49
+ const title = normalizedTitle(article.title);
50
+ return { article, title, words: new Set(title.split(" ")), url: canonicalArticleUrl(article.url), published: Date.parse(article.publishedAt) };
51
+ });
52
+ const feeds = new Map();
53
+ const folders = new Map();
54
+ let minutes = 0;
55
+ const distinctFolders = Math.max(1, new Set(candidates.map((item) => item.folderId)).size);
56
+ const folderCap = Math.max(2, Math.ceil(settings.editionSize / distinctFolders * 1.5));
57
+ // New stories precede repeats, including older background pieces within the seven-day window.
58
+ for (const repeats of [false, true]) {
59
+ for (const cap of [3, 6, 12, 24]) {
60
+ while (chosen.length < settings.editionSize) {
61
+ let best;
62
+ let bestScore = -Infinity;
63
+ for (const candidate of prepared) {
64
+ const item = candidate.article;
65
+ if (item.previouslySelected !== repeats || chosenIds.has(item.id) ||
66
+ (feeds.get(item.subscriptionId) ?? 0) >= cap ||
67
+ (folders.get(item.folderId) ?? 0) >= Math.max(folderCap, cap) ||
68
+ (settings.readingMinutes && minutes + item.readingMinutes > settings.readingMinutes && chosen.length > 0))
69
+ continue;
70
+ const adjusted = item.score / (1 + (feeds.get(item.subscriptionId) ?? 0) * 0.7 + (folders.get(item.folderId) ?? 0) * 0.35);
71
+ if (best && (adjusted < bestScore || (adjusted === bestScore &&
72
+ (candidate.published < best.published || (candidate.published === best.published && item.id < best.article.id)))))
73
+ continue;
74
+ if (chosenKeys.some((selected) => duplicate(selected, candidate)))
75
+ continue;
76
+ best = candidate;
77
+ bestScore = adjusted;
78
+ }
79
+ if (!best)
80
+ break;
81
+ const item = best.article;
82
+ chosen.push(item);
83
+ chosenKeys.push(best);
84
+ chosenIds.add(item.id);
85
+ minutes += item.readingMinutes;
86
+ feeds.set(item.subscriptionId, (feeds.get(item.subscriptionId) ?? 0) + 1);
87
+ folders.set(item.folderId, (folders.get(item.folderId) ?? 0) + 1);
88
+ }
89
+ }
90
+ }
91
+ return chosen;
92
+ }
93
+ const formatters = new Map();
94
+ export function editionPeriod(now, timeZone) {
95
+ let formatter = formatters.get(timeZone);
96
+ if (!formatter) {
97
+ formatter = new Intl.DateTimeFormat("en-CA", { timeZone, year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", hourCycle: "h23" });
98
+ if (formatters.size >= 100)
99
+ formatters.clear();
100
+ formatters.set(timeZone, formatter);
101
+ }
102
+ const parts = Object.fromEntries(formatter.formatToParts(now).map((part) => [part.type, part.value]));
103
+ const hour = Number(parts.hour);
104
+ const period = hour >= 5 && hour < 11 ? "morning" : hour >= 11 && hour < 17 ? "midday" : hour >= 17 && hour < 23 ? "evening" : "late";
105
+ // The late issue spans midnight; use the preceding calendar date before 05:00.
106
+ let day = `${parts.year}-${parts.month}-${parts.day}`;
107
+ if (hour < 5)
108
+ day = new Date(Date.parse(`${day}T12:00:00Z`) - 86_400_000).toISOString().slice(0, 10);
109
+ return { key: `${day}:${period}`, period };
110
+ }
111
+ /** Find the next actual boundary in the user's zone, including DST and fractional offsets. */
112
+ export function nextEditionBoundary(now, timeZone) {
113
+ const key = editionPeriod(now, timeZone).key;
114
+ let low = Math.floor(now.getTime() / 60_000);
115
+ let high = low + 60;
116
+ while (editionPeriod(new Date(high * 60_000), timeZone).key === key)
117
+ high += 60;
118
+ while (high - low > 1) {
119
+ const middle = Math.floor((low + high) / 2);
120
+ if (editionPeriod(new Date(middle * 60_000), timeZone).key === key)
121
+ low = middle;
122
+ else
123
+ high = middle;
124
+ }
125
+ return new Date(high * 60_000);
126
+ }
127
+ //# sourceMappingURL=editionSelection.js.map
@@ -0,0 +1,15 @@
1
+ import { findItemById, canAccessItem } from "../feeds/repository.js";
2
+ import { sourceText, contentMetadata } from "../feeds/articleContent.js";
3
+ /** Complete cached source material; consumers must explicitly chunk their own bounded requests. */
4
+ export function cachedEditionSources(userId, itemIds) {
5
+ if (itemIds.length > 24 || new Set(itemIds).size !== itemIds.length)
6
+ throw new Error("invalid_source_selection");
7
+ return itemIds.map(id => {
8
+ const item = findItemById(id);
9
+ if (!item || !canAccessItem(userId, id))
10
+ throw new Error("item_not_found");
11
+ return { id, text: sourceText(item), textKind: contentMetadata(item).source,
12
+ revision: item.content_revision, status: item.extraction_status, truncated: false };
13
+ });
14
+ }
15
+ //# sourceMappingURL=editionText.js.map
@@ -0,0 +1,218 @@
1
+ import { hasUserCapability } from "../auth/capabilities.js";
2
+ import { randomUUID } from "node:crypto";
3
+ import cron from "node-cron";
4
+ import { z } from "zod";
5
+ import { db } from "../db/index.js";
6
+ import { canAccessItem } from "../feeds/repository.js";
7
+ import { decodeEntities } from "../feeds/text.js";
8
+ import { editionPeriod, nextEditionBoundary, selectEdition } from "./editionSelection.js";
9
+ import { getNativePreferences } from "./preferences.js";
10
+ import { NativeError, nativeRequest } from "./requests.js";
11
+ export const overviewSchema = z.array(z.object({
12
+ heading: z.string().trim().min(1).max(160),
13
+ segments: z.array(z.object({
14
+ text: z.string().min(1).max(1000),
15
+ itemId: z.number().int().positive().optional(),
16
+ }).strict()).min(1).max(20),
17
+ }).strict()).min(1).max(5).refine((blocks) => blocks.reduce((sum, block) => sum + block.heading.length + block.segments.reduce((n, segment) => n + segment.text.length, 0), 0) <= 3000, "Overview text must not exceed 3000 characters");
18
+ function storedEdition(userId) {
19
+ return db.prepare("SELECT * FROM editions WHERE user_id = ?").get(userId);
20
+ }
21
+ function serialize(row) {
22
+ const prefs = getNativePreferences(row.user_id);
23
+ return {
24
+ id: row.edition_id, revision: row.revision, source: row.source,
25
+ createdAt: row.created_at, updatedAt: row.updated_at, expiresAt: row.expires_at,
26
+ period: row.period?.split(":")[1] ?? editionPeriod(new Date(row.created_at), prefs.timeZone).period,
27
+ title: row.title, summary: row.summary, overview: row.overview ? JSON.parse(row.overview) : null, preferencesRevision: row.preferences_revision,
28
+ itemIds: JSON.parse(row.item_ids),
29
+ };
30
+ }
31
+ export function getEditionState(userId, now = new Date()) {
32
+ const row = storedEdition(userId);
33
+ if (!row)
34
+ return { revision: 0, status: "none", edition: null };
35
+ const status = row.status === "active" && Date.parse(row.expires_at) <= now.getTime() ? "expired" : row.status;
36
+ return { revision: row.revision, status, edition: status === "active" ? serialize(row) : null };
37
+ }
38
+ export function getEdition(userId, now = new Date()) {
39
+ return getEditionState(userId, now).edition;
40
+ }
41
+ function checkRevision(userId, expectedRevision, now) {
42
+ const current = getEditionState(userId, now);
43
+ if (expectedRevision !== undefined && current.revision !== expectedRevision)
44
+ throw new NativeError("revision_conflict", 409, current);
45
+ }
46
+ function writeEdition(userId, itemIds, source, expiry, now, title = null, summary = null, overview = null) {
47
+ const prefs = getNativePreferences(userId);
48
+ const id = randomUUID();
49
+ const revision = (storedEdition(userId)?.revision ?? 0) + 1;
50
+ const created = now.toISOString();
51
+ db.prepare(`INSERT INTO editions (user_id, edition_id, item_ids, revision, source, status, created_at, updated_at, expires_at, period, title, summary, preferences_revision, overview)
52
+ VALUES (?, ?, ?, ?, ?, 'active', ?, ?, ?, ?, ?, ?, ?, ?)
53
+ ON CONFLICT(user_id) DO UPDATE SET edition_id = excluded.edition_id, item_ids = excluded.item_ids,
54
+ revision = excluded.revision, source = excluded.source, status = 'active', created_at = excluded.created_at,
55
+ updated_at = excluded.updated_at, expires_at = excluded.expires_at, period = excluded.period,
56
+ title = excluded.title, summary = excluded.summary, preferences_revision = excluded.preferences_revision, overview = excluded.overview`)
57
+ .run(userId, id, JSON.stringify(itemIds), revision, source, created, created, expiry.toISOString(), editionPeriod(now, prefs.timeZone).key, title, summary, prefs.revision, overview ? JSON.stringify(overview.map((block, index) => ({ id: `topic-${index + 1}`, ...block }))) : null);
58
+ db.prepare("INSERT INTO edition_history (user_id, edition_id, item_ids, revision, created_at) VALUES (?, ?, ?, ?, ?)")
59
+ .run(userId, id, JSON.stringify(itemIds), revision, created);
60
+ db.prepare(`DELETE FROM edition_history WHERE user_id = ? AND edition_id NOT IN
61
+ (SELECT edition_id FROM edition_history WHERE user_id = ? ORDER BY revision DESC LIMIT 32)`)
62
+ .run(userId, userId);
63
+ return serialize(storedEdition(userId));
64
+ }
65
+ export const publishEditionFields = {
66
+ itemIds: z.array(z.number().int().positive()).min(1).max(24),
67
+ expiresAt: z.string().datetime({ offset: true }).optional(),
68
+ durationHours: z.number().positive().max(168).optional(),
69
+ expectedRevision: z.number().int().min(0).optional(),
70
+ requestId: z.uuid().optional(),
71
+ title: z.string().trim().min(1).max(200).optional(),
72
+ summary: z.string().trim().min(1).max(2000).optional(),
73
+ overview: overviewSchema.optional(),
74
+ };
75
+ const publishSchema = z.object(publishEditionFields).strict().refine((input) => !(input.expiresAt && input.durationHours), "Use expiresAt or durationHours, not both");
76
+ /** Both automatic and MCP editions obey the app's hard exclusions. Read/saved articles
77
+ * may be selected deliberately by an agent, but foreign and muted items cannot be published. */
78
+ export function publishCuratedEdition(userId, input, now = new Date()) {
79
+ const parsed = publishSchema.safeParse(input);
80
+ if (!parsed.success)
81
+ throw new NativeError("invalid_input");
82
+ const { requestId, ...data } = parsed.data;
83
+ return nativeRequest(userId, requestId, "edition.publish", data, () => {
84
+ checkRevision(userId, data.expectedRevision, now);
85
+ if (new Set(data.itemIds).size !== data.itemIds.length)
86
+ throw new NativeError("duplicate_item_ids");
87
+ if (data.overview?.some((block) => !block.segments.some((segment) => segment.itemId !== undefined) ||
88
+ block.segments.some((segment) => segment.itemId !== undefined && !data.itemIds.includes(segment.itemId)))) {
89
+ throw new NativeError("invalid_overview_reference");
90
+ }
91
+ const prefs = getNativePreferences(userId);
92
+ for (const id of data.itemIds) {
93
+ if (!canAccessItem(userId, id))
94
+ throw new NativeError("item_not_found", 404);
95
+ const item = db.prepare(`SELECT s.folder_id, s.id IS NOT NULL AS subscribed,
96
+ EXISTS (SELECT 1 FROM user_muted_keywords m WHERE m.user_id = ? AND
97
+ (instr(lower(coalesce(i.title, '')), lower(m.keyword)) > 0 OR instr(lower(coalesce(i.content_snippet, '')), lower(m.keyword)) > 0)) AS muted
98
+ FROM items i LEFT JOIN subscriptions s ON s.feed_id = i.feed_id AND s.user_id = ? WHERE i.id = ?`).get(userId, userId, id);
99
+ if (item.muted || (item.subscribed && (item.folder_id === null ? !prefs.showUnfiled : prefs.hiddenFolderIds.includes(item.folder_id)))) {
100
+ throw new NativeError("item_excluded");
101
+ }
102
+ }
103
+ const expiry = data.expiresAt ? new Date(data.expiresAt) : new Date(now.getTime() + (data.durationHours ?? 24) * 3_600_000);
104
+ if (expiry.getTime() <= now.getTime() || expiry.getTime() > now.getTime() + 168 * 3_600_000)
105
+ throw new NativeError("invalid_expiry");
106
+ return writeEdition(userId, data.itemIds, "curated", expiry, now, data.title ?? null, data.summary ?? (data.overview?.map((block) => `${block.heading}: ${block.segments.map((segment) => segment.text).join("")}`).join("\n\n").slice(0, 2000).replace(/[\uD800-\uDBFF]$/, "") ?? null), data.overview ?? null);
107
+ }, now);
108
+ }
109
+ /** Compatibility entry point for existing internal callers. New agents supply a
110
+ * request ID and expected revision to publishCuratedEdition. */
111
+ export function publishEdition(userId, itemIds, expiresAt) {
112
+ return publishCuratedEdition(userId, { itemIds, ...(expiresAt ? { expiresAt } : {}) });
113
+ }
114
+ export function dismissEdition(userId, input = {}, now = new Date()) {
115
+ return nativeRequest(userId, input.requestId, "edition.dismiss", { expectedRevision: input.expectedRevision }, () => {
116
+ checkRevision(userId, input.expectedRevision, now);
117
+ const row = storedEdition(userId);
118
+ if (row && row.status !== "dismissed") {
119
+ db.prepare("UPDATE editions SET status = 'dismissed', revision = revision + 1, updated_at = ?, period = ? WHERE user_id = ?")
120
+ .run(now.toISOString(), editionPeriod(now, getNativePreferences(userId).timeZone).key, userId);
121
+ }
122
+ return getEditionState(userId, now);
123
+ }, now);
124
+ }
125
+ export function deleteEdition(userId) {
126
+ const existed = Boolean(getEdition(userId));
127
+ dismissEdition(userId);
128
+ return existed;
129
+ }
130
+ export function editionCandidates(userId, now = new Date(), limit = 200) {
131
+ const prefs = getNativePreferences(userId);
132
+ const previous = new Set(db.prepare("SELECT item_ids FROM edition_history WHERE user_id = ? AND created_at >= ?").all(userId, new Date(now.getTime() - 72 * 3_600_000).toISOString()).flatMap((row) => JSON.parse(row.item_ids)));
133
+ // Fetch bounded candidates per source, so a busy feed cannot hide the others.
134
+ const rows = db.prepare(`SELECT * FROM (
135
+ SELECT i.id, i.feed_id, s.folder_id, i.title, i.link, i.content_snippet, i.image_url,
136
+ coalesce(s.label, f.title) AS source_name, folders.name AS folder_name,
137
+ strftime('%Y-%m-%dT%H:%M:%fZ', coalesce(julianday(i.published_at), julianday(i.created_at))) AS published,
138
+ length(coalesce(i.full_content_html, i.content_html, i.content_snippet, '')) AS content_chars,
139
+ row_number() OVER (PARTITION BY i.feed_id ORDER BY coalesce(julianday(i.published_at), julianday(i.created_at)) DESC, i.id DESC) AS source_rank
140
+ FROM items i JOIN subscriptions s ON s.feed_id = i.feed_id AND s.user_id = ?
141
+ JOIN feeds f ON f.id = i.feed_id LEFT JOIN folders ON folders.id = s.folder_id
142
+ WHERE NOT EXISTS (SELECT 1 FROM item_reads r WHERE r.item_id = i.id AND r.user_id = ?)
143
+ AND NOT EXISTS (SELECT 1 FROM user_muted_keywords m WHERE m.user_id = ? AND
144
+ (instr(lower(coalesce(i.title, '')), lower(m.keyword)) > 0 OR instr(lower(coalesce(i.content_snippet, '')), lower(m.keyword)) > 0))
145
+ AND coalesce(julianday(i.published_at), julianday(i.created_at)) BETWEEN julianday(?) AND julianday(?)
146
+ ) WHERE source_rank <= 40 ORDER BY source_rank, published DESC, id DESC LIMIT 4000`)
147
+ .all(userId, userId, userId, new Date(now.getTime() - 7 * 86_400_000).toISOString(), now.toISOString());
148
+ const scored = rows.filter((row) => row.folder_id === null ? prefs.showUnfiled : !prefs.hiddenFolderIds.includes(row.folder_id))
149
+ .map((row) => ({
150
+ id: row.id, sourceName: row.source_name, folderName: row.folder_name, subscriptionId: row.feed_id, folderId: row.folder_id,
151
+ title: row.title, url: row.link, publishedAt: row.published, snippet: decodeEntities(row.content_snippet) ?? null, imageUrl: row.image_url,
152
+ readingMinutes: Math.max(1, Math.ceil(row.content_chars / 1400)), previouslySelected: previous.has(row.id),
153
+ score: Math.pow(0.5, Math.max(0, now.getTime() - Date.parse(row.published)) / (18 * 3_600_000)) *
154
+ (row.folder_id !== null && prefs.preferredFolderIds.includes(row.folder_id) ? 1.4 : 1),
155
+ }));
156
+ // Candidate requests expose a diversified shortlist rather than only the busiest source.
157
+ if (limit < scored.length) {
158
+ const sorted = selectEdition(scored, { ...prefs, editionSize: Math.min(limit, 24), readingMinutes: null });
159
+ const selected = new Set(sorted.map((item) => item.id));
160
+ return [...sorted, ...scored.filter((item) => !selected.has(item.id)).sort((a, b) => b.score - a.score || b.id - a.id)].slice(0, limit);
161
+ }
162
+ return scored.sort((a, b) => b.score - a.score || b.id - a.id);
163
+ }
164
+ export const generateEditionSchema = z.object({
165
+ requestId: z.uuid(),
166
+ expectedRevision: z.number().int().min(0).optional(),
167
+ force: z.boolean().default(false),
168
+ }).strict().refine((input) => !input.force || input.expectedRevision !== undefined, "force requires expectedRevision");
169
+ export function generateEdition(userId, input = {}, now = new Date()) {
170
+ return nativeRequest(userId, input.requestId, "edition.generate", { expectedRevision: input.expectedRevision, force: input.force ?? false }, () => {
171
+ checkRevision(userId, input.expectedRevision, now);
172
+ const prefs = getNativePreferences(userId);
173
+ const period = editionPeriod(now, prefs.timeZone).key;
174
+ const state = getEditionState(userId, now);
175
+ if (state.edition) {
176
+ if (state.edition.source === "curated") {
177
+ if (input.force)
178
+ throw new NativeError("curated_edition_active", 409, state);
179
+ return state.edition;
180
+ }
181
+ if (!input.force)
182
+ return state.edition;
183
+ }
184
+ const row = storedEdition(userId);
185
+ if (!input.force && row?.status === "dismissed" && row.period === period)
186
+ return null;
187
+ if (row?.status === "active" && !state.edition) {
188
+ db.prepare("UPDATE editions SET status = 'expired', revision = revision + 1, updated_at = ? WHERE user_id = ?").run(now.toISOString(), userId);
189
+ }
190
+ const chosen = selectEdition(editionCandidates(userId, now, 4000), prefs);
191
+ if (!chosen.length)
192
+ return state.edition;
193
+ if (state.edition && JSON.stringify(state.edition.itemIds) === JSON.stringify(chosen.map((item) => item.id)))
194
+ return state.edition;
195
+ return writeEdition(userId, chosen.map((item) => item.id), "automatic", nextEditionBoundary(now, prefs.timeZone), now);
196
+ }, now);
197
+ }
198
+ /** Only users who opted in by storing preferences or creating an edition are scheduled. */
199
+ export function maintainEditions(now = new Date()) {
200
+ const users = db.prepare("SELECT user_id FROM native_preferences UNION SELECT user_id FROM editions").all();
201
+ for (const user of users) {
202
+ if (!hasUserCapability(user.user_id, "editions"))
203
+ continue;
204
+ try {
205
+ generateEdition(user.user_id, {}, now);
206
+ }
207
+ catch (error) {
208
+ console.error("[edition] could not refresh an edition", error instanceof Error ? error.message : "unknown error");
209
+ }
210
+ }
211
+ // Receipts remain valid for at least 35 days, beyond the native offline window.
212
+ db.prepare("DELETE FROM native_requests WHERE created_at < ?").run(new Date(now.getTime() - 35 * 86_400_000).toISOString());
213
+ }
214
+ export function startEditionScheduler() {
215
+ maintainEditions();
216
+ cron.schedule("* * * * *", () => maintainEditions());
217
+ }
218
+ //# sourceMappingURL=editions.js.map
@@ -0,0 +1,66 @@
1
+ import { z } from "zod";
2
+ import { db } from "../db/index.js";
3
+ import { NativeError, nativeRequest } from "./requests.js";
4
+ export const folderId = z.number().int().positive();
5
+ const distinctIds = z.array(folderId).max(1000).refine((ids) => new Set(ids).size === ids.length, "IDs must be unique");
6
+ const timezone = z.string().max(100).refine((value) => {
7
+ try {
8
+ new Intl.DateTimeFormat("en", { timeZone: value });
9
+ return true;
10
+ }
11
+ catch {
12
+ return false;
13
+ }
14
+ }, "Use an IANA time zone");
15
+ export const preferenceFields = {
16
+ timeZone: timezone,
17
+ editionSize: z.number().int().min(1).max(24).nullable(),
18
+ readingMinutes: z.number().int().min(5).max(180).nullable(),
19
+ folderOrder: z.array(folderId.nullable()).max(1001).refine((ids) => new Set(ids).size === ids.length, "IDs must be unique"),
20
+ hiddenFolderIds: distinctIds,
21
+ preferredFolderIds: distinctIds,
22
+ showUnfiled: z.boolean(),
23
+ };
24
+ export const preferencePatchSchema = z.object(preferenceFields).partial().extend({
25
+ requestId: z.uuid(),
26
+ expectedRevision: z.number().int().min(0),
27
+ }).strict();
28
+ const defaults = {
29
+ timeZone: "UTC", editionSize: 24, editionSizeIsCustom: false, readingMinutes: null,
30
+ folderOrder: [], hiddenFolderIds: [], preferredFolderIds: [], showUnfiled: true,
31
+ };
32
+ export function getNativePreferences(userId) {
33
+ const row = db.prepare("SELECT revision, settings, updated_at FROM native_preferences WHERE user_id = ?").get(userId);
34
+ const stored = row ? JSON.parse(row.settings) : {};
35
+ // Legacy non-default values express intent. Legacy 24 is indistinguishable from
36
+ // an implicit default recorded by a folder-only write; treat it as the default.
37
+ return { ...defaults, ...stored, editionSizeIsCustom: stored.editionSizeIsCustom ?? (stored.editionSize != null && stored.editionSize !== 24), revision: row?.revision ?? 0, updatedAt: row?.updated_at ?? null };
38
+ }
39
+ export function patchNativePreferences(userId, input) {
40
+ const parsed = preferencePatchSchema.safeParse(input);
41
+ if (!parsed.success)
42
+ throw new NativeError("invalid_input");
43
+ const { requestId, expectedRevision, ...patch } = parsed.data;
44
+ return nativeRequest(userId, requestId, "preferences.patch", { expectedRevision, ...patch }, () => {
45
+ const current = getNativePreferences(userId);
46
+ if (current.revision !== expectedRevision)
47
+ throw new NativeError("revision_conflict", 409, current);
48
+ const ownedIds = new Set(db.prepare("SELECT id FROM folders WHERE user_id = ?").all(userId).map((f) => f.id));
49
+ for (const ids of [patch.folderOrder, patch.hiddenFolderIds, patch.preferredFolderIds]) {
50
+ if (ids?.some((id) => id !== null && !ownedIds.has(id)))
51
+ throw new NativeError("folder_not_found", 404);
52
+ }
53
+ const { revision, updatedAt: _updatedAt, ...settings } = current;
54
+ const next = { ...settings, ...patch, editionSize: patch.editionSize === null ? defaults.editionSize : patch.editionSize ?? current.editionSize,
55
+ editionSizeIsCustom: patch.editionSize === undefined ? current.editionSizeIsCustom : patch.editionSize !== null };
56
+ // The first write records ownership of defaults, allowing a one-time local migration.
57
+ if (revision > 0 && JSON.stringify(settings) === JSON.stringify(next))
58
+ return current;
59
+ const now = new Date().toISOString();
60
+ db.prepare(`INSERT INTO native_preferences (user_id, revision, settings, updated_at) VALUES (?, ?, ?, ?)
61
+ ON CONFLICT(user_id) DO UPDATE SET revision = excluded.revision, settings = excluded.settings, updated_at = excluded.updated_at`)
62
+ .run(userId, revision + 1, JSON.stringify(next), now);
63
+ return getNativePreferences(userId);
64
+ });
65
+ }
66
+ //# sourceMappingURL=preferences.js.map
@@ -0,0 +1,35 @@
1
+ import { createHash } from "node:crypto";
2
+ import { db } from "../db/index.js";
3
+ export class NativeError extends Error {
4
+ code;
5
+ status;
6
+ current;
7
+ constructor(code, status = 400, current) {
8
+ super(code);
9
+ this.code = code;
10
+ this.status = status;
11
+ this.current = current;
12
+ }
13
+ }
14
+ /** An operation and its receipt commit together. Replays return the original result,
15
+ * even after newer changes; reusing an ID with a different payload is rejected. */
16
+ export function nativeRequest(userId, requestId, operation, input, action, now = new Date()) {
17
+ return db.transaction(() => {
18
+ const fingerprint = createHash("sha256").update(JSON.stringify([operation, input])).digest("hex");
19
+ if (requestId) {
20
+ const receipt = db.prepare("SELECT fingerprint, response FROM native_requests WHERE user_id = ? AND request_id = ?").get(userId, requestId);
21
+ if (receipt) {
22
+ if (receipt.fingerprint !== fingerprint)
23
+ throw new NativeError("request_id_reused", 409);
24
+ return JSON.parse(receipt.response);
25
+ }
26
+ }
27
+ const result = action();
28
+ if (requestId) {
29
+ db.prepare("INSERT INTO native_requests (user_id, request_id, fingerprint, response, created_at) VALUES (?, ?, ?, ?, ?)")
30
+ .run(userId, requestId, fingerprint, JSON.stringify(result), now.toISOString());
31
+ }
32
+ return result;
33
+ })();
34
+ }
35
+ //# sourceMappingURL=requests.js.map
@@ -0,0 +1,6 @@
1
+ import { config } from "../config.js";
2
+ /** The address clients know this server by; OAuth tokens are bound to the /mcp resource below it. */
3
+ export const ISSUER = config.publicUrl.replace(/\/+$/, "");
4
+ export const MCP_RESOURCE = `${ISSUER}/mcp`;
5
+ export const RESOURCE_METADATA_URL = `${ISSUER}/.well-known/oauth-protected-resource/mcp`;
6
+ //# sourceMappingURL=constants.js.map
@@ -0,0 +1,8 @@
1
+ let activeHost = null;
2
+ export function setOAuthHost(host) {
3
+ activeHost = host;
4
+ }
5
+ export function getOAuthHost() {
6
+ return activeHost;
7
+ }
8
+ //# sourceMappingURL=host.js.map
@@ -0,0 +1,148 @@
1
+ const strings = {
2
+ en: {
3
+ passkey: "Use a passkey",
4
+ loginTitle: "Sign in to FeedKeeper",
5
+ loginIntro: "{client} wants to connect to your FeedKeeper account. Sign in to continue.",
6
+ email: "Email",
7
+ password: "Password",
8
+ signIn: "Sign in",
9
+ badLogin: "Email or password is wrong.",
10
+ consentTitle: "Allow access?",
11
+ consentIntro: "{client} wants to access your FeedKeeper account through MCP.",
12
+ returnsTo: "After you decide, you are sent back to {host}.",
13
+ readOnly: "Read only",
14
+ readOnlyHint: "Look up feeds and articles. Changes nothing.",
15
+ readWrite: "Read and write",
16
+ readWriteHint: "Also mark articles read, bookmark, and manage feeds and folders.",
17
+ allow: "Allow",
18
+ deny: "Deny",
19
+ signedInAs: "Signed in as {user}",
20
+ errorTitle: "This request cannot be completed",
21
+ unknownClient: "The app is not registered with this server.",
22
+ badRedirect: "The return address does not match the app's registration.",
23
+ noAccess: "Your account does not include MCP access.",
24
+ manage: "Manage your plan",
25
+ forbidden: "The request came from another site and was blocked.",
26
+ },
27
+ de: {
28
+ passkey: "Mit Passkey anmelden",
29
+ loginTitle: "Bei FeedKeeper anmelden",
30
+ loginIntro: "{client} möchte sich mit deinem FeedKeeper-Konto verbinden. Melde dich an, um fortzufahren.",
31
+ email: "E-Mail",
32
+ password: "Passwort",
33
+ signIn: "Anmelden",
34
+ badLogin: "E-Mail oder Passwort ist falsch.",
35
+ consentTitle: "Zugriff erlauben?",
36
+ consentIntro: "{client} möchte über MCP auf dein FeedKeeper-Konto zugreifen.",
37
+ returnsTo: "Nach deiner Entscheidung geht es zurück zu {host}.",
38
+ readOnly: "Nur lesen",
39
+ readOnlyHint: "Feeds und Artikel abrufen. Ändert nichts.",
40
+ readWrite: "Lesen und schreiben",
41
+ readWriteHint: "Zusätzlich Artikel als gelesen markieren, merken sowie Feeds und Ressorts verwalten.",
42
+ allow: "Erlauben",
43
+ deny: "Ablehnen",
44
+ signedInAs: "Angemeldet als {user}",
45
+ errorTitle: "Die Anfrage kann nicht abgeschlossen werden",
46
+ unknownClient: "Die App ist bei diesem Server nicht registriert.",
47
+ badRedirect: "Die Rücksprungadresse passt nicht zur Registrierung der App.",
48
+ noAccess: "Dein Konto enthält keinen MCP-Zugriff.",
49
+ manage: "Tarif verwalten",
50
+ forbidden: "Die Anfrage kam von einer anderen Website und wurde blockiert.",
51
+ },
52
+ ja: {
53
+ passkey: "パスキーでサインイン",
54
+ loginTitle: "FeedKeeperにサインイン",
55
+ loginIntro: "{client} がFeedKeeperアカウントへの接続を求めています。続けるにはサインインしてください。",
56
+ email: "メールアドレス",
57
+ password: "パスワード",
58
+ signIn: "サインイン",
59
+ badLogin: "メールアドレスまたはパスワードが正しくありません。",
60
+ consentTitle: "アクセスを許可しますか?",
61
+ consentIntro: "{client} がMCP経由でFeedKeeperアカウントにアクセスしようとしています。",
62
+ returnsTo: "選択後は {host} に戻ります。",
63
+ readOnly: "読み取りのみ",
64
+ readOnlyHint: "フィードと記事を参照します。変更はしません。",
65
+ readWrite: "読み取りと書き込み",
66
+ readWriteHint: "記事の既読化、ブックマーク、フィードとフォルダの管理も行えます。",
67
+ allow: "許可",
68
+ deny: "拒否",
69
+ signedInAs: "{user} としてサインイン中",
70
+ errorTitle: "このリクエストは完了できません",
71
+ unknownClient: "このアプリはこのサーバーに登録されていません。",
72
+ badRedirect: "戻り先のアドレスがアプリの登録と一致しません。",
73
+ noAccess: "お使いのアカウントにはMCPアクセスが含まれていません。",
74
+ manage: "プランを管理",
75
+ forbidden: "別のサイトからのリクエストのためブロックしました。",
76
+ },
77
+ };
78
+ function languageOf(req) {
79
+ const header = String(req.headers["accept-language"] ?? "").toLowerCase();
80
+ for (const part of header.split(",")) {
81
+ const tag = part.trim().slice(0, 2);
82
+ if (tag === "de" || tag === "ja" || tag === "en")
83
+ return tag;
84
+ }
85
+ return "en";
86
+ }
87
+ export function escapeHtml(value) {
88
+ return value.replace(/[&<>"']/g, (char) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[char]);
89
+ }
90
+ function text(lang, key, values = {}) {
91
+ const template = strings[lang][key];
92
+ return escapeHtml(template.replace(/\{(\w+)\}/g, (_match, name) => values[name] ?? ""));
93
+ }
94
+ function layout(lang, title, body) {
95
+ return `<!doctype html>
96
+ <html lang="${lang}"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
97
+ <meta name="robots" content="noindex"><title>${title}</title>
98
+ <style>
99
+ :root{--bg:#f6f7f8;--card:#fff;--text:#1d2329;--muted:#5c6770;--line:#d9dee2;--accent:#3d5a73;--accent-text:#fff}
100
+ @media (prefers-color-scheme:dark){:root{--bg:#14181c;--card:#1e242a;--text:#e8ecef;--muted:#9aa6b0;--line:#323a42;--accent:#8fb0cc;--accent-text:#10161b}}
101
+ *{box-sizing:border-box}body{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;padding:16px;background:var(--bg);color:var(--text);font:16px/1.45 -apple-system,BlinkMacSystemFont,"Segoe UI",Inter,sans-serif}
102
+ main{width:100%;max-width:420px;background:var(--card);border:1px solid var(--line);border-radius:16px;padding:28px}
103
+ h1{font-size:1.35rem;margin:0 0 8px}p{margin:0 0 16px}.muted{color:var(--muted);font-size:.9rem}
104
+ label.field{display:block;font-weight:600;font-size:.9rem;margin-bottom:12px}
105
+ input[type=email],input[type=password]{display:block;width:100%;margin-top:4px;padding:10px 12px;border:1px solid var(--line);border-radius:10px;background:var(--bg);color:var(--text);font:inherit}
106
+ .choice{display:flex;gap:10px;align-items:flex-start;padding:12px;border:1px solid var(--line);border-radius:12px;margin-bottom:10px;cursor:pointer}
107
+ .choice input{margin-top:4px}.choice strong{display:block}.choice span{color:var(--muted);font-size:.88rem}
108
+ .row{display:flex;gap:10px;margin-top:20px}button{flex:1;padding:11px 14px;border-radius:10px;border:1px solid var(--line);background:transparent;color:var(--text);font:inherit;font-weight:600;cursor:pointer}
109
+ button.primary{background:var(--accent);border-color:var(--accent);color:var(--accent-text)}.error{color:#b3261e}a{color:var(--accent)}
110
+ </style></head><body><main>${body}</main></body></html>`;
111
+ }
112
+ export function hidden(name, value) {
113
+ return `<input type="hidden" name="${escapeHtml(name)}" value="${escapeHtml(value)}">`;
114
+ }
115
+ export function renderLogin(req, input) {
116
+ const lang = languageOf(req);
117
+ return layout(lang, text(lang, "loginTitle"), `
118
+ <h1>${text(lang, "loginTitle")}</h1>
119
+ <p>${text(lang, "loginIntro", { client: input.clientName })}</p>
120
+ ${input.failed ? `<p class="error" role="alert">${text(lang, "badLogin")}</p>` : ""}
121
+ <form method="post" action="/oauth/authorize/login">
122
+ ${hidden("return_to", input.returnTo)}
123
+ <label class="field">${text(lang, "email")}<input type="email" name="email" required autofocus autocomplete="username"></label>
124
+ <label class="field">${text(lang, "password")}<input type="password" name="password" required autocomplete="current-password"></label>
125
+ <div class="row"><button class="primary" type="submit">${text(lang, "signIn")}</button></div>
126
+ </form>
127
+ <p><a href="/login?next=${encodeURIComponent(input.returnTo)}">${text(lang, "passkey")}</a></p>`);
128
+ }
129
+ export function renderConsent(req, input) {
130
+ const lang = languageOf(req);
131
+ const radio = (value, label, hint) => `<label class="choice"><input type="radio" name="grant_scope" value="${value}"${input.preselect === value ? " checked" : ""}><div><strong>${text(lang, label)}</strong><span>${text(lang, hint)}</span></div></label>`;
132
+ return layout(lang, text(lang, "consentTitle"), `
133
+ <h1>${text(lang, "consentTitle")}</h1>
134
+ <p>${text(lang, "consentIntro", { client: input.clientName })}</p>
135
+ <form method="post" action="/oauth/authorize/decision">
136
+ ${Object.entries(input.fields).map(([name, value]) => hidden(name, value)).join("\n")}
137
+ ${radio("read", "readOnly", "readOnlyHint")}
138
+ ${input.allowWrite ? radio("write", "readWrite", "readWriteHint") : ""}
139
+ <p class="muted">${text(lang, "returnsTo", { host: input.redirectHost })}<br>${text(lang, "signedInAs", { user: input.userName })}</p>
140
+ <div class="row"><button type="submit" name="decision" value="deny">${text(lang, "deny")}</button><button class="primary" type="submit" name="decision" value="allow">${text(lang, "allow")}</button></div>
141
+ </form>`);
142
+ }
143
+ export function renderError(req, key, manageUrl) {
144
+ const lang = languageOf(req);
145
+ const link = manageUrl && /^https?:\/\//.test(manageUrl) ? `<p><a href="${escapeHtml(manageUrl)}">${text(lang, "manage")}</a></p>` : "";
146
+ return layout(lang, text(lang, "errorTitle"), `<h1>${text(lang, "errorTitle")}</h1><p>${text(lang, key)}</p>${link}`);
147
+ }
148
+ //# sourceMappingURL=pages.js.map