gloomberb 0.8.1 → 0.9.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 (196) hide show
  1. package/README.md +106 -54
  2. package/package.json +14 -13
  3. package/patches/{@opentui%2Fcore@0.1.90.patch → @opentui%2Fcore@0.3.2.patch} +175 -90
  4. package/src/api-client/chat.ts +8 -0
  5. package/src/api-client/index.test.ts +30 -0
  6. package/src/api-client/index.ts +4 -0
  7. package/src/api-client/normalizers.ts +1 -0
  8. package/src/api-client/types.ts +1 -0
  9. package/src/app/global-shortcuts.test.tsx +54 -4
  10. package/src/app/global-shortcuts.ts +11 -5
  11. package/src/capabilities/factories.ts +4 -0
  12. package/src/components/chart/comparison/data.test.ts +54 -0
  13. package/src/components/chart/comparison/data.ts +47 -24
  14. package/src/components/chart/core/projection.ts +51 -27
  15. package/src/components/command-bar/action-runtime.ts +1 -0
  16. package/src/components/command-bar/layout-items/current-layout.test.ts +115 -0
  17. package/src/components/command-bar/layout-items/current-layout.ts +33 -0
  18. package/src/components/command-bar/list/view.tsx +3 -1
  19. package/src/components/command-bar/pane-templates/items.ts +4 -1
  20. package/src/components/command-bar/panel/body.tsx +1 -0
  21. package/src/components/command-bar/routes/root/results.ts +0 -30
  22. package/src/components/command-bar/routes/root/runtime.ts +2 -20
  23. package/src/components/command-bar/routes/ticker-search/actions.ts +42 -4
  24. package/src/components/command-bar/routes/ticker-search/route.ts +4 -1
  25. package/src/components/command-bar/routing/launch-request.ts +14 -7
  26. package/src/components/command-bar/routing/list-state.ts +1 -3
  27. package/src/components/command-bar/surface/index.test.tsx +76 -4
  28. package/src/components/command-bar/surface/index.tsx +0 -2
  29. package/src/components/command-bar/surface/pane.test.tsx +1 -0
  30. package/src/components/command-bar/surface/test-harness.tsx +1 -0
  31. package/src/components/command-bar/view-model.ts +3 -3
  32. package/src/components/data-table/stack-view.test.tsx +14 -11
  33. package/src/components/data-table/stack-view.tsx +1 -2
  34. package/src/components/data-table/view.test.tsx +24 -18
  35. package/src/components/data-table/view.tsx +313 -83
  36. package/src/components/feed-data-table/stack-view.tsx +6 -8
  37. package/src/components/layout/pane/footer/index.test.tsx +6 -0
  38. package/src/components/layout/pane/footer/index.tsx +14 -2
  39. package/src/components/layout/pane/title.ts +5 -1
  40. package/src/components/layout/shell/index.test.tsx +120 -2
  41. package/src/components/layout/shell/index.tsx +2 -0
  42. package/src/components/layout/shell/menu.ts +1 -7
  43. package/src/components/layout/shell/pane/actions.ts +8 -0
  44. package/src/components/layout/shell/pane/management-shortcuts.ts +12 -2
  45. package/src/components/layout/shell/shortcuts.ts +14 -6
  46. package/src/components/layout/shell/window-mode/index.ts +8 -3
  47. package/src/components/layout/window-edit/presentation.ts +4 -4
  48. package/src/components/onboarding/onboarding-steps.tsx +3 -2
  49. package/src/components/pane-settings-dialog/field-dialogs.tsx +0 -2
  50. package/src/components/pane-settings-dialog/tui.tsx +1 -1
  51. package/src/components/ticker/list-table-view.tsx +17 -25
  52. package/src/components/ui/choice-dialog.tsx +12 -2
  53. package/src/components/ui/multi-select/dialog.tsx +17 -5
  54. package/src/components/ui/remote-image.tsx +1 -14
  55. package/src/components/ui/shortcut-hint.tsx +14 -2
  56. package/src/components/ui/table-layout.test.ts +2 -2
  57. package/src/components/ui/table-layout.ts +8 -2
  58. package/src/components/ui/tabs.tsx +115 -86
  59. package/src/components/ui/ui.test.tsx +83 -4
  60. package/src/core/state/app/types.ts +12 -4
  61. package/src/market-data/coordinator/auxiliary.ts +29 -1
  62. package/src/market-data/coordinator/index.ts +17 -1
  63. package/src/market-data/hooks.tsx +17 -1
  64. package/src/market-data/selectors.ts +4 -0
  65. package/src/plugins/builtin/account-management/form-components.tsx +0 -4
  66. package/src/plugins/builtin/account-management/model.test.ts +54 -0
  67. package/src/plugins/builtin/account-management/model.ts +13 -3
  68. package/src/plugins/builtin/account-management/pane.tsx +5 -11
  69. package/src/plugins/builtin/account-management/password-dialog.tsx +1 -1
  70. package/src/plugins/builtin/alerts/pane.tsx +5 -3
  71. package/src/plugins/builtin/analytics/index.tsx +0 -3
  72. package/src/plugins/builtin/analytics/view.tsx +6 -5
  73. package/src/plugins/builtin/broker-manager/index.tsx +6 -6
  74. package/src/plugins/builtin/buildout/pane/index.tsx +6 -6
  75. package/src/plugins/builtin/changelog/index.tsx +40 -27
  76. package/src/plugins/builtin/changelog/model.test.ts +77 -0
  77. package/src/plugins/builtin/changelog/model.ts +98 -0
  78. package/src/plugins/builtin/chat/channel-labels.ts +53 -0
  79. package/src/plugins/builtin/chat/channels.test.ts +28 -0
  80. package/src/plugins/builtin/chat/channels.ts +36 -19
  81. package/src/plugins/builtin/chat/chat.test.tsx +264 -5
  82. package/src/plugins/builtin/chat/content/channel-navigation.ts +50 -11
  83. package/src/plugins/builtin/chat/content/composer-runtime.test.tsx +144 -0
  84. package/src/plugins/builtin/chat/content/composer-runtime.ts +151 -20
  85. package/src/plugins/builtin/chat/content/composer.tsx +23 -2
  86. package/src/plugins/builtin/chat/content/index.tsx +66 -4
  87. package/src/plugins/builtin/chat/content/layout-metrics.ts +3 -1
  88. package/src/plugins/builtin/chat/content/shortcuts.ts +27 -1
  89. package/src/plugins/builtin/chat/content/transcript.tsx +8 -0
  90. package/src/plugins/builtin/chat/content/types.ts +1 -0
  91. package/src/plugins/builtin/chat/controller/index.ts +41 -0
  92. package/src/plugins/builtin/chat/controller/messages.ts +18 -0
  93. package/src/plugins/builtin/chat/controller/notifications.ts +10 -4
  94. package/src/plugins/builtin/chat/controller/utils.ts +6 -3
  95. package/src/plugins/builtin/chat/controller/view.ts +1 -1
  96. package/src/plugins/builtin/chat/controller.test.ts +189 -0
  97. package/src/plugins/builtin/chat/edit-window.ts +24 -0
  98. package/src/plugins/builtin/chat/layout.ts +94 -31
  99. package/src/plugins/builtin/chat/message/action-chip.tsx +1 -0
  100. package/src/plugins/builtin/chat/message/desktop.tsx +53 -20
  101. package/src/plugins/builtin/chat/message/inline-tokens.tsx +16 -10
  102. package/src/plugins/builtin/chat/message/render-state.ts +5 -1
  103. package/src/plugins/builtin/chat/message/terminal.tsx +49 -20
  104. package/src/plugins/builtin/chat/message/types.ts +2 -0
  105. package/src/plugins/builtin/chat/pane.tsx +35 -7
  106. package/src/plugins/builtin/chat/sidebar.test.tsx +72 -0
  107. package/src/plugins/builtin/chat/status-widget.tsx +29 -11
  108. package/src/plugins/builtin/chat/test-harness.tsx +2 -0
  109. package/src/plugins/builtin/cloud/plugin.tsx +3 -0
  110. package/src/plugins/builtin/cloud-tweets/table.tsx +7 -11
  111. package/src/plugins/builtin/congress-trades/pane.tsx +14 -22
  112. package/src/plugins/builtin/earnings/index.tsx +8 -7
  113. package/src/plugins/builtin/econ/index.tsx +6 -8
  114. package/src/plugins/builtin/help/components.tsx +1 -1
  115. package/src/plugins/builtin/help/index.test.tsx +105 -30
  116. package/src/plugins/builtin/help/index.tsx +326 -164
  117. package/src/plugins/builtin/help/shortcut-model.ts +5 -6
  118. package/src/plugins/builtin/holders/pane.tsx +7 -6
  119. package/src/plugins/builtin/market-movers/index.tsx +6 -3
  120. package/src/plugins/builtin/news/wire/news/table.tsx +7 -6
  121. package/src/plugins/builtin/options/view.tsx +16 -8
  122. package/src/plugins/builtin/portfolio-list/column-values.ts +304 -1
  123. package/src/plugins/builtin/portfolio-list/metrics.test.ts +93 -0
  124. package/src/plugins/builtin/portfolio-list/pane/data.ts +7 -0
  125. package/src/plugins/builtin/portfolio-list/pane/index.tsx +25 -2
  126. package/src/plugins/builtin/portfolio-list/pane/supplemental.ts +281 -0
  127. package/src/plugins/builtin/portfolio-list/settings.ts +27 -0
  128. package/src/plugins/builtin/portfolio-list/table.tsx +4 -2
  129. package/src/plugins/builtin/portfolio-list/use-throttled-cursor-symbol.ts +10 -68
  130. package/src/plugins/builtin/research/analyst-pane.tsx +1 -2
  131. package/src/plugins/builtin/research/corporate-actions-pane.tsx +358 -7
  132. package/src/plugins/builtin/research/index.test.ts +30 -1
  133. package/src/plugins/builtin/research/relative-valuation-pane.tsx +6 -8
  134. package/src/plugins/builtin/sec/filing-documents.ts +66 -0
  135. package/src/plugins/builtin/sec/index.tsx +132 -20
  136. package/src/plugins/builtin/sectors/index.tsx +13 -23
  137. package/src/plugins/builtin/shared/ticker-request.ts +1 -0
  138. package/src/plugins/builtin/substack/api/auth.ts +185 -0
  139. package/src/plugins/builtin/substack/api/cache.ts +78 -0
  140. package/src/plugins/builtin/substack/api/loaders.ts +146 -0
  141. package/src/plugins/builtin/substack/api/store.ts +186 -0
  142. package/src/plugins/builtin/substack/api/types.ts +40 -0
  143. package/src/plugins/builtin/substack/api.test.ts +298 -0
  144. package/src/plugins/builtin/substack/article-detail.tsx +359 -0
  145. package/src/plugins/builtin/substack/article-stack.tsx +129 -0
  146. package/src/plugins/builtin/substack/content.ts +478 -0
  147. package/src/plugins/builtin/substack/feed-tabs.tsx +41 -0
  148. package/src/plugins/builtin/substack/index.tsx +49 -0
  149. package/src/plugins/builtin/substack/login-view.tsx +126 -0
  150. package/src/plugins/builtin/substack/model.test.ts +155 -0
  151. package/src/plugins/builtin/substack/normalize.ts +343 -0
  152. package/src/plugins/builtin/substack/pane-footer.ts +71 -0
  153. package/src/plugins/builtin/substack/pane-state.ts +170 -0
  154. package/src/plugins/builtin/substack/pane.tsx +500 -0
  155. package/src/plugins/builtin/substack/table.ts +111 -0
  156. package/src/plugins/builtin/substack/types.ts +71 -0
  157. package/src/plugins/builtin/substack/utils.ts +131 -0
  158. package/src/plugins/builtin/thirteenf/pane.tsx +26 -25
  159. package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph/content.tsx +5 -4
  160. package/src/plugins/builtin/ticker-detail/data-panes/historical-prices.tsx +5 -4
  161. package/src/plugins/builtin/ticker-detail/data-panes/provider-search.tsx +6 -5
  162. package/src/plugins/builtin/ticker-detail/financials/tab.tsx +10 -8
  163. package/src/plugins/builtin/ticker-detail/index.test.tsx +16 -2
  164. package/src/plugins/builtin/ticker-detail/index.tsx +1 -0
  165. package/src/plugins/builtin/ticker-detail/pane.tsx +13 -1
  166. package/src/plugins/builtin/world-indices/index.tsx +9 -6
  167. package/src/plugins/catalog-ui.ts +2 -0
  168. package/src/plugins/pane-manager/layout-state.ts +11 -0
  169. package/src/plugins/pane-manager.ts +1 -1
  170. package/src/plugins/prediction-markets/pane.tsx +10 -9
  171. package/src/plugins/registry/index.ts +3 -0
  172. package/src/plugins/runtime/context.tsx +1 -0
  173. package/src/plugins/runtime/index.tsx +4 -0
  174. package/src/react/use-throttled-commit-value.ts +116 -0
  175. package/src/renderers/electrobun/bun/desktop/http-fetch.test.ts +42 -0
  176. package/src/renderers/electrobun/bun/desktop/http-fetch.ts +5 -0
  177. package/src/renderers/electrobun/view/cli-pane-shot-entry.tsx +1 -0
  178. package/src/renderers/electrobun/view/data-table/index.tsx +3 -2
  179. package/src/renderers/electrobun/view/data-table/row.tsx +2 -2
  180. package/src/renderers/electrobun/view/host/input.tsx +23 -2
  181. package/src/renderers/electrobun/view/http-fetch.ts +10 -3
  182. package/src/renderers/electrobun/view/remote/asset-data-client.ts +1 -0
  183. package/src/sources/gloomberb-cloud/index.ts +4 -0
  184. package/src/sources/provider-router/cache.ts +1 -0
  185. package/src/sources/provider-router/document-routes.ts +45 -0
  186. package/src/sources/provider-router/index.ts +4 -0
  187. package/src/sources/sec-edgar/content.ts +9 -3
  188. package/src/sources/sec-edgar.test.ts +108 -1
  189. package/src/sources/sec-edgar.ts +105 -1
  190. package/src/sources/yahoo-finance.ts +4 -0
  191. package/src/state/app/context/index.test.tsx +16 -0
  192. package/src/test-support/plugin-runtime.ts +1 -0
  193. package/src/tickers/search/index.ts +1 -0
  194. package/src/types/data-provider.ts +11 -0
  195. package/src/ui/host.tsx +1 -0
  196. package/src/version.ts +1 -1
