diff --git a/README.md b/README.md index 032acff..02083a6 100644 --- a/README.md +++ b/README.md @@ -108,6 +108,7 @@ Keep the session token local and out of source control. All MCP tools are read-o | `deleteComment(id)` | Permanently deletes an authenticated user's comment. | | `setNoteRestack(id, restacked, options)` | Restacks or removes a Note restack. | | `getActivity(filter)` | Activity feed. Filters: `all`, `replies-and-mentions`, `restacks`. | +| `getActivityPage({ filter, after })` | One complete historical activity page with validated `more` and `nextAfter`. | | `getUnreadActivity()` | Activity feed annotated using Substack's unread count. | | `getFollowing()` | Accounts followed by the authenticated account. | | `testConnectivity()` | Whether the session can perform a lightweight API request. | @@ -206,6 +207,49 @@ const latestFive = (activity.activityItems ?? []).slice(0, 5) This is an activity feed, so it can include both replies and mentions. To fetch the comments for one particular post, use `getPostComments(postId)`. +For historical activity, use the separately exported `ActivityPageOptions` and +`ActivityPage` types with `getActivityPage()`: + +```ts +const firstPage = await client.getActivityPage({ filter: 'all' }) +// The consuming app decides when to request another page. +if (firstPage.nextAfter !== null) { + const olderPage = await client.getActivityPage({ + filter: 'all', + after: firstPage.nextAfter + }) +} +``` + +Each call makes exactly one authenticated GET to the global +`/api/v1/activity-feed-web?filter=...` endpoint, adding URL-encoded `after` when +provided. `filter` defaults to `all` and supports the same filters as +`getActivity()`. Input cursors and every item's `updated_at` must be valid ISO +8601 timestamps with a timezone (`Z` or a numeric offset) and at most three +fractional-second digits. + +The result preserves all upstream fields, lookup tables, and every +`activityItems` record, adding explicit pagination metadata. `more` must be an +upstream boolean. When `more=true`, `nextAfter` is the **last item's `updated_at` +minus one millisecond**, formatted as a UTC ISO timestamp. For example, +`updated_at: 2026-09-05T21:53:10.058Z` produces +`nextAfter: 2026-09-05T21:53:10.057Z`, even if that item's `created_at` is +`2026-09-05T21:53:10.062Z`. When `more=false`, `nextAfter` is `null`, including +on an empty final page. These rules follow observed native Substack pagination. + +Items must be ordered by descending `updated_at` (ties allowed). With an `after` +cursor, no item may be newer than the cursor, and a nonempty page's last item +must be strictly older than it. Invalid input throws `SubstackConfigurationError` +before requesting activity. Malformed responses, missing or invalid timestamps, +unordered items, non-advancing pages, and empty pages with `more=true` throw +`SubstackApiError`; they are never interpreted as exhaustion. + +Grouped activity can be created in April and updated in September. This method +preserves such records and never filters or paginates by `created_at`. +`getActivity()` retains its existing raw-response behavior. Date cutoffs, +automatic pagination, retries, scheduling, persistence, and resumable backfills +remain the consuming app's responsibility. + ## Publishing Notes `publishNote` creates public content. Its `bodyJson` is passed directly to Substack's ProseMirror-style Notes API. Create a link or image attachment first, then include its returned ID in `attachmentIds`. diff --git a/package.json b/package.json index f1a9172..fba19ca 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "unofficial-substack-sdk", - "version": "0.3.12", + "version": "0.3.13", "description": "Unofficial, portable TypeScript SDK for Substack's web API", "type": "module", "license": "MIT", diff --git a/src/core/client.ts b/src/core/client.ts index e93aa66..a4376b7 100644 --- a/src/core/client.ts +++ b/src/core/client.ts @@ -3,6 +3,8 @@ import type { EndpointContext } from './transport.js' import type { ActivityFeed, ActivityFilter, + ActivityPage, + ActivityPageOptions, CreateAttachmentRequest, CursorOptions, DraftNotesOptions, @@ -40,7 +42,7 @@ import type { UploadedImage, UpdateScheduledNoteRequest } from './types.js' -import { getActivity, getUnreadActivity } from '../resources/activity/index.js' +import { getActivity, getActivityPage, getUnreadActivity } from '../resources/activity/index.js' import { getAllEmailStats, getEmailStats } from '../resources/email-stats/index.js' import { getGrowthSources } from '../resources/growth/index.js' import { @@ -371,6 +373,11 @@ export class SubstackClient { return getUnreadActivity(this.endpoints) } + /** Fetches one complete activity page, validating updated_at ordering and cursor progress. */ + getActivityPage(options: ActivityPageOptions = {}): Promise { + return getActivityPage(this.endpoints, options) + } + getFollowing(): Promise { return getFollowing(this.endpoints) } diff --git a/src/core/index.ts b/src/core/index.ts index 5dbd96a..44fcb8f 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -5,6 +5,8 @@ export { ACTIVITY_FILTERS, type ActivityFeed, type ActivityFilter, + type ActivityPage, + type ActivityPageOptions, type CreateAttachmentRequest, type CreateImageAttachmentRequest, type CreateLinkAttachmentRequest, diff --git a/src/core/types.ts b/src/core/types.ts index c3c13dc..7bdb4c1 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -509,6 +509,21 @@ export type ActivityFeed = { [key: string]: unknown } +export interface ActivityPageOptions { + /** Defaults to all. */ + filter?: ActivityFilter + /** ISO 8601 timestamp with a timezone and at most millisecond precision. */ + after?: string +} + +/** A complete upstream activity page with validated historical pagination. */ +export type ActivityPage = ActivityFeed & { + activityItems: Array<{ updated_at: string; [key: string]: unknown }> + more: boolean + /** Last updated_at minus 1 ms in UTC, or null when more is false. */ + nextAfter: string | null +} + export type UnreadActivityFeed = ActivityFeed & { activityItems: unknown[] unread: UnreadActivityMetadata diff --git a/src/resources/activity/index.ts b/src/resources/activity/index.ts index 4347f2d..befb6a3 100644 --- a/src/resources/activity/index.ts +++ b/src/resources/activity/index.ts @@ -1,5 +1,6 @@ import type { EndpointContext } from '../../core/transport.js' -import { ACTIVITY_FILTERS, type ActivityFeed, type ActivityFilter, type UnreadActivityFeed } from '../../core/types.js' +import { SubstackApiError, SubstackConfigurationError } from '../../core/errors.js' +import { ACTIVITY_FILTERS, type ActivityFeed, type ActivityFilter, type ActivityPage, type ActivityPageOptions, type UnreadActivityFeed } from '../../core/types.js' export function isActivityFilter(value: string): value is ActivityFilter { return (ACTIVITY_FILTERS as readonly string[]).includes(value) @@ -9,6 +10,66 @@ export function getActivity(context: EndpointContext, filter: ActivityFilter = ' return context.global(`/activity-feed-web?filter=${encodeURIComponent(filter)}`) } +// Date.parse alone accepts normalized invalid dates and non-ISO date strings. +function timestamp(value: unknown): number | null { + if (typeof value !== 'string') return null + const match = /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})(?:\.(\d{1,3}))?(Z|[+-]\d{2}:\d{2})$/.exec(value) + if (!match) return null + const local = `${match[1]}.${(match[2] ?? '').padEnd(3, '0')}Z` + const localTime = Date.parse(local) + if (!Number.isFinite(localTime) || new Date(localTime).toISOString() !== local) return null + if (match[3] !== 'Z' && (Number(match[3]!.slice(1, 3)) > 23 || Number(match[3]!.slice(4)) > 59)) return null + const time = Date.parse(value) + return Number.isFinite(time) ? time : null +} + +export async function getActivityPage( + context: EndpointContext, + options: ActivityPageOptions = {} +): Promise { + const filter = options.filter ?? 'all' + if (typeof filter !== 'string' || !isActivityFilter(filter)) { + throw new SubstackConfigurationError('Invalid activity filter.') + } + const afterTime = options.after === undefined ? null : timestamp(options.after) + if (options.after !== undefined && afterTime === null) { + throw new SubstackConfigurationError('after must be a valid ISO 8601 timestamp with a timezone and at most millisecond precision.') + } + const path = `/activity-feed-web?filter=${encodeURIComponent(filter)}` + + (options.after === undefined ? '' : `&after=${encodeURIComponent(options.after)}`) + const response = await context.global(path) + const invalid = (message: string): never => { + throw new SubstackApiError(`Invalid activity pagination: ${message}`, 502, path) + } + if (!response || typeof response !== 'object' || Array.isArray(response)) { + invalid('expected an object response.') + } + const page = response as Record + if (!Array.isArray(page.activityItems) || typeof page.more !== 'boolean') { + invalid('activityItems must be an array and more must be a boolean.') + } + const items = page.activityItems as unknown[] + if (page.more && items.length === 0) invalid('an empty page cannot have more=true.') + let previous = Infinity + for (const item of items) { + const time = timestamp(item && typeof item === 'object' && !Array.isArray(item) + ? (item as Record).updated_at : undefined) + if (time === null) return invalid('each item must have a valid updated_at timestamp.') + if (time > previous) invalid('updated_at values must be in descending order (ties allowed).') + if (afterTime !== null && time > afterTime) invalid('items must not be newer than the supplied after cursor.') + previous = time + } + const nextTime = previous - 1 + if (items.length && afterTime !== null && previous >= afterTime) { + invalid('the page must advance beyond the supplied after cursor.') + } + const nextAfter = page.more ? new Date(nextTime).toISOString() : null + if (nextAfter !== null && timestamp(nextAfter) === null) { + invalid('the next cursor is outside the supported timestamp range.') + } + return { ...page, activityItems: items, more: page.more, nextAfter } as ActivityPage +} + export async function getUnreadActivity(context: EndpointContext): Promise { const [unread, feed] = await Promise.all([ context.global<{ count?: unknown; max?: unknown; lastViewedAt?: unknown }>('/activity/unread'), diff --git a/test/activity.test.ts b/test/activity.test.ts new file mode 100644 index 0000000..8510402 --- /dev/null +++ b/test/activity.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, test } from 'bun:test' +import { type ActivityPage, SubstackApiError, SubstackClient, SubstackConfigurationError } from '../src/index.js' + +const recent = '2026-09-06T12:00:00.000Z' +const oldest = '2026-09-05T21:53:10.058Z' +const next = '2026-09-05T21:53:10.057Z' +const item = (updated_at = oldest) => ({ updated_at, created_at: '2026-04-01T00:00:00.000Z' }) + +function fixture(response: unknown) { + const requests: Request[] = [] + const client = new SubstackClient({ + sessionToken: 'test-token', + fetch: async (input, init) => { + requests.push(new Request(input, init)) + return response === undefined ? new Response('') : Response.json(response) + } + }) + return { client, requests } +} + +describe('historical activity pages', () => { + test('first page preserves every item and lookup table and uses the verified updated_at cursor', async () => { + const response = { + activityItems: [item(recent), { ...item(), created_at: '2026-09-05T21:53:10.062Z' }], + more: true, + profiles: { '1': { name: 'Example' } }, + posts: { '2': { title: 'Example' } }, + unknownFutureField: { preserved: true } + } + const { client, requests } = fixture(response) + const page: ActivityPage = await client.getActivityPage() + expect(page).toEqual({ ...response, nextAfter: next }) + expect(requests).toHaveLength(1) + expect(requests[0]!.url).toBe('https://substack.com/api/v1/activity-feed-web?filter=all') + expect(requests[0]!.method).toBe('GET') + }) + + test('encodes the native cursor and requested filter in a single request', async () => { + const { client, requests } = fixture({ activityItems: [item('2026-09-04T00:00:00.000Z')], more: true }) + await client.getActivityPage({ filter: 'restacks', after: next }) + expect(requests).toHaveLength(1) + expect(requests[0]!.url).toBe('https://substack.com/api/v1/activity-feed-web?filter=restacks&after=2026-09-05T21%3A53%3A10.057Z') + }) + + test('preserves grouped records regardless of creation date and allows updated_at ties', async () => { + const response = { activityItems: [item(recent), item(recent), item()], more: true } + const { client } = fixture(response) + expect(await client.getActivityPage()).toEqual({ ...response, nextAfter: next }) + }) + + for (const activityItems of [[], [item()]]) { + test(`exhaustion with ${activityItems.length} items returns null`, async () => { + const { client, requests } = fixture({ activityItems, more: false }) + expect(await client.getActivityPage({ after: recent })).toEqual({ activityItems, more: false, nextAfter: null }) + expect(requests).toHaveLength(1) + }) + } + + const malformed = [ + undefined, null, [], 'invalid', {}, + { more: false }, { activityItems: [], more: 'false' }, + { activityItems: [], more: 0 }, { activityItems: [] }, + { activityItems: {}, more: false }, { activityItems: [], more: true }, + { activityItems: [null], more: false }, { activityItems: [[]], more: true }, + { activityItems: [{ created_at: oldest }], more: true }, + { activityItems: [item('bad')], more: false }, + { activityItems: [item('2026-02-30T00:00:00.000Z')], more: true }, + { activityItems: [item('0000-01-01T00:00:00.000Z')], more: true }, + { activityItems: [item(), item(recent)], more: false } + ] + for (const [index, response] of malformed.entries()) { + test(`rejects malformed response ${index} rather than reporting exhaustion`, async () => { + const { client, requests } = fixture(response) + await expect(client.getActivityPage()).rejects.toBeInstanceOf(SubstackApiError) + expect(requests).toHaveLength(1) + }) + } + + for (const more of [true, false]) { + for (const times of [[recent], [oldest], [recent, '2026-09-04T00:00:00.000Z']]) { + test(`rejects non-advancing or out-of-bound pages: ${times.join(',')}, more=${more}`, async () => { + const { client } = fixture({ activityItems: times.map(time => item(time)), more }) + await expect(client.getActivityPage({ after: oldest })).rejects.toBeInstanceOf(SubstackApiError) + }) + } + } + + for (const after of ['', 'bad', '2026-02-30T00:00:00Z', '2026-09-05', '2026-09-05T21:53:10', + '2026-09-05T24:00:00Z', '2026-09-05T21:53:10.0581Z', '2026-09-05T21:53:10+24:00', null, 123]) { + test(`rejects invalid input cursor ${after} before requesting activity`, async () => { + const { client, requests } = fixture({ activityItems: [], more: false }) + await expect(client.getActivityPage({ after: after as string })).rejects.toBeInstanceOf(SubstackConfigurationError) + expect(requests).toHaveLength(0) + }) + } + + test('validates timezone offsets and compares their instants', async () => { + const { client, requests } = fixture({ activityItems: [item('2026-09-05T23:53:10.058+02:00')], more: true }) + expect((await client.getActivityPage({ after: '2026-09-06T00:00:00+02:00' })).nextAfter).toBe(next) + expect(requests[0]!.url).toContain('after=2026-09-06T00%3A00%3A00%2B02%3A00') + }) + + test('rejects invalid filters without a request', async () => { + const { client, requests } = fixture({ activityItems: [], more: false }) + // @ts-expect-error Verify runtime validation for JavaScript callers. + await expect(client.getActivityPage({ filter: 'invalid' })).rejects.toBeInstanceOf(SubstackConfigurationError) + expect(requests).toHaveLength(0) + }) + + test('legacy getActivity remains raw and permissive with default and explicit filters', async () => { + const response = { activityItems: [{ id: 1 }], more: 'unvalidated', profiles: {} } + const { client, requests } = fixture(response) + expect(await client.getActivity()).toEqual(response) + expect(await client.getActivity('replies-and-mentions')).toEqual(response) + expect(requests.map(request => request.url)).toEqual([ + 'https://substack.com/api/v1/activity-feed-web?filter=all', + 'https://substack.com/api/v1/activity-feed-web?filter=replies-and-mentions' + ]) + }) +})