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') {