Conversation
…g soft-deleted tags Deleting a tag now hard-deletes its TagsPosts rows (org-scoped soft-delete runs first), the save-path tag matching filters deletedAt so a recreated same-name tag can no longer attach the old dead tag alongside it, and the post read queries filter soft-deleted tags out of the tags include so already-orphaned assignments stop rendering immediately and self-heal on the next save. Tested e2e on a local run: reproduced the stuck remnant and the doubled label on main (delete tag assigned to 2 posts -> assignments and calendar label survive; recreate same name -> both dead and live tag attach), then verified on this branch that orphaned assignments stop rendering immediately, re-saving a post drops the orphan row, deleting a live tag removes its assignments, and a recreated same-name tag attaches exactly once - all confirmed in both the calendar UI and the DB. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…caching an empty result analytics() destructured only `data` from the page insights response, so any Graph error body (rate limit, permission, invalid metric) silently became [] and checkAnalytics cached that empty result for an hour. Log the error and throw, so checkAnalytics skips the cache write and the next call retries. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The MCP server is stateless, so the GET that streamable-HTTP clients send to open a server-to-client stream is not a supported route: nothing is ever pushed on it. It is still about a quarter of MCP traffic (roughly 180k-214k of ~750k requests per day), and each one became a Sentry transaction that stayed open for the life of the held connection (avg ~5 min, p95 15 min). Sentry usage is already near its limits, which is why gitroomhq#2082 recently cut trace sampling from 100% to 20%. There is nothing to learn from traces of an unsupported route, so tracesSampler now returns 0 for GET on /mcp, /mcp/:id and the /mcp-oauth* mounts. POST traffic on the same routes, the legacy /sse/:id transport and the /.well-known discovery routes keep the existing sample rates. Pairs with the change that answers these GETs with 405. Deploy this one after that change has been verified in Sentry, because the verification reads the GET transactions this commit removes. Testing: - Called the real tracesSampler with request contexts shaped like the http instrumentation passes them (method and URL via normalizedRequest and via span attributes only): GET on the MCP routes returns 0; POST on the same routes 0.2; unrelated GETs 0.2; analytics 0.01; legacy /sse 0.2; /.well-known/.../mcp-oauth discovery 0.2; /mcpfoo 0.2. - Ran the backend against a Sentry development environment and sent 140+ GETs and 60+ POSTs to /mcp, /mcp/:id and /mcp-oauth-claude: zero GET transactions recorded, POST /mcp and POST /mcp/:id recorded as before. Same result on SDK 10.45.0 and 10.56.0. - Backend type-check passes. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The release URL for every Google My Business post was a hardcoded https://business.google.com/locations/<locationId> link, which Google answers with a 404, even for the logged-in profile owner. That link is what the "published" notification email and the preview point to. localPosts.create returns a searchUrl for the created post; use it as the release URL and keep the old link only as a fallback when Google does not send one. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…get-traces fix(sentry): stop tracing GET requests to the MCP streamable routes
…ch-url fix(gmb): use Google's searchUrl as the post release URL
…ost schema
Hashnode retired gql.hashnode.com on 2026-05-13: every request now gets a
301 to an announcement page, so connecting a channel failed with "Invalid
credentials" for every key, and publishing failed for existing channels.
- point the three calls (authenticate, publications, post) at
gql-beta.hashnode.com
- send tags as { slug } (PublishPostTagInput no longer has id); the slug is
looked up by objectID from the existing tags list, so stored posts keep
working
- send the cover as coverImage (coverImageOptions was removed from
PublishPostInput)
- read the GraphQL errors array and throw BadBody with Hashnode's message.
Hashnode answers HTTP 200 with data: null for refusals such as a
publication without an active Pro plan, which used to surface as a
TypeError, get retried, and end as "couldn't confirm it was published"
Fixes gitroomhq#1737
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A response without a post (data: null with no errors array, or a null publishPost/post) threw a TypeError on destructuring, which is retried and re-sends the publish mutation. Treat it as BadBody like the errors case. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ration fix(hashnode): move to the live GraphQL endpoint and current publishPost schema
Add POST /public/v1/clipping, GET /public/v1/clipping and GET /public/v1/clipping/:id to the public API, calling the existing ClippingService. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…-api feat(public-api): clipping endpoints
…cs-swallowed-errors fix(facebook): surface Graph API errors in page analytics instead of caching an empty result
A customer reported that their Facebook posts show no statistics at all; every one of their Facebook posts is a reel. The video_insights edge answers a reel with an empty data array for total_video_impressions, total_video_views and total_video_reactions_by_type_total, so videoPostAnalytics returned [] and the statistics modal stayed empty. Verified against a real reel: the same request with fb_reels_total_plays, post_video_likes_by_reaction_type and post_video_social_actions returns values, and Graph simply omits whichever metrics do not apply to the node, so both sets are now requested in one call. Regular videos keep their existing cases; the new ones map to Plays, Reactions and Engagement. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014wvGQeFVP1UKfi9VAYYBYK
…sights fix(facebook): request the reels metrics in video post analytics
…it search The composer's "Search Subreddit" box sent the typed text verbatim to Reddit's subreddit search. A pasted reddit.com/r/<name> URL matched nothing, so there was no result to pick and the subreddit setting was saved empty, which then failed validation with "value should not be null or undefined". When the text contains r/<name>, search by the name alone and put the exact subreddit first in the results if it exists. Plain text still sends the same search request as before, and the exact lookup only swallows a not-found response so token refresh keeps working. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…post published
Slack answers HTTP 200 with { ok: false, error } when it rejects a
message. post() and comment() destructured ts off that body and returned
status 'posted' regardless, so a rejected post (e.g. invalid_blocks for
an mp4 in an image block) was marked PUBLISHED with an empty release
URL and nothing in the channel.
Check the response after both chat.postMessage calls: auth errors throw
RefreshToken, ratelimited is retried, everything else throws BadBody
with Slack's error code and detail lines as the message.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The Slack provider sends every attachment as a Block Kit image block, which Slack only accepts for png / jpg / gif. An mp4 made chat.postMessage reject the whole message, and the post was still shown as published. Add a checkValidity override so a Slack post or comment with an mp4 attachment fails validation with "No video support for Slack, only images" in the composer, the public API and the MCP schedule tool. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Mirror the subreddit_type === 'public' filter of the search path so a pasted r/name that points at a private, restricted or archived subreddit is not offered as a destination the post would fail on. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…y-name feat(reddit): resolve a pasted r/name or subreddit URL in the subreddit search
…failing Reddit answers a rate-limited /api/submit with HTTP 200 and an errors array. finalizePost treated every entry as a terminal BadBody, so a temporary rate limit failed the whole post. A RATELIMIT entry means nothing was submitted, so finalizePost now clears the armed marker and returns pending: the next status check re-arms the same subreddit and submits it again once the window has passed. Every other rejection still throws the non-retryable BadBody. Verified against real Reddit: with the rate-limit payload injected into the first submit only, the following check re-armed and the resubmit published; a non-RATELIMIT rejection still throws BadBody. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…known Error" Adds handleErrors branches for 190/459 (checkpoint), 190/492 (no Page role), the 190 missing pages_* permissions body, 100/33 object does not exist, and Facebook's HTML outage page, so users see the reason and the right outcome (fail, reconnect, or retry) instead of "Unknown Error".
"error_subcode":33 as a substring also matched 330 and 331; the same held for 459 and 492. Test the three subcodes with a trailing word boundary instead.
Reddit can return several error entries at once. A terminal error next to RATELIMIT would reject every resubmit, so retrying burns the whole pending budget before the user sees the real reason. Retry only when every entry is RATELIMIT. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… rejection Reject a board name in the settings DTO at scheduling time and fix the over-escaped handleErrors match so Pinterest's board-id rejection is curated instead of surfacing as Unknown Error.
…-retry fix(reddit): retry the submit after a RATELIMIT rejection instead of failing
…appings-main fix(facebook): map five recurring Graph API rejections instead of "Unknown Error"
Route post, comment and login through this.fetch with a handleErrors mapping so Lemmy rejections fail once with a readable message (rate limits retry, bad credentials flag the channel); the login failure is rebuilt without the request body so the stored password is never persisted.
VK answers HTTP 200 with { error } on failures, so wall.post/wall.createComment rejections were stored as completed with postId undefined. Map code 5 to RefreshToken, 6/9/29 to a retryable error, everything else to BadBody with VK's error_msg.
…id-main fix(pinterest): validate board as a numeric id and map the board-name rejection
…rrors twitter-api-v2 401s reach handleErrors via runInConcurrent with status 200, so the generic 401 rule never fired and the post failed as Unknown Error without flagging the channel.
A non-2xx from /api/v3/search threw a TypeError on communities.map; it now fails through the same handleErrors mapping as post and comment.
…ry-message-main fix(x): readable message when Too Many Requests retries are exhausted
…rrors-main fix(vk): surface VK API errors instead of marking posts completed
Locked account, crypto addresses, invalid media ids, 10-minute video, Premium-only articles and empty tweet now map to bad-body with a message instead of Unknown Error.
Dribbble 4xx responses on shot creation were plain AxiosErrors: the workflow retried them to exhaustion and recorded "Could not publish after several attempts", and the response body with Dribbble's actual rejection reason was discarded. Map 4xx (except 429) to BadBody with the response body persisted in the failure details. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0188yxtbWi6wtfJg7ydjUpfG
…ling-main fix(lemmy): surface Lemmy API errors instead of crashing on post_view
…efresh-token-main fix(x): mark the channel for reconnect on Unauthorized media upload errors
…main # Conflicts: # libraries/nestjs-libraries/src/integrations/social/x.provider.ts
…-main fix(x): map remaining known error responses to readable messages
…-mapping-main fix(dribbble): map 4xx shot rejections to non-retryable BadBody errors
# Conflicts: # apps/frontend/src/components/onboarding/onboarding.modal.tsx # apps/frontend/src/components/public-api/public.component.tsx # chatgpt-app-submission.json # libraries/nestjs-libraries/src/database/prisma/organizations/organization.repository.ts # libraries/nestjs-libraries/src/integrations/social/reddit.provider.ts
- reddit.provider.ts: the fork's broker-based generateAuthUrl uses makeId(32) for the state; the upstream merge replaced the import with makeSecureId. - public.component.tsx: drop the upstream mcpConnectorUrls table (claude.ai/directory/postiz etc.) entirely - it routes users to the upstream cloud and the fork gates connector cards via brandConfig URLs (fail closed). Onboarding modal's unused import removed.
chore: sync with upstream Postiz (2026-09-22 evening)
There was a problem hiding this comment.
Code Review
This pull request introduces several security enhancements, new features, and bug fixes across the codebase. Key changes include replacing makeId with a cryptographically secure makeSecureId for generating sensitive tokens and OAuth states, adding a new video clipping feature with dedicated endpoints and tools, and improving error handling and API integrations for various social media providers (such as Facebook, Hashnode, Lemmy, Reddit, and Slack). Additionally, Sentry tracing was optimized, and a detailed comparison between Postiz Cloud and Open-source was added to the README. The review feedback highlights a few important improvements: parsing the page query parameter as an integer in the clipping endpoint to prevent database type mismatches, filtering out unmapped tags in the Hashnode provider to avoid sending undefined slugs, and broadening the video extension check in the Slack provider to support formats other than .mp4.
| getClippings( | ||
| @GetOrgFromRequest() org: Organization, | ||
| @Query('page') page: number | ||
| ) { |
There was a problem hiding this comment.
The page query parameter is typed as a number but is not parsed using ParseIntPipe. In NestJS, query parameters are received as strings by default unless explicitly transformed. Passing a string to the database service expecting a number can cause runtime database errors (e.g., Prisma type mismatches).
| getClippings( | |
| @GetOrgFromRequest() org: Organization, | |
| @Query('page') page: number | |
| ) { | |
| getClippings( | |
| @GetOrgFromRequest() org: Organization, | |
| @Query('page', new ParseIntPipe({ optional: true })) page?: number | |
| ) { |
| tags: settings.tags.map((tag: any) => ({ | ||
| slug: tags.find((t) => t.objectID === tag.value)?.slug, | ||
| })), |
There was a problem hiding this comment.
If a tag is not found in the local tags array, tags.find(...) will return undefined, resulting in { slug: undefined }. Passing undefined slugs to the Hashnode GraphQL API can cause validation or bad request errors. It is safer to filter out any tags that could not be mapped to a valid slug.
tags: settings.tags
.map((tag: any) => {
const found = tags.find((t) => t.objectID === tag.value);
return found ? { slug: found.slug } : null;
})
.filter((t: any) => t),| const hasVideo = posts?.some((post) => | ||
| post?.some((item) => (item?.path?.indexOf?.('mp4') ?? -1) > -1) | ||
| ); |
There was a problem hiding this comment.
The video check only looks for the 'mp4' substring. If a user uploads a video with a different extension (such as .mov or .webm), it will bypass this check and cause Slack's chat.postMessage to fail. Broaden the check to include other common video extensions.
| const hasVideo = posts?.some((post) => | |
| post?.some((item) => (item?.path?.indexOf?.('mp4') ?? -1) > -1) | |
| ); | |
| const hasVideo = posts?.some((post) => | |
| post?.some((item) => { | |
| const path = item?.path?.toLowerCase() || ''; | |
| return path.includes('mp4') || path.includes('mov') || path.includes('webm') || path.includes('avi'); | |
| }) | |
| ); |
What kind of change does this PR introduce?
Release promotion: dev -> main. Ships the upstream sync (75 commits of provider error-mapping fixes, clipping workflow stabilization), the DOS billing owner guard (PR #53), the auth UX fixes (PR #55: SSO auto-redirect + honest error page), and the deps/compiler fixes (#57).
Why was this change needed?
Ships the guarded billing sync (free-plan member logins can no longer wipe paid org subscriptions - the 2026-09-22 incident vector) and the auth UX fixes to production.
Technical Details & Scope
Full dev history since the last promote: PRs #52-#57 plus upstream sync. No new code authored here.
Verification & Testing
All constituent PRs CI-gated (build.yml bootstrap suites + full build + branding guard + CodeQL) and reviewed. Auth login verified live against beta (e2e suite) and prod (post-incident script). Owner-guard verified by unit suite + reviewer walk.
QA
Checklist: