Skip to content

promote: dev -> main (upstream sync 2026-09-22 + auth/billing guards) - #60

Merged
JOY (JOY) merged 83 commits into
mainfrom
dev
Sep 23, 2026
Merged

JOY (JOY) merged 83 commits into
mainfrom
dev

Conversation

@JOY

Copy link
Copy Markdown

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

  1. CI green on this PR
  2. Merge, wait for build-containers to publish the new :latest image
  3. Deploy on the VM, verify healthcheck + login + subscriptions intact

Checklist:

  • My code follows the project's code style and architectural conventions.
  • Local verification done - all constituent PRs CI-gated and reviewed.
  • Branding guard - CI gate on this PR.
  • Tests - dev carries the new suites.
  • Documentation updated in constituent PRs.
  • No secrets included.
  • QA section filled with real steps.

…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)

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +436 to +439
getClippings(
@GetOrgFromRequest() org: Organization,
@Query('page') page: number
) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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).

Suggested change
getClippings(
@GetOrgFromRequest() org: Organization,
@Query('page') page: number
) {
getClippings(
@GetOrgFromRequest() org: Organization,
@Query('page', new ParseIntPipe({ optional: true })) page?: number
) {

Comment on lines +180 to +182
tags: settings.tags.map((tag: any) => ({
slug: tags.find((t) => t.objectID === tag.value)?.slug,
})),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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),

Comment on lines +40 to +42
const hasVideo = posts?.some((post) =>
post?.some((item) => (item?.path?.indexOf?.('mp4') ?? -1) > -1)
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.

Suggested change
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');
})
);

@JOY
JOY (JOY) merged commit 0a9f80d into main Sep 23, 2026
18 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants