diff --git a/apps/dashboard/src/features/growth/growth-page.tsx b/apps/dashboard/src/features/growth/growth-page.tsx index f8e6d80..73c0ec6 100644 --- a/apps/dashboard/src/features/growth/growth-page.tsx +++ b/apps/dashboard/src/features/growth/growth-page.tsx @@ -86,9 +86,12 @@ function AccountRow({ function SubredditRow({ accountId, subreddit, + dailyAllowance, }: { accountId: string; subreddit: GrowthSubreddit; + /** The account-wide ceiling, which binds before any per-community cap. */ + dailyAllowance: number; }) { const update = useUpdateGrowthSubredditMutation(accountId); const remove = useRemoveGrowthSubredditMutation(accountId); @@ -102,9 +105,16 @@ function SubredditRow({
r/{subreddit.subreddit} - {subreddit.postedLast24h} of {subreddit.dailyCap} today + {subreddit.postedLast24h} of {subreddit.dailyCap} in the last 24h
+ {subreddit.dailyCap > dailyAllowance ? ( +

+ Only {dailyAllowance} of these can actually be used today — the account allowance is the + overall limit, and this cap only stops one community taking all of it. +

+ ) : null} + {subreddit.disabledReason ? (

Reddit refused this account here: {subreddit.disabledReason} @@ -211,12 +221,12 @@ function AccountDetail({ account }: { account: GrowthAccountDetail }) {

-
Allowance
-
{stats.postedLast24h} / {settings.dailyAllowance} today
+
Used in last 24h
+
{stats.postedLast24h} / {settings.dailyAllowance}
Trust level
-
{settings.trustLevel} of 4
+
{settings.trustLevel} of 4 → {settings.dailyAllowance}/day
Comments live
@@ -237,9 +247,12 @@ function AccountDetail({ account }: { account: GrowthAccountDetail }) {

- The allowance rises on its own while comments survive and earn upvotes, and falls back - the moment one is removed or heavily downvoted. Two removals in a row, or a comment the - public cannot see, pauses the account for you. + The allowance comes from the trust level, not from a setting: level 0 permits 2 comments + a day, then 3, 5, 8 and 12. It is a rolling 24-hour window rather than a midnight reset, + so capacity frees up 24 hours after each comment. The level rises on its own while + comments survive and earn upvotes, and falls back the moment one is removed or heavily + downvoted. Two removals in a row, or a comment the public cannot see, pauses the account + for you.

@@ -331,7 +344,12 @@ function AccountDetail({ account }: { account: GrowthAccountDetail }) { ) : (
{account.subreddits.map((subreddit) => ( - + ))}
)} diff --git a/src/reddit/comment-client.ts b/src/reddit/comment-client.ts index 066a111..d0e3b6b 100644 --- a/src/reddit/comment-client.ts +++ b/src/reddit/comment-client.ts @@ -71,6 +71,12 @@ export interface RedditCommentClientDependencies { scheduler?: RedditRequestScheduler; anonymousFetch?: AnonymousFetcher; now?: () => number; + /** + * Names and domains of the cookies visible to Reddit, never their values. + * Included in the log when Reddit reports a signed-out session, because the + * usual cause is cookies scoped to a host that this origin cannot see. + */ + describeCookies?: () => Promise; } const passthroughScheduler: RedditRequestScheduler = { @@ -107,6 +113,7 @@ export class RedditCommentClient { private readonly scheduler: RedditRequestScheduler; private readonly anonymousFetch: AnonymousFetcher | null; private readonly now: () => number; + private readonly describeCookies: (() => Promise) | null; constructor( private readonly transport: RedditWriteTransport, @@ -116,6 +123,7 @@ export class RedditCommentClient { this.scheduler = dependencies.scheduler ?? passthroughScheduler; this.anonymousFetch = dependencies.anonymousFetch ?? null; this.now = dependencies.now ?? Date.now; + this.describeCookies = dependencies.describeCookies ?? null; } /** @@ -157,7 +165,12 @@ export class RedditCommentClient { } if (response.status === 403) { - // A 403 here is ambiguous between a dead session and a community ban, so + // Reddit's anti-abuse interstitial is an HTML 403. It says nothing about + // whether we are signed in, so it must not invalidate the account. + if (!looksLikeJson(response.body)) { + return { status: 'failed', reason: blockPageReason(response) }; + } + // A JSON 403 is ambiguous between a dead session and a community ban, so // drop the cached modhash and let the next attempt re-derive identity. this.modhash = null; return { status: 'session-invalid', reason: 'Reddit rejected the comment with HTTP 403' }; @@ -271,6 +284,13 @@ export class RedditCommentClient { }; } + // A non-JSON body is Reddit refusing to serve us at all — an anti-abuse + // interstitial or error page. Reading that as "signed out" would invalidate + // a perfectly good session and disrupt outreach, so it stays retryable. + if (!looksLikeJson(response.body)) { + this.logger.warn(`Growth: ${blockPageReason(response)}`); + return { ok: false, kind: 'unreachable', reason: blockPageReason(response) }; + } if (response.status === 401 || response.status === 403) { return { ok: false, @@ -293,16 +313,32 @@ export class RedditCommentClient { : undefined; const modhash = typeof data?.modhash === 'string' ? data.modhash : ''; if (!modhash) { + // Reddit answered with real JSON and no modhash, so it genuinely does not + // recognise this session. The cookie summary is the fastest way to see + // why — most often they are scoped to a host this origin cannot read. + const cookies = await this.describeSessionCookies(); + this.logger.warn( + `Growth: Reddit does not recognise this session on ${REDDIT_WRITE_ORIGIN}. Cookies visible: ${cookies}`, + ); return { ok: false, kind: 'signed-out', - reason: 'Reddit did not return a modhash for this session', + reason: `Reddit did not return a modhash for this session (cookies visible: ${cookies})`, }; } this.modhash = modhash; return { ok: true, modhash }; } + private async describeSessionCookies(): Promise { + if (!this.describeCookies) return 'not inspected'; + try { + return await this.describeCookies(); + } catch (error) { + return `could not be read (${errorMessage(error)})`; + } + } + private classifyError( error: { code: string; message: string }, subreddit: string, @@ -490,6 +526,17 @@ function readListing(payload: unknown): Map { + const cookies = await browserClient.getContext()?.cookies(REDDIT_WRITE_ORIGIN) ?? []; + if (cookies.length === 0) return `none visible to ${REDDIT_WRITE_ORIGIN}`; + return cookies.map((cookie) => `${cookie.name}@${cookie.domain}`).join(', '); + }, }, ); const growthService = new AccountGrowthService({ diff --git a/tests/dashboard/growth.test.tsx b/tests/dashboard/growth.test.tsx index 38dbf7e..96cb32a 100644 --- a/tests/dashboard/growth.test.tsx +++ b/tests/dashboard/growth.test.tsx @@ -195,20 +195,64 @@ describe('GrowthPage', () => { it('surfaces the allowance, trust level and comment health', async () => { renderGrowth(); - expect(await screen.findByText('1 / 3 today')).toBeVisible(); - expect(screen.getByText('1 of 4')).toBeVisible(); + expect(await screen.findByText('1 / 3')).toBeVisible(); + expect(screen.getByText('Used in last 24h')).toBeVisible(); + expect(screen.getByText('1 of 4 → 3/day')).toBeVisible(); // 6 posted less 1 removed. expect(screen.getByText('5')).toBeVisible(); expect(screen.getByText('4')).toBeVisible(); }); - it('explains that the allowance is adaptive rather than fixed', async () => { + it('explains where the allowance comes from and that it is a rolling window', async () => { renderGrowth(); - expect(await screen.findByText(/allowance rises on its own/i)).toBeVisible(); + // The number is derived from the trust level, not typed in by the operator, + // which is the thing most likely to confuse someone reading the page. + expect(await screen.findByText(/level 0 permits 2 comments a day/i)).toBeVisible(); + expect(screen.getByText(/rolling 24-hour window rather than a midnight reset/i)).toBeVisible(); expect(screen.getByText(/Two removals in a row/i)).toBeVisible(); }); + it('warns when a community cap exceeds what the account allowance permits', async () => { + detailPayload = detail({ + settings: settings({ trustLevel: 0, dailyAllowance: 2 }), + subreddits: [{ + subreddit: 'AskUK', + enabled: true, + weight: 1, + dailyCap: 10, + disabledReason: null, + cooldownUntil: null, + addedAt: 1, + postedLast24h: 1, + }], + }); + renderGrowth(); + + // A cap of 10 with an allowance of 2 reads as "10 available" otherwise. + expect(await screen.findByText(/Only 2 of these can actually be used today/i)).toBeVisible(); + }); + + it('does not warn when the cap sits within the allowance', async () => { + detailPayload = detail({ + settings: settings({ dailyAllowance: 5 }), + subreddits: [{ + subreddit: 'AskUK', + enabled: true, + weight: 1, + dailyCap: 2, + disabledReason: null, + cooldownUntil: null, + addedAt: 1, + postedLast24h: 0, + }], + }); + renderGrowth(); + + expect(await screen.findByText('r/AskUK')).toBeVisible(); + expect(screen.queryByText(/can actually be used today/i)).not.toBeInTheDocument(); + }); + it('toggles growth off through the API', async () => { renderGrowth(); const user = userEvent.setup(); diff --git a/tests/reddit-comment-client.test.ts b/tests/reddit-comment-client.test.ts index 9060caf..3df7b37 100644 --- a/tests/reddit-comment-client.test.ts +++ b/tests/reddit-comment-client.test.ts @@ -175,9 +175,9 @@ describe('RedditCommentClient.postComment', () => { expect((await client.postComment('t3_a', 'cats', 'text')).status).toBe('failed'); }); - it.each([401, 403])('treats HTTP %i on the identity check as signed out', async (status) => { + it.each([401, 403])('treats a JSON HTTP %i on the identity check as signed out', async (status) => { const client = new RedditCommentClient( - fakeTransport({ get: () => ({ status, body: '' }) }), + fakeTransport({ get: () => ({ status, body: '{"error":' + status + '}' }) }), logger, ); @@ -292,8 +292,10 @@ describe('RedditCommentClient.postComment', () => { }); describe('HTTP failures', () => { - it('treats 403 as an invalid session and clears the cached modhash', async () => { - const transport = fakeTransport({ post: () => ({ status: 403, body: '' }) }); + it('treats a JSON 403 as an invalid session and clears the cached modhash', async () => { + // A bodiless or HTML 403 is Reddit blocking us; only a JSON 403 is a + // statement about the session. See the block-page tests below. + const transport = fakeTransport({ post: () => ({ status: 403, body: '{"message":"Forbidden"}' }) }); const client = new RedditCommentClient(transport, logger); expect((await client.postComment('t3_a', 'cats', 'text')).status).toBe('session-invalid'); @@ -459,6 +461,109 @@ describe('RedditCommentClient.fetchPubliclyVisible', () => { }); }); +describe('Reddit anti-abuse block pages', () => { + const logger = createMockLogger(); + // Reddit serves a large HTML interstitial with a 403 when it throttles a + // client. It looks identical to a signed-out session unless the body is + // inspected, and misreading it invalidates a healthy account's auth. + const BLOCK_PAGE = { status: 403, body: '' + 'x'.repeat(2_000) + '' }; + + it('treats an HTML 403 on the identity check as retryable, not signed out', async () => { + const client = new RedditCommentClient(fakeTransport({ get: () => BLOCK_PAGE }), logger); + + const result = await client.postComment('t3_a', 'cats', 'text'); + + expect(result.status).toBe('failed'); + expect(result).toMatchObject({ reason: expect.stringContaining('anti-abuse block page') }); + }); + + it('treats an HTML 403 on the comment itself as retryable', async () => { + const client = new RedditCommentClient( + fakeTransport({ post: () => BLOCK_PAGE }), + logger, + ); + + const result = await client.postComment('t3_a', 'cats', 'text'); + + expect(result.status).toBe('failed'); + expect(result).toMatchObject({ reason: expect.stringContaining('anti-abuse block page') }); + }); + + it('still honours a genuine JSON 403 on the comment as an invalid session', async () => { + const client = new RedditCommentClient( + fakeTransport({ post: () => ({ status: 403, body: '{"message":"Forbidden"}' }) }), + logger, + ); + + expect((await client.postComment('t3_a', 'cats', 'text')).status).toBe('session-invalid'); + }); + + it('treats a non-JSON 200 as retryable too', async () => { + const client = new RedditCommentClient( + fakeTransport({ get: () => ({ status: 200, body: 'are you a robot?' }) }), + logger, + ); + + expect((await client.postComment('t3_a', 'cats', 'text')).status).toBe('failed'); + }); +}); + +describe('signed-out session diagnostics', () => { + const logger = createMockLogger(); + + it('reports which cookies were visible when Reddit does not recognise the session', async () => { + const client = new RedditCommentClient( + fakeTransport({ get: () => ({ status: 200, body: JSON.stringify({ data: { modhash: '' } }) }) }), + logger, + { describeCookies: async () => 'reddit_session@www.reddit.com, token_v2@www.reddit.com' }, + ); + + const result = await client.postComment('t3_a', 'cats', 'text'); + + expect(result.status).toBe('session-invalid'); + // The cookie host is the usual cause, so it belongs in the reason. + expect(result).toMatchObject({ reason: expect.stringContaining('www.reddit.com') }); + }); + + it('never leaks cookie values, only what the caller chose to describe', async () => { + const describeCookies = vi.fn(async () => 'reddit_session@.reddit.com'); + const client = new RedditCommentClient( + fakeTransport({ get: () => ({ status: 200, body: JSON.stringify({ data: { modhash: '' } }) }) }), + logger, + { describeCookies }, + ); + + const result = await client.postComment('t3_a', 'cats', 'text'); + + expect(describeCookies).toHaveBeenCalledTimes(1); + expect(result).toMatchObject({ reason: expect.stringContaining('reddit_session@.reddit.com') }); + }); + + it('survives a cookie description that throws', async () => { + const client = new RedditCommentClient( + fakeTransport({ get: () => ({ status: 200, body: JSON.stringify({ data: { modhash: '' } }) }) }), + logger, + { describeCookies: async () => { throw new Error('context closed'); } }, + ); + + const result = await client.postComment('t3_a', 'cats', 'text'); + + expect(result.status).toBe('session-invalid'); + expect(result).toMatchObject({ reason: expect.stringContaining('context closed') }); + }); + + it('says so when no diagnostic is wired up', async () => { + const client = new RedditCommentClient( + fakeTransport({ get: () => ({ status: 200, body: JSON.stringify({ data: { modhash: '' } }) }) }), + logger, + ); + + expect(await client.postComment('t3_a', 'cats', 'text')).toMatchObject({ + reason: expect.stringContaining('not inspected'), + }); + }); +}); + describe('createBrowserWriteTransport', () => { /** Mirrors production: a freshly opened Playwright page is on about:blank. */ function fakePage(initialUrl = 'about:blank') {