Skip to content

chore: sync with upstream Postiz (2026-09-22 evening) - #59

Merged
JOY (JOY) merged 78 commits into
devfrom
upstream-sync-20260922
Sep 23, 2026
Merged

JOY (JOY) merged 78 commits into
devfrom
upstream-sync-20260922

Conversation

@JOY

Copy link
Copy Markdown

What kind of change does this PR introduce?

CI/CD / Upstream sync. Manually completed sync merging upstream gitroomhq/postiz-app main (75 commits, head 4c33d52) into dev: provider error-mapping fixes (dribbble, X, lemmy, VK), X unauthorized-refresh-token fix, YouTube clipping workflow stabilization, Mastra/MCP updates.

Five conflicts resolved by hand:

  • .env.example - kept the fork Temporal section, accepted upstream additions
  • libraries/nestjs-libraries/src/chat/load.tools.service.ts + start.mcp.ts - kept fork branded tool aliases (crove_post_*), accepted upstream clipping tool additions
  • libraries/nestjs-libraries/src/database/prisma/organizations/organization.repository.ts - kept BOTH imports (fork isCroveBillingGated + upstream makeSecureId)
  • libraries/nestjs-libraries/src/integrations/social/reddit.provider.ts - kept the fork's Reddit OAuth broker flow entirely (upstream's direct-Reddit change rejected); restored the makeId import the merge had dropped
  • apps/frontend/src/components/public-api/public.component.tsx + onboarding/onboarding.modal.tsx - kept fork's brandConfig fail-closed connector gating, REMOVED the upstream mcpConnectorUrls table (hardcoded claude.ai/directory/postiz, chatgpt plugins, cursor marketplace, x.ai bot URLs - upstream cloud routing, flagged by branding guard)

Why was this change needed?

Keep the upstream corridor current: 75 commits of provider fixes, clipping workflow stabilization and security hardening flow in small reviewable increments (per ADR-0001).

Technical Details & Scope

  • Merge commit ff8e69a + follow-up resolution commits on the branch
  • Branding guard passes clean: 0 strict leaks (the mcpConnectorUrls upstream URL table was removed as part of the resolution)
  • Backend nest build verified green locally (0 TS errors)

Verification & Testing

  • Zero conflict markers repo-wide (git grep)
  • pnpm run build:backend: compiles clean
  • branding-guard.ts: PASS (0 strict leaks)
  • CI on this PR runs build.yml (bootstrap suites + full build) + branding-guard

QA

  1. Open the PR Files changed - confirm the 5 resolved files keep fork branding/behavior (branded tool aliases, broker reddit flow, fail-closed connector cards)
  2. CI: build.yml green (bootstrap suites 53 passing, full build)
  3. branding-guard.yml green on this PR
  4. After deploy: schedule a post on one connected channel on beta to confirm the provider error-mapping changes did not regress posting

Checklist:

  • My code follows the project's code style and architectural conventions.
  • Local verification done: backend build clean before push.
  • Branding guard validated locally (PASS, 0 strict leaks) + CI gate.
  • Tests - CI bootstrap suites are the gate.
  • Documentation n/a (sync).
  • No secrets or sensitive credentials are included in this PR.
  • I have filled in the QA / Verification section above with real steps to verify this change.

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

@dos dos 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 completed (44 files · 102,084 chars · 3 PR unit(s))

ℹ️ Full-Context Analysis: Analyzed all changed files in a unified context pass to preserve cross-file type definitions, imports, and caller contracts. Deducted 3 PR units.
⚠️ PR diff exceeded maximum review ceiling (3 batches / ~90,000 chars) - lower-priority files were skipped.

⏱️ Adversarial Review completed (Model: qwen3.8-27b)

🔍 Verified Adversarial Review Findings

📋 Findings Summary (1 inline finding)

  • 🟡 IMPORTANT libraries/nestjs-libraries/src/integrations/social/hashnode.provider.ts:177-180: Tag mapping produces undefined slug for unknown tags (💡 1-click suggestion on diff)

💡 1-Click Suggestions Ready: Go to the Files changed tab to review and apply 1 suggestion directly with 1-click commit.

🛡️ Dismissed Claims

  • Hashnode cover image field change (coverImage vs coverImageOptions): The diff changes the endpoint from gql.hashnode.com to gql-beta.hashnode.com. The gql-beta endpoint is the newer API version which uses the coverImage string field directly, whereas the older gql endpoint used coverImageOptions. Since the endpoint and the field name are changed together to match the new API contract, this is a valid migration, not a bug.