@@ -0,0 +1,146 @@
1
+ import {
2
+ normalizeFeedItems,
3
+ normalizePostDetail,
4
+ normalizeSubscriptions,
5
+ sortSubscriptionsByLatest,
6
+ } from "../normalize";
7
+ import type {
8
+ SubstackArticleSummary,
9
+ SubstackPublication,
10
+ } from "../types";
11
+ import { SubstackAuthError, type SubstackCachedData, type SubstackHomeData, type SubstackPublicationFeedPage } from "./types";
12
+ import { publicationCacheKey } from "./cache";
13
+ import {
14
+ fetchJsonAuthenticated,
15
+ loadCachedResource,
16
+ requireAuth,
17
+ SUBSTACK_ORIGIN,
18
+ } from "./store";
19
+
20
+ const READER_FEED_TARGET_ITEMS = 50;
21
+ const READER_FEED_MAX_PAGES = 12;
22
+ const PUBLICATION_ARCHIVE_PAGE_LIMIT = 12;
23
+
24
+ export async function loadSubstackHome(force = false): Promise<SubstackHomeData> {
25
+ const auth = requireAuth();
26
+ const [subscriptionsEntry, feedEntry] = await Promise.all([
27
+ loadCachedResource("subscriptions", "me", force, async () => {
28
+ const payload = await fetchJsonAuthenticated<unknown>(`${SUBSTACK_ORIGIN}/api/v1/subscriptions/page_v2`, auth);
29
+ return normalizeSubscriptions(payload);
30
+ }),
31
+ loadCachedResource("feed", "subscribed", force, async () => {
32
+ return fetchReaderFeedItems(auth, "subscribed");
33
+ }),
34
+ ]);
35
+ return {
36
+ subscriptions: sortSubscriptionsByLatest(subscriptionsEntry.data, feedEntry.data),
37
+ feed: feedEntry.data,
38
+ fetchedAt: Math.max(subscriptionsEntry.fetchedAt, feedEntry.fetchedAt),
39
+ stale: subscriptionsEntry.stale || feedEntry.stale,
40
+ };
41
+ }
42
+
43
+ function payloadCursor(payload: unknown): string | null {
44
+ const record = payload && typeof payload === "object" && !Array.isArray(payload)
45
+ ? payload as Record<string, unknown>
46
+ : null;
47
+ const cursor = record?.nextCursor ?? record?.next_cursor;
48
+ return typeof cursor === "string" && cursor.trim() ? cursor : null;
49
+ }
50
+
51
+ async function fetchReaderFeedItems(auth: ReturnType<typeof requireAuth>, tab: string): Promise<SubstackArticleSummary[]> {
52
+ const collected = new Map<string, SubstackArticleSummary>();
53
+ let cursor: string | null = null;
54
+ for (let page = 0; page < READER_FEED_MAX_PAGES && collected.size < READER_FEED_TARGET_ITEMS; page += 1) {
55
+ const url = new URL(`${SUBSTACK_ORIGIN}/api/v1/reader/feed`);
56
+ url.searchParams.set("tab", tab);
57
+ if (cursor) url.searchParams.set("cursor", cursor);
58
+ const payload = await fetchJsonAuthenticated<unknown>(url.toString(), auth);
59
+ for (const article of normalizeFeedItems(payload)) {
60
+ collected.set(article.id, article);
61
+ if (collected.size >= READER_FEED_TARGET_ITEMS) break;
62
+ }
63
+ const nextCursor = payloadCursor(payload);
64
+ if (!nextCursor || nextCursor === cursor) break;
65
+ cursor = nextCursor;
66
+ }
67
+ return [...collected.values()];
68
+ }
69
+
70
+ export async function loadSubstackPublicationFeed(
71
+ publication: SubstackPublication,
72
+ force = false,
73
+ offset = 0,
74
+ ): Promise<SubstackCachedData<SubstackPublicationFeedPage>> {
75
+ const auth = requireAuth();
76
+ const baseUrl = publication.baseUrl;
77
+ if (!baseUrl) {
78
+ return {
79
+ data: { items: [], nextOffset: null, hasMore: false },
80
+ fetchedAt: Date.now(),
81
+ stale: false,
82
+ };
83
+ }
84
+ return loadCachedResource("publication", publicationCacheKey(publication, offset), force, async () => {
85
+ const url = new URL("/api/v1/archive", baseUrl);
86
+ url.searchParams.set("sort", "new");
87
+ url.searchParams.set("limit", String(PUBLICATION_ARCHIVE_PAGE_LIMIT));
88
+ if (offset > 0) url.searchParams.set("offset", String(offset));
89
+ const payload = await fetchJsonAuthenticated<unknown>(url.toString(), auth);
90
+ const items = normalizeFeedItems(payload, publication);
91
+ const hasMore = items.length >= PUBLICATION_ARCHIVE_PAGE_LIMIT;
92
+ return {
93
+ items,
94
+ nextOffset: hasMore ? offset + PUBLICATION_ARCHIVE_PAGE_LIMIT : null,
95
+ hasMore,
96
+ };
97
+ });
98
+ }
99
+
100
+ function detailCandidateUrls(article: SubstackArticleSummary): string[] {
101
+ const urls: string[] = [];
102
+ if (/^\d+$/.test(article.id)) {
103
+ urls.push(new URL(`/api/v1/posts/by-id/${article.id}`, SUBSTACK_ORIGIN).toString());
104
+ }
105
+ const baseUrl = article.publicationBaseUrl
106
+ ?? (article.url ? new URL(article.url).origin : null);
107
+ if (baseUrl) {
108
+ if (/^\d+$/.test(article.id)) {
109
+ urls.push(new URL(`/api/v1/posts/by-id/${article.id}`, baseUrl).toString());
110
+ }
111
+ if (article.slug) {
112
+ urls.push(new URL(`/api/v1/posts/${article.slug}`, baseUrl).toString());
113
+ }
114
+ if (article.url) {
115
+ try {
116
+ const pathSlug = new URL(article.url).pathname.match(/\/p\/([^/?#]+)/)?.[1];
117
+ if (pathSlug) urls.push(new URL(`/api/v1/posts/${decodeURIComponent(pathSlug)}`, baseUrl).toString());
118
+ } catch {
119
+ // Ignore malformed article URLs; the direct post id/slug paths above still apply.
120
+ }
121
+ }
122
+ }
123
+ return [...new Set(urls)];
124
+ }
125
+
126
+ export async function loadSubstackArticleDetail(
127
+ article: SubstackArticleSummary,
128
+ force = false,
129
+ ): Promise<SubstackCachedData<ReturnType<typeof normalizePostDetail>>> {
130
+ const auth = requireAuth();
131
+ return loadCachedResource("post", article.id, force, async () => {
132
+ const urls = detailCandidateUrls(article);
133
+ const errors: string[] = [];
134
+ for (const url of urls) {
135
+ try {
136
+ const payload = await fetchJsonAuthenticated<unknown>(url, auth);
137
+ return normalizePostDetail(payload, article);
138
+ } catch (error) {
139
+ if (error instanceof SubstackAuthError) throw error;
140
+ errors.push(error instanceof Error ? error.message : String(error));
141
+ }
142
+ }
143
+ if (article.bodyHtml || urls.length === 0) return normalizePostDetail(article, article);
144
+ throw new Error(errors[0] ?? "Substack post content unavailable");
145
+ });
146
+ }
@@ -0,0 +1,186 @@
1
+ import type { PluginPersistence } from "../../../../types/plugin";
2
+ import type { CachePolicy, PersistedResourceValue } from "../../../../types/persistence";
3
+ import { createThrottledFetch, type ThrottledFetchTransport } from "../../../../utils/throttled-fetch";
4
+ import { normalizedHttpUrl } from "../../../../utils/url";
5
+ import { SubstackAuthError, type SubstackAuthState, type SubstackCachedData } from "./types";
6
+
7
+ export const SUBSTACK_ORIGIN = "https://substack.com";
8
+
9
+ const AUTH_STATE_KEY = "auth";
10
+ const AUTH_SCHEMA_VERSION = 1;
11
+ export const CACHE_SOURCE = "substack";
12
+ export const CACHE_SCHEMA_VERSION = 3;
13
+ const DEFAULT_HEADERS = {
14
+ accept: "application/json,text/plain,*/*",
15
+ "accept-language": "en-US,en;q=0.9",
16
+ "user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36",
17
+ };
18
+ const CACHE_POLICIES = {
19
+ subscriptions: { staleMs: 30 * 60 * 1000, expireMs: 14 * 24 * 60 * 60 * 1000 },
20
+ feed: { staleMs: 5 * 60 * 1000, expireMs: 3 * 24 * 60 * 60 * 1000 },
21
+ publication: { staleMs: 10 * 60 * 1000, expireMs: 7 * 24 * 60 * 60 * 1000 },
22
+ post: { staleMs: 30 * 24 * 60 * 60 * 1000, expireMs: 365 * 24 * 60 * 60 * 1000 },
23
+ } satisfies Record<string, CachePolicy>;
24
+
25
+ let substackPersistence: PluginPersistence | null = null;
26
+ let substackClient = createThrottledFetch({
27
+ requestsPerMinute: 60,
28
+ maxRetries: 1,
29
+ timeoutMs: 15_000,
30
+ defaultHeaders: DEFAULT_HEADERS,
31
+ dedupeGetRequests: false,
32
+ });
33
+ const activeFetches = new Map<string, Promise<SubstackCachedData<unknown>>>();
34
+
35
+ export function attachSubstackPersistence(persistence: PluginPersistence): void {
36
+ substackPersistence = persistence;
37
+ }
38
+
39
+ export function resetSubstackPersistence(): void {
40
+ substackPersistence = null;
41
+ activeFetches.clear();
42
+ }
43
+
44
+ export function setSubstackFetchTransportForTests(transport: ThrottledFetchTransport | null): void {
45
+ substackClient = createThrottledFetch({
46
+ requestsPerMinute: 10_000,
47
+ maxRetries: 0,
48
+ timeoutMs: 15_000,
49
+ defaultHeaders: DEFAULT_HEADERS,
50
+ dedupeGetRequests: false,
51
+ transport: transport ?? undefined,
52
+ });
53
+ activeFetches.clear();
54
+ }
55
+
56
+ function isAuthState(value: unknown): value is SubstackAuthState {
57
+ const record = value as Partial<SubstackAuthState> | null;
58
+ return !!record
59
+ && typeof record === "object"
60
+ && typeof record.email === "string"
61
+ && typeof record.sid === "string"
62
+ && record.sid.length > 0
63
+ && typeof record.loggedInAt === "number";
64
+ }
65
+
66
+ export function getStoredSubstackAuth(): SubstackAuthState | null {
67
+ const auth = substackPersistence?.getState<unknown>(AUTH_STATE_KEY, { schemaVersion: AUTH_SCHEMA_VERSION });
68
+ return isAuthState(auth) ? auth : null;
69
+ }
70
+
71
+ export function storeSubstackAuth(auth: SubstackAuthState): void {
72
+ substackPersistence?.setState(AUTH_STATE_KEY, auth, { schemaVersion: AUTH_SCHEMA_VERSION });
73
+ }
74
+
75
+ export function clearSubstackAuth(): void {
76
+ substackPersistence?.deleteState(AUTH_STATE_KEY);
77
+ }
78
+
79
+ export function requireAuth(): SubstackAuthState {
80
+ const auth = getStoredSubstackAuth();
81
+ if (!auth) throw new SubstackAuthError();
82
+ return auth;
83
+ }
84
+
85
+ function authHeaders(auth: SubstackAuthState): Record<string, string> {
86
+ return {
87
+ cookie: `substack.sid=${auth.sid}; substack.lli=${auth.lli?.trim() || "1"}`,
88
+ };
89
+ }
90
+
91
+ export async function parseErrorText(response: Response): Promise<string> {
92
+ try {
93
+ const text = await response.text();
94
+ return text.replace(/\s+/g, " ").trim().slice(0, 240);
95
+ } catch {
96
+ return "";
97
+ }
98
+ }
99
+
100
+ export async function substackFetch(url: string, init?: RequestInit): Promise<Response> {
101
+ return substackClient.fetch(url, init);
102
+ }
103
+
104
+ export async function fetchJsonAuthenticated<T = unknown>(url: string, auth: SubstackAuthState): Promise<T> {
105
+ const response = await substackClient.fetch(url, {
106
+ headers: authHeaders(auth),
107
+ });
108
+ if (response.status === 401 || response.status === 403) {
109
+ clearSubstackAuth();
110
+ throw new SubstackAuthError("Substack session expired");
111
+ }
112
+ if (!response.ok) {
113
+ const detail = await parseErrorText(response);
114
+ throw new Error(`Substack HTTP ${response.status}${detail ? `: ${detail}` : ""}`);
115
+ }
116
+ return response.json() as Promise<T>;
117
+ }
118
+
119
+ export function readResource<T>(kind: string, key: string, allowExpired = false): PersistedResourceValue<T> | null {
120
+ return substackPersistence?.getResource<T>(kind, key, {
121
+ sourceKey: CACHE_SOURCE,
122
+ schemaVersion: CACHE_SCHEMA_VERSION,
123
+ allowExpired,
124
+ }) ?? null;
125
+ }
126
+
127
+ function writeResource<T>(kind: string, key: string, value: T, cachePolicy: CachePolicy): PersistedResourceValue<T> | null {
128
+ return substackPersistence?.setResource<T>(kind, key, value, {
129
+ sourceKey: CACHE_SOURCE,
130
+ schemaVersion: CACHE_SCHEMA_VERSION,
131
+ cachePolicy,
132
+ }) ?? null;
133
+ }
134
+
135
+ export async function loadCachedResource<T>(
136
+ kind: keyof typeof CACHE_POLICIES,
137
+ key: string,
138
+ force: boolean,
139
+ loader: () => Promise<T>,
140
+ ): Promise<SubstackCachedData<T>> {
141
+ const fresh = readResource<T>(kind, key, false);
142
+ if (!force && fresh && !fresh.stale) {
143
+ return { data: fresh.value, fetchedAt: fresh.fetchedAt, stale: false };
144
+ }
145
+
146
+ const activeKey = `${kind}:${key}`;
147
+ const active = activeFetches.get(activeKey);
148
+ if (active) return active as Promise<SubstackCachedData<T>>;
149
+
150
+ const fallback = fresh ?? readResource<T>(kind, key, true);
151
+ const promise = loader()
152
+ .then((data) => {
153
+ const record = writeResource(kind, key, data, CACHE_POLICIES[kind]);
154
+ return {
155
+ data,
156
+ fetchedAt: record?.fetchedAt ?? Date.now(),
157
+ stale: false,
158
+ };
159
+ })
160
+ .catch((error) => {
161
+ if (fallback) {
162
+ return {
163
+ data: fallback.value,
164
+ fetchedAt: fallback.fetchedAt,
165
+ stale: true,
166
+ };
167
+ }
168
+ throw error;
169
+ })
170
+ .finally(() => {
171
+ activeFetches.delete(activeKey);
172
+ });
173
+ activeFetches.set(activeKey, promise as Promise<SubstackCachedData<unknown>>);
174
+ return promise;
175
+ }
176
+
177
+ export function resolveSubstackUrl(value: unknown, baseUrl = SUBSTACK_ORIGIN): string | null {
178
+ if (typeof value !== "string" || !value.trim()) return null;
179
+ const direct = normalizedHttpUrl(value);
180
+ if (direct) return direct;
181
+ try {
182
+ return normalizedHttpUrl(new URL(value, baseUrl).toString());
183
+ } catch {
184
+ return null;
185
+ }
186
+ }
@@ -0,0 +1,40 @@
1
+ import type {
2
+ SubstackArticleDetail,
3
+ SubstackArticleSummary,
4
+ SubstackPublication,
5
+ } from "../types";
6
+
7
+ export interface SubstackAuthState {
8
+ email: string;
9
+ sid: string;
10
+ lli: string | null;
11
+ loggedInAt: number;
12
+ }
13
+
14
+ export interface SubstackHomeData {
15
+ subscriptions: SubstackPublication[];
16
+ feed: SubstackArticleSummary[];
17
+ fetchedAt: number;
18
+ stale: boolean;
19
+ }
20
+
21
+ export interface SubstackPublicationFeedPage {
22
+ items: SubstackArticleSummary[];
23
+ nextOffset: number | null;
24
+ hasMore: boolean;
25
+ }
26
+
27
+ export interface SubstackCachedData<T> {
28
+ data: T;
29
+ fetchedAt: number;
30
+ stale: boolean;
31
+ }
32
+
33
+ export class SubstackAuthError extends Error {
34
+ constructor(message = "Substack login required") {
35
+ super(message);
36
+ this.name = "SubstackAuthError";
37
+ }
38
+ }
39
+
40
+ export type { SubstackArticleDetail, SubstackArticleSummary, SubstackPublication };
@@ -0,0 +1,298 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ import { MemoryPluginPersistence } from "../../../test-support/plugin-persistence";
3
+ import {
4
+ attachSubstackPersistence,
5
+ resetSubstackPersistence,
6
+ setSubstackFetchTransportForTests,
7
+ } from "./api/store";
8
+ import {
9
+ completeSubstackMagicLink,
10
+ completeSubstackOtpLogin,
11
+ parseCookiesFromSetCookie,
12
+ splitSetCookieHeader,
13
+ } from "./api/auth";
14
+ import {
15
+ getCachedSubstackArticleDetail,
16
+ getCachedSubstackHome,
17
+ getCachedSubstackPublicationFeed,
18
+ } from "./api/cache";
19
+ import {
20
+ loadSubstackArticleDetail,
21
+ loadSubstackHome,
22
+ loadSubstackPublicationFeed,
23
+ } from "./api/loaders";
24
+ import type { SubstackArticleSummary, SubstackPublication } from "./types";
25
+
26
+ const EMAIL = "reader@example.com";
27
+ const LOGIN_URL = "https://substack.com/login-token";
28
+
29
+ afterEach(() => {
30
+ setSubstackFetchTransportForTests(null);
31
+ resetSubstackPersistence();
32
+ });
33
+
34
+ function publication(overrides: Partial<SubstackPublication> = {}): SubstackPublication {
35
+ return {
36
+ id: "alpha",
37
+ name: "Alpha Research",
38
+ subdomain: null,
39
+ baseUrl: "https://alpha.example",
40
+ description: null,
41
+ logoUrl: null,
42
+ latestPublishedAt: null,
43
+ ...overrides,
44
+ };
45
+ }
46
+
47
+ function article(overrides: Partial<SubstackArticleSummary> = {}): SubstackArticleSummary {
48
+ const pub = publication();
49
+ return {
50
+ id: "1",
51
+ title: "Markets and $SPY",
52
+ publicationId: pub.id,
53
+ publicationName: pub.name,
54
+ publicationSubdomain: pub.subdomain,
55
+ publicationBaseUrl: pub.baseUrl,
56
+ url: `${pub.baseUrl}/p/markets`,
57
+ slug: "markets",
58
+ publishedAt: "2026-06-05T10:00:00Z",
59
+ subtitle: null,
60
+ previewText: null,
61
+ bodyHtml: null,
62
+ imageUrls: [],
63
+ wordCount: 220,
64
+ readMinutes: 1,
65
+ ...overrides,
66
+ };
67
+ }
68
+
69
+ function installAuthenticatedTransport(
70
+ handler: (url: string, init?: RequestInit) => Response | undefined | Promise<Response | undefined>,
71
+ ): string[] {
72
+ const requests: string[] = [];
73
+ setSubstackFetchTransportForTests(async (url, init) => {
74
+ requests.push(url);
75
+ if (url === LOGIN_URL) {
76
+ return new Response(null, {
77
+ status: 302,
78
+ headers: {
79
+ location: "/reader",
80
+ "set-cookie": "substack.sid=sid123; Path=/; HttpOnly",
81
+ },
82
+ });
83
+ }
84
+ if (url === "https://substack.com/reader") {
85
+ return new Response("ok", { status: 200 });
86
+ }
87
+ return await handler(url, init) ?? new Response(`unexpected ${url}`, { status: 500 });
88
+ });
89
+ return requests;
90
+ }
91
+
92
+ describe("Substack auth", () => {
93
+ test("parses combined cookie headers and follows OTP redirects for the session cookie", async () => {
94
+ const headers = splitSetCookieHeader(
95
+ "foo=bar; Expires=Wed, 21 Oct 2026 07:28:00 GMT; Path=/, substack.sid=sid123; Path=/; HttpOnly, substack.lli=1; Path=/",
96
+ );
97
+ expect(parseCookiesFromSetCookie(headers).get("substack.sid")).toBe("sid123");
98
+
99
+ attachSubstackPersistence(new MemoryPluginPersistence());
100
+ const requests: string[] = [];
101
+ setSubstackFetchTransportForTests(async (url, init) => {
102
+ requests.push(url);
103
+ if (url === "https://substack.com/api/v1/email-otp-login/complete") {
104
+ expect(init?.method).toBe("POST");
105
+ expect(JSON.parse(String(init?.body))).toMatchObject({ code: "123456", email: EMAIL });
106
+ return Response.json({ redirect: "/reader" });
107
+ }
108
+ if (url === "https://substack.com/reader") {
109
+ return new Response(null, {
110
+ status: 302,
111
+ headers: {
112
+ location: "/home",
113
+ "set-cookie": "substack.sid=sid789; Path=/; HttpOnly",
114
+ },
115
+ });
116
+ }
117
+ if (url === "https://substack.com/home") {
118
+ return new Response("ok", {
119
+ headers: { "set-cookie": "substack.lli=1; Path=/" },
120
+ });
121
+ }
122
+ return new Response(`unexpected ${url}`, { status: 500 });
123
+ });
124
+
125
+ const auth = await completeSubstackOtpLogin("123456", EMAIL);
126
+
127
+ expect(auth).toMatchObject({ sid: "sid789", lli: "1", email: EMAIL });
128
+ expect(requests).toEqual([
129
+ "https://substack.com/api/v1/email-otp-login/complete",
130
+ "https://substack.com/reader",
131
+ "https://substack.com/home",
132
+ ]);
133
+ });
134
+ });
135
+
136
+ describe("Substack cached data loading", () => {
137
+ test("loads subscriptions plus paginated feed and reuses the cached result", async () => {
138
+ attachSubstackPersistence(new MemoryPluginPersistence());
139
+ const feedUrls: string[] = [];
140
+ const requests = installAuthenticatedTransport((url) => {
141
+ if (url === "https://substack.com/api/v1/subscriptions/page_v2") {
142
+ return Response.json({
143
+ result: {
144
+ subscriptions: [{ id: "sub-alpha", publication_id: "alpha" }],
145
+ publicationMap: {
146
+ alpha: { id: "alpha", name: "Alpha Research", base_url: "https://alpha.example" },
147
+ },
148
+ },
149
+ });
150
+ }
151
+ if (url.startsWith("https://substack.com/api/v1/reader/feed?")) {
152
+ feedUrls.push(url);
153
+ const parsed = new URL(url);
154
+ return Response.json(parsed.searchParams.has("cursor")
155
+ ? { items: [{ post: { id: "second", title: "Second", post_date: "2026-06-05T11:00:00Z", publication: { id: "alpha", name: "Alpha Research", base_url: "https://alpha.example" } } }] }
156
+ : {
157
+ items: [
158
+ { type: "comment", comment: { id: 1, body: "not a post" } },
159
+ { post: { id: "first", title: "First", post_date: "2026-06-05T10:00:00Z", publication: { id: "alpha", name: "Alpha Research", base_url: "https://alpha.example" } } },
160
+ ],
161
+ nextCursor: "cursor-2",
162
+ });
163
+ }
164
+ });
165
+
166
+ await completeSubstackMagicLink(LOGIN_URL, EMAIL);
167
+ const first = await loadSubstackHome(false);
168
+ const requestCountAfterFirstLoad = requests.length;
169
+ const second = await loadSubstackHome(false);
170
+
171
+ expect(first.subscriptions.map((item) => item.name)).toEqual(["Alpha Research"]);
172
+ expect(first.feed.map((item) => item.id)).toEqual(["first", "second"]);
173
+ expect(second.feed.map((item) => item.id)).toEqual(["first", "second"]);
174
+ expect(feedUrls).toHaveLength(2);
175
+ expect(new URL(feedUrls[1]!).searchParams.get("cursor")).toBe("cursor-2");
176
+ expect(requests).toHaveLength(requestCountAfterFirstLoad);
177
+ });
178
+
179
+ test("hydrates cached home, merged publication pages, and article detail synchronously", () => {
180
+ const persistence = new MemoryPluginPersistence();
181
+ attachSubstackPersistence(persistence);
182
+ const pub = publication({ name: "Alpha" });
183
+ const firstArticle = article({ id: "1", title: "Archive one", publicationName: "Alpha" });
184
+ const secondArticle = article({
185
+ id: "2",
186
+ title: "Archive two",
187
+ publicationName: "Alpha",
188
+ url: "https://alpha.example/p/archive-two",
189
+ slug: "archive-two",
190
+ publishedAt: "2026-06-04T10:00:00Z",
191
+ readMinutes: 2,
192
+ wordCount: 440,
193
+ });
194
+ const cacheOptions = { sourceKey: "substack", schemaVersion: 3 };
195
+
196
+ persistence.seedResource("subscriptions", "me", [pub], cacheOptions);
197
+ persistence.seedResource("feed", "subscribed", [firstArticle], cacheOptions);
198
+ persistence.seedResource("publication", "https://alpha.example:offset:0", {
199
+ items: [firstArticle],
200
+ nextOffset: 12,
201
+ hasMore: true,
202
+ }, cacheOptions);
203
+ persistence.seedResource("publication", "https://alpha.example:offset:12", {
204
+ items: [secondArticle],
205
+ nextOffset: null,
206
+ hasMore: false,
207
+ }, cacheOptions);
208
+ persistence.seedResource("post", "1", {
209
+ ...firstArticle,
210
+ bodyHtml: "<p>Cached body</p>",
211
+ contentText: "Cached body",
212
+ contentBlocks: [{ type: "paragraph", text: "Cached body" }],
213
+ linkUrls: [],
214
+ }, cacheOptions);
215
+
216
+ expect(getCachedSubstackHome()?.feed.map((item) => item.title)).toEqual(["Archive one"]);
217
+ expect(getCachedSubstackPublicationFeed(pub)?.data.items.map((item) => item.title)).toEqual([
218
+ "Archive one",
219
+ "Archive two",
220
+ ]);
221
+ expect(getCachedSubstackArticleDetail({ id: "1" })?.data.contentText).toBe("Cached body");
222
+ });
223
+ });
224
+
225
+ describe("Substack publication archive loading", () => {
226
+ test("uses archive page limits and offset pagination", async () => {
227
+ attachSubstackPersistence(new MemoryPluginPersistence());
228
+ const requests = installAuthenticatedTransport((url) => {
229
+ if (url === "https://alpha.example/api/v1/archive?sort=new&limit=12") {
230
+ return Response.json(Array.from({ length: 12 }, (_, index) => ({
231
+ id: index + 1,
232
+ title: `Archive ${index + 1}`,
233
+ post_date: "2026-06-05T10:00:00Z",
234
+ slug: `archive-${index + 1}`,
235
+ })));
236
+ }
237
+ if (url === "https://alpha.example/api/v1/archive?sort=new&limit=12&offset=12") {
238
+ return Response.json(Array.from({ length: 3 }, (_, index) => ({
239
+ id: index + 13,
240
+ title: `Archive ${index + 13}`,
241
+ post_date: "2026-06-05T10:00:00Z",
242
+ slug: `archive-${index + 13}`,
243
+ })));
244
+ }
245
+ });
246
+
247
+ await completeSubstackMagicLink(LOGIN_URL, EMAIL);
248
+ const first = await loadSubstackPublicationFeed(publication(), false);
249
+ const second = await loadSubstackPublicationFeed(publication(), false, first.data.nextOffset ?? 0);
250
+
251
+ expect(first.data).toMatchObject({ hasMore: true, nextOffset: 12 });
252
+ expect(first.data.items).toHaveLength(12);
253
+ expect(second.data.items.map((item) => item.id)).toEqual(["13", "14", "15"]);
254
+ expect(second.data.hasMore).toBe(false);
255
+ expect(requests).toContain("https://alpha.example/api/v1/archive?sort=new&limit=12");
256
+ expect(requests).toContain("https://alpha.example/api/v1/archive?sort=new&limit=12&offset=12");
257
+ });
258
+ });
259
+
260
+ describe("Substack article detail loading", () => {
261
+ test("prefers the Substack origin post endpoint over custom-domain teaser content", async () => {
262
+ attachSubstackPersistence(new MemoryPluginPersistence());
263
+ const requests = installAuthenticatedTransport((url) => {
264
+ if (url === "https://substack.com/api/v1/posts/by-id/42") {
265
+ return Response.json({
266
+ id: 42,
267
+ title: "Full post",
268
+ body_html: "<p>Preview.</p><p>Paid section with $NVDA and more detail.</p>",
269
+ publication: { id: "alpha", name: "Alpha", base_url: "https://alpha.example" },
270
+ });
271
+ }
272
+ if (url === "https://alpha.example/api/v1/posts/by-id/42") {
273
+ return Response.json({
274
+ id: 42,
275
+ title: "Full post",
276
+ body_html: "<p>Preview.</p>",
277
+ publication: { id: "alpha", name: "Alpha", base_url: "https://alpha.example" },
278
+ });
279
+ }
280
+ });
281
+
282
+ await completeSubstackMagicLink(LOGIN_URL, EMAIL);
283
+ const detail = await loadSubstackArticleDetail(article({
284
+ id: "42",
285
+ title: "Full post",
286
+ url: "https://alpha.example/p/full-post",
287
+ slug: "full-post",
288
+ previewText: "Preview.",
289
+ bodyHtml: "<p>Preview.</p>",
290
+ wordCount: 1,
291
+ }));
292
+
293
+ expect(detail.data.contentText).toContain("Paid section");
294
+ expect(detail.data.wordCount).toBeGreaterThan(1);
295
+ expect(requests).toContain("https://substack.com/api/v1/posts/by-id/42");
296
+ expect(requests).not.toContain("https://alpha.example/api/v1/posts/by-id/42");
297
+ });
298
+ });