Comment on lines 177 to +180
? { originalArticleURL: settings.canonical }
: {}),
contentMarkdown: postDetails?.[0].message,
tags: settings.tags.map((tag: any) => ({ id: tag.value })),
tags: settings.tags.map((tag: any) => ({

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 IMPORTANT: Tag mapping produces undefined slug for unknown tags

Failure Trace:

  1. User selects a tag in the UI. The tag.value is an objectID string.
    2. The code executes tags.find((t) => t.objectID === tag.value)?.slug.
    3. If tag.value does not match any objectID in the static tags array (e.g., due to a mismatch, a new tag added to Hashnode not yet in the local list, or a typo), find returns undefined.
    4. The resulting object is { slug: undefined }.
    5. This object is included in the tags array of the GraphQL mutation payload.
    6. When sent to the Hashnode API, the undefined value is either stripped (resulting in an empty object {} which may fail validation) or causes a GraphQL validation error because slug is required. This breaks post creation or results in tags being silently dropped.
Suggested change
? { originalArticleURL: settings.canonical }
: {}),
contentMarkdown: postDetails?.[0].message,
tags: settings.tags.map((tag: any) => ({ id: tag.value })),
tags: settings.tags.map((tag: any) => ({
tags: settings.tags
.map((tag: any) => ({
slug: tags.find((t) => t.objectID === tag.value)?.slug,
}))
.filter((t) => t.slug),

@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 enhancements, including the integration of clipping tools, the addition of a comprehensive Postiz Cloud vs. Open-source comparison in the README, and the implementation of a cryptographically secure makeSecureId utility to replace makeId for sensitive tokens and credentials across multiple social providers. It also strengthens error handling for various social integrations (such as Facebook, Lemmy, Slack, and X), refines tag deletion in the database, and adds support for both .mov and .mp4 video uploads. The code review feedback highlights three key areas for improvement: first, the page query parameter in public.integrations.controller.ts should be explicitly parsed as an integer using NestJS pipes to prevent runtime type mismatches; second, in reddit.provider.ts, a safety check should be added to ensure about.data is defined before accessing its nested properties to avoid potential crashes; and third, in hashnode.provider.ts, undefined slugs should be filtered out during tag mapping to prevent sending empty objects that would fail Hashnode's GraphQL validation.

@Get('/clipping')
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.

high

The page query parameter is typed as number, but without a transformation pipe or explicit parsing, NestJS will receive it as a string at runtime (or undefined if not provided). This can cause runtime errors or unexpected behavior (like NaN offset calculations) when passed to the database service. Consider using ParseIntPipe along with DefaultValuePipe to ensure it is always a valid integer. Note: You will need to import ParseIntPipe and DefaultValuePipe from @nestjs/common.

Suggested change
@Query('page') page: number
@Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number

Comment on lines +847 to +851
if (
about?.kind !== 't5' ||
about.data.subreddit_type !== 'public' ||
about.data.submission_type === 'image'
) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

If the about response is returned but the data property is missing or undefined, accessing about.data.subreddit_type will throw a TypeError: Cannot read properties of undefined (reading 'subreddit_type') and crash the execution. You should safely guard against a missing data object before accessing its nested properties.

Suggested change
if (
about?.kind !== 't5' ||
about.data.subreddit_type !== 'public' ||
about.data.submission_type === 'image'
) {
if (
about?.kind !== 't5' ||
!about?.data ||
about.data.subreddit_type !== 'public' ||
about.data.submission_type === 'image'
) {

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 tags.find returns undefined (i.e., a tag value is not found in the predefined tags array), mapping it directly will result in { slug: undefined }. When serialized to JSON, this becomes {}. Hashnode's GraphQL API expects a non-empty slug or id for each tag input, so sending an empty object will trigger a validation error. It is safer to filter out any undefined slugs before mapping them.

Suggested change
tags: settings.tags.map((tag: any) => ({
slug: tags.find((t) => t.objectID === tag.value)?.slug,
})),
tags: settings.tags
.map((tag: any) => tags.find((t) => t.objectID === tag.value)?.slug)
.filter((slug): slug is string => !!slug)
.map((slug) => ({ slug })),

@dos dos 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 completed (44 files · 101,935 chars · 3 PR unit(s))

ℹ️ Full-Context Analysis: Analyzed all changed files in a unified context pass to preserve cross-file type definitions, imports, and caller contracts. Deducted 3 PR units.
⚠️ PR diff exceeded maximum review ceiling (3 batches / ~90,000 chars) - lower-priority files were skipped.

⏱️ Adversarial Review completed (Model: qwen3.8-27b)

🔍 Verified Adversarial Review Findings

📋 Findings Summary (1 inline finding)

  • 🟡 IMPORTANT libraries/nestjs-libraries/src/integrations/social/hashnode.provider.ts:180: Unsafe tag mapping produces undefined slug (💡 1-click suggestion on diff)

💡 1-Click Suggestions Ready: Go to the Files changed tab to review and apply 1 suggestion directly with 1-click commit.

🛡️ Dismissed Claims

  • Hashnode cover image field name change: The change from coverImageOptions: { coverImageURL: ... } to coverImage: ... is a deliberate API contract update. The diff also updates the endpoint from gql.hashnode.com to gql-beta.hashnode.com, indicating a migration to a new API version where the schema has changed. Without evidence that the new endpoint still expects the old field name, this is not a bug but an intentional adaptation to the new API.

: {}),
contentMarkdown: postDetails?.[0].message,
tags: settings.tags.map((tag: any) => ({ id: tag.value })),
tags: settings.tags.map((tag: any) => ({

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 IMPORTANT: Unsafe tag mapping produces undefined slug

Failure Trace:

  1. User creates a post with a tag where tag.value is an ID not present in the static tags array (e.g., a new tag added to Hashnode after the static list was generated, or a mismatch in ID format).
    2. tags.find((t) => t.objectID === tag.value) returns undefined.
    3. ?.slug evaluates to undefined.
    4. The resulting object is { slug: undefined }.
    5. When jsonToGraphQLQuery constructs the mutation, it serializes this as { slug: null } or omits the field depending on configuration, but typically sends null for undefined values in object literals.
    6. The Hashnode GraphQL API rejects the mutation with a validation error (e.g., "Field slug of type String! was not provided") or silently fails to apply the tag, whereas the previous code sent the raw ID which might have been handled differently or failed more explicitly.
Suggested change
tags: settings.tags.map((tag: any) => ({
tags: settings.tags
.map((tag: any) => {
const found = tags.find((t) => t.objectID === tag.value);
return found ? { slug: found.slug } : null;
})
.filter(Boolean),

@JOY
JOY (JOY) merged commit 47e5aca into dev Sep 23, 2026
10 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