chore(sync): merge upstream main (Mastra 1.67, CopilotKit 1.72, MCP ChatGPT app, provider fixes) - #40
Conversation
… missing insights edge
postAnalytics always called /{postId}/insights, but that edge only exists on
page feed posts. Video/reel and story posts store a bare id whose node has no
insights edge, so Facebook returned "(#100) Tried accessing nonexisting field
(insights)" — surfaced in prod as "Error fetching Facebook post analytics:
ApplicationFailure: Unknown Error".
postAnalytics now branches on the stored releaseId shape (the only available
discriminator, since no post type is persisted for analytics):
- Feed post ({pageid}_{postid}, contains "_") -> unchanged: /{postId}/insights
with the existing metrics/mapping. No regression.
- Video/reel (bare numeric id) -> /{videoId}/video_insights on v23.0, mapped to
AnalyticsData: total_video_impressions -> Impressions, total_video_views ->
Views, total_video_reactions_by_type_total -> Reactions (metric names verified
against the current v23.0 docs).
- Story (bare id, no usable insights via this path) -> clean [], no error.
The video call uses plain fetch (not this.fetch) so a "(#100) nonexisting field
(video_insights)" / empty response yields a quiet [] instead of throwing. A
"nonexisting field" error stays silent (expected for stories); any other error
(bad metric name, token, permissions) is logged via console.warn so a silent
empty result is diagnosable without breaking the statistics page. postAnalytics
still never throws.
Tested locally against a freshly connected Facebook page: published a video post,
confirmed its releaseId resolves to a real video node, and that
/{videoId}/video_insights returns {"data":[]} (no data yet) with NO
"(#100) nonexisting field" or "must be one of" error — i.e. the old crash is gone,
the metric names are valid, and the app returns a clean [] for the no-data-yet case.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
… hijack the personal channel Co-Authored-By: Claude Fable 5 <[email protected]>
…fetime_metrics postAnalytics requests IMPRESSION, PIN_CLICK, OUTBOUND_CLICK and SAVE, but read the results from lifetime_metrics. Pinterest returns period metrics in summary_metrics; lifetime_metrics only ever carries lifetime metric types (TOTAL_COMMENTS / TOTAL_REACTIONS), and a request cannot mix lifetime and non-lifetime types. So lifetime_metrics was always empty and every Pinterest pin showed no stats. Verified against the live API: published a real pin, pre-fix the method returned [], post-fix it returns all four metrics. Co-Authored-By: Claude Fable 5 <[email protected]>
…image sizes TikTok returns fail_reason `picture_size_check_failed` when media violates its size rules, and we mapped it to "Video must be at least 720p, Picture must no exceed 1080p". Both halves were wrong or unhelpful: TikTok documents no minimum for photos, "1080p" never said which dimension, and the 720p video claim has no basis in the docs. A customer hit this on TikTok carousels created through the public API. Her images were 941x1672 (valid), but each failing carousel also contained a 1086x1448 image - 6px over the 1080 limit on the shorter side - and a single oversized image fails the entire carousel. TikTok never reports which image failed, so the generic message sent her resizing images that were already fine. Verified against the real API on a connected TikTok channel, uploading through the public API so the media reaches TikTok at its original dimensions: 480x640 -> PUBLISHED (disproves the "at least 720p" claim) 320x320 -> FAILED (picture_size_check_failed, confirms the 360 floor) 5000x5000 -> PUBLISHED (TikTok does not enforce its documented 4096 max) The message therefore claims only what was verified: the 1080 image ceiling and the 360 video floor. The documented 4096 ceiling is omitted because TikTok accepted a 5000x5000 video. `checkValidity` now measures photos up front and names the offending image, so the failure surfaces at save time instead of hours later at publish. It mirrors the existing Pinterest check and reuses `getImageDimensions`. Videos are not pre-checked: there is no ffmpeg dependency server-side, so video size still only surfaces at publish through the corrected message. Already-scheduled carousels are not re-validated - `checkValidity` only runs on create/update - so existing invalid posts will still fail at publish, now with the corrected message. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
…connection A Facebook channel can end up holding the user's token instead of the page's: if the page is only visible through a Business portfolio and was never ticked in the OAuth page list, Graph returns no access_token for it, fetchPageInformation passes undefined to the update, and Prisma leaves the between-steps user token in place. The channel then looks connected while every publish is rejected with "(gitroomhq#200) Unpublished posts must be posted to a page as the page itself". That error was unmapped, so it surfaced as a generic "Unknown Error" on every post while the channel kept reporting as healthy - the user had no way to tell a token problem from a content problem. Map it to refresh-token so the channel is flagged, the reconnect notification is sent, and the post is not retried against a token that can never work. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
…sage Facebook rejects page publishes with an OAuthException code 200 when the connected account lacks pages_manage_posts / pages_read_engagement or sufficient page role. This previously fell through handleErrors to the 'Unknown Error' placeholder, so the failure email (and, once merged, the curated calendar tooltip from gitroomhq#1868) showed nothing actionable. Per Meta's docs, codes 200-299 are API Permission errors, so the (gitroomhq#200) marker always denotes a permissions problem. As a side effect, the preset retry helper no longer treats these as 'Unknown Error', avoiding a wasted second publish attempt. Co-Authored-By: Claude Fable 5 <[email protected]>
…ssage too The same Graph (gitroomhq#200) OAuthException surfaces on the Instagram (Facebook-login) provider when the connected user lacks sufficient permissions on the Facebook Page linked to the Instagram account. Map it the same way as in the Facebook provider (non-retryable bad-body), placed before the generic '190,' rule; instagram-standalone delegates to the same handleErrors. Co-Authored-By: Claude Fable 5 <[email protected]>
The Reddit DTO advertised type as link/self/image/video/videogif, but the provider only implements self, link and media (upload the first attached file). Other values were forwarded to Reddit as kind without a file and always failed with BAD_URL. Validate type with @isin and fix the public API / MCP schema description. Also initialise url to '' when a subreddit is picked in the composer, so the URL input is controlled from mount (fixes the React uncontrolled to controlled warning) and empty link URLs are validated. Co-Authored-By: Claude Fable 5 <[email protected]>
…ling
After 5 failed preparation attempts the provider threw a generic
"Could not prepare the post for Bluesky" BadBody with an empty json
payload, swallowing the real reason (e.g. the video service rejecting
an oversized upload). Include the underlying error message in both the
BadBody json ({"message": ...}) and the user-facing failure message,
so the bell notification and failure email tell the user what actually
went wrong.
Co-Authored-By: Claude Fable 5 <[email protected]>
The subreddit picker stores Reddit's search result url verbatim
("/r/Name/"), and the sr normalization only removed the "/r/" prefix,
so submits were sent with a trailing slash ("name/"). Reddit accepts
that shape intermittently but sometimes rejects it with
SUBREDDIT_NOEXIST even though the community exists. Strip leading and
trailing slashes at both normalization sites.
Co-Authored-By: Claude Fable 5 <[email protected]>
Public-API users sometimes type "r/name" without the leading slash, which the '/r/' string replace left untouched. Normalize both prefix shapes with one regex so every input form resolves to the bare name. Co-Authored-By: Claude Fable 5 <[email protected]>
Meta's Threads API counts the 500-character post limit in UTF-8 bytes (https://developers.facebook.com/docs/threads/posts), while Postiz counted UTF-16 code units, so a post with curly quotes, accented letters or emoji could pass the editor counter and the server validation and then be rejected by Threads at publish time. A thread whose second part was affected published only its first part. Adds a countLength helper (X keeps twitter-text weighted length, Threads uses UTF-8 byte length, everything else plain length) and uses it in the /posts/valid check and in the editor character pill, so both sides flag the same text. The global-mode pill now shows the count for the channel whose limit it displays instead of the raw length. Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01TEjfd79TJocNAFULswHkXV
Adds activeOrgsBySource to /admin/stats (distinct orgs with a QUEUE, PUBLISHED or ERROR post in the range, grouped by Post.creationMethod) and renders it as a summary card and table on the admin stats page.
…in state Neynar retired Sign In With Neynar, so the hosted popup used for Farcaster login and channel connect no longer completes. Replace it with Neynar managed signers: the backend creates a signer, signs the EIP-712 signed key request with the app custody mnemonic (viem), registers it with Neynar and renders the approval deep link as a QR (qrcode) since the link is a bare 302 to the farcaster:// scheme. The frontend polls the new public /auth/farcaster/signer routes and builds the same base64 blob the popup produced, so authenticate(), the auth provider, the stored token format and existing users/channels are unchanged. @neynar/react is removed. Also thread the oauth_state nonce through the Farcaster login button: since the Apple login change, checkExists rejects logins without a matching state cookie, which broke Farcaster login independently of the SIWN retirement. New env vars: NEYNAR_APP_FID, NEYNAR_APP_MNEMONIC, NEYNAR_SPONSOR_SIGNERS. Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01R8gvBNgPqqayC9zGPB5R5a
…of hardcoded v23.0 Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01B31McizpPZ7MhPdHSmbpt2
A thrown fetch inside the async generator ended the for-await loop and left the modal spinning with no timeout. Catch it and keep polling, as the Moltbook status poll does; the 10 minute cap still applies. Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01R8gvBNgPqqayC9zGPB5R5a
POST /auth/farcaster/signer is unauthenticated and creates a signer at Neynar on every call. The global throttler guard only covers the public posts API, so add a route-level guard keyed by the forwarded client address and cap the route at 10 requests per hour per address. Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01R8gvBNgPqqayC9zGPB5R5a
GET /auth/farcaster/signer is unauthenticated and calls Neynar on every request. Apply the same per-address guard as the create route, with a ceiling of 1000 per hour so the modal's 2s poll over a 10 minute window still fits several approvals. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01R8gvBNgPqqayC9zGPB5R5a
…ed-signers feat(farcaster): migrate from SIWN to Neynar managed signers, fix login state
After the managed-signer migration, clicking Farcaster on the login and signup pages opened a new tab to the approval link, which only redirects to the farcaster:// scheme and stays blank on desktop, while the QR was squeezed into the narrow provider button slot. The approval view now opens straight in a modal on the login and signup pages and replaces the intermediate button step in the add/reconnect channel modal. No tab opens; a Copy Farcaster link button replaces the Open in Farcaster link. The auth layout mounts the shared modal manager and the toaster, so errors on those pages are visible. A response without an approval link (for example the rate limit) now shows the failure toaster instead of leaving the modal empty, and the signer creation limit is raised from 10 to 30 per hour per address. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01R8gvBNgPqqayC9zGPB5R5a
fix(farcaster): show the approval QR in a modal instead of a blank tab
…nsights fix(facebook): video/reel analytics via video_insights (no more nonexisting-field crash)
fix: count Threads post length in UTF-8 bytes
…stats feat(admin-stats): active orgs per post creation source
…jacks-personal-channel fix(linkedin-page): connecting a company page hijacks the personal LinkedIn channel
@mastra/pg checks for a column against a schema snapshot and then runs a plain ALTER TABLE ADD COLUMN, so instances migrating at the same time race and the loser throws 'column "organizationId" ... already exists' into the first user request. Init now runs when the Mastra instance is created and retries once against the already migrated schema. Co-Authored-By: Claude Fable 5.1 <[email protected]>
prisma-db-push runs with --accept-data-loss on deploy, so anything Mastra created that the schema did not mirror (37 tables, 31 columns) was dropped and rebuilt by @mastra/pg on the next boot, which is where instances raced on ALTER TABLE ADD COLUMN. The mastra_* models are now introspected from a database migrated by @mastra/pg 1.25, so db push creates them before the backend starts and Mastra's init finds nothing to alter. Existing mirrored columns are unchanged; migrate diff against a migrated DB is empty. Co-Authored-By: Claude Fable 5.1 <[email protected]>
The mastra_* tables are now mirrored in schema.prisma, so prisma db push creates them before the backend starts and the init race cannot happen. Co-Authored-By: Claude Fable 5.1 <[email protected]>
…atGPT MCP has no way to send a file from the user's device, so add an MCP Apps (SEP-1865) widget: uploadWidgetTool renders ui://postiz/upload, the widget gets a short-lived ticket through the app-only uploadWidgetTicketTool and posts the file to /media-widget/upload, then reports the media back to the model. uploadWidgetStatusTool is the host-independent fallback. The widget tools are mcpOnly, so the in-app agent keeps its current toolset. Co-Authored-By: Claude Fable 5.1 <[email protected]>
feat(mcp): upload widget for uploading local files from Claude and ChatGPT
…rsonate feat: superuser public API endpoints
…include-deleted header
…hatGPT app, OAuth hardening, provider fixes) Conflict resolutions: - .env.example: keep Crove rebranded reference (incl. DOS billing block) - staging-conflicts.yml: keep deleted (upstream-only staging infra) - wallet.provider.tsx: keep deleted (Solana dead-code removal, PR #34) - copilot.controller: upstream NodeHttp endpoint + copilotCors + new CopilotKit request shape, plus our OpenAI-compatible env support - oauth.controller: keep our revoke endpoint + their client_secret_basic - auth layout: our branding/i18n + their MantineWrapper/Toaster - load.tools.service: their mcpOnly filter + AgentToolInterface, our DOSClaw alias map and branded agent - start.mcp: both (brand + MCP Apps upload widget) - sentry: their tracesSampler/profiling quota fix + our bootstrap telemetry scrubbers - schema.prisma: keep fork architecture (Mastra owns its tables; no prisma mirror) - package.json/lockfile: 3-way semantic merge + regenerated lockfile
| window.location.href = `/auth?provider=FARCASTER&code=${code}`; | ||
| const state = await (await fetch('/auth/oauth/FARCASTER')).text(); | ||
| window.location.href = `/auth?provider=FARCASTER&code=${encodeURIComponent(code)}&state=${state}`; | ||
| }, []); |
| t('link_copied_to_clipboard', 'Link copied to clipboard'), | ||
| 'success' | ||
| ); | ||
| }, [approvalUrl]); |
| return () => { | ||
| activeSigner.current = ''; | ||
| }; | ||
| }, []); |
| <img | ||
| src={qrCode} | ||
| alt="" | ||
| className="w-[200px] h-[200px] rounded-[8px] bg-white" | ||
| /> |
There was a problem hiding this comment.
Code Review
This pull request introduces a media normalization and processing pipeline using RunPod and Temporal workflows, adds an MCP upload widget, updates the Farcaster signer flow, and implements various bug fixes and optimizations across social providers (Facebook, Instagram, TikTok, Bluesky, Pinterest, and Reddit). The review feedback highlights a critical git merge conflict marker left in the code, potential unhandled exceptions during JSON parsing and handle-stripping, duplicate React key warnings in the customer modal, potential undefined values in CORS origins, and a missing UUID format validation for organization overrides in the authentication middleware.
| const directTools = ( | ||
| >>>>>>> upstream/main | ||
| await Promise.all<{ name: string; tool: any }>( |
There was a problem hiding this comment.
A git merge conflict marker (>>>>>>> upstream/main) was accidentally left in the code. This will cause a syntax/compilation error and must be removed.
| const directTools = ( | |
| >>>>>>> upstream/main | |
| await Promise.all<{ name: string; tool: any }>( | |
| const directTools = ( | |
| await Promise.all<{ name: string; tool: any }>( | |
| const webHosts: string[] = []; | ||
| for (const uri of JSON.parse(app.redirectUris || '[]') as string[]) { |
There was a problem hiding this comment.
If app.redirectUris contains malformed or invalid JSON, JSON.parse will throw an unhandled exception and crash the request with a 500 error. It is safer to wrap the parsing in a try-catch block to handle this gracefully.
let uris: string[] = [];
try {
uris = JSON.parse(app.redirectUris || '[]') as string[];
} catch {
return false;
}
const webHosts: string[] = [];
for (const uri of uris) {| private stripHandle(handle: string) { | ||
| return handle.replace(/^@+/, ''); | ||
| } |
There was a problem hiding this comment.
If handle is undefined or null, calling handle.replace will throw a TypeError and crash the execution. It is safer to default handle to an empty string or check its type before calling .replace.
| private stripHandle(handle: string) { | |
| return handle.replace(/^@+/, ''); | |
| } | |
| private stripHandle(handle?: string) { | |
| return (handle || '').replace(/^@+/, ''); | |
| } |
| <datalist id="customer-name-options"> | ||
| {(data?.map((p: any) => p.name) || []).map((name: string) => ( | ||
| <option key={name} value={name} /> | ||
| ))} | ||
| </datalist> |
There was a problem hiding this comment.
Using name as the React key can lead to duplicate key warnings and rendering issues if multiple customers share the same name. It is safer to map over the original data array and use the customer's unique id (or the array index as a fallback) for the key.
| <datalist id="customer-name-options"> | |
| {(data?.map((p: any) => p.name) || []).map((name: string) => ( | |
| <option key={name} value={name} /> | |
| ))} | |
| </datalist> | |
| <datalist id="customer-name-options"> | |
| {(data || []).map((p: any, index: number) => ( | |
| <option key={p.id || index} value={p.name} /> | |
| ))} | |
| </datalist> |
| origin: [ | ||
| process.env.FRONTEND_URL, | ||
| 'http://localhost:6274', | ||
| ...(process.env.MAIN_URL ? [process.env.MAIN_URL] : []), | ||
| ], |
There was a problem hiding this comment.
If process.env.FRONTEND_URL is undefined, the origin array will contain undefined, which can cause issues or runtime errors in some CORS libraries. It is safer to filter out any falsy values from the origins array.
| origin: [ | |
| process.env.FRONTEND_URL, | |
| 'http://localhost:6274', | |
| ...(process.env.MAIN_URL ? [process.env.MAIN_URL] : []), | |
| ], | |
| origin: [ | |
| process.env.FRONTEND_URL, | |
| 'http://localhost:6274', | |
| process.env.MAIN_URL, | |
| ].filter(Boolean) as string[], |
| if (overrideOrgId) { | ||
| if ( | ||
| isOAuthApp || | ||
| !(await this._organizationService.canUseSuperAdminApi(org.id)) | ||
| ) { | ||
| res.status(HttpStatus.FORBIDDEN).json({ msg: 'Unauthorized' }); | ||
| return; | ||
| } | ||
|
|
||
| const overrideOrg = | ||
| await this._organizationService.getOrgByIdWithSubscription( | ||
| overrideOrgId | ||
| ); |
There was a problem hiding this comment.
If overrideOrgId is not a valid UUID format, passing it to the database query inside getOrgByIdWithSubscription will cause Prisma to throw an inconsistent column data exception, resulting in an unhandled 500 Internal Server Error. It is safer to validate that overrideOrgId is a valid UUID before querying the database.
if (overrideOrgId) {
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
if (!uuidRegex.test(overrideOrgId)) {
res.status(HttpStatus.BAD_REQUEST).json({ msg: 'Invalid organization ID format' });
return;
}
if (
isOAuthApp ||
!(await this._organizationService.canUseSuperAdminApi(org.id))
) {
res.status(HttpStatus.FORBIDDEN).json({ msg: 'Unauthorized' });
return;
}
const overrideOrg =
await this._organizationService.getOrgByIdWithSubscription(
overrideOrgId
);…us fields - load.tools.service: drop stray >>>>>>> marker left by resolution - start.mcp: dedupe agents map (keep branded 3-alias version) - schema.prisma: take upstream Media status/processingError fields (required by the MCP upload widget pipeline) while keeping the no-Mastra-tables fork architecture
Merge rationale for 2 failing code-scanning checksCodeQL — 1 critical (SSRF) at ESLint — 3 errors in Both checks are code-scanning surfaces, not build/test gates — Build, ESLint Analysis (both), Analyze, Branding Guard and CodeQL build steps are green. The build has compiled the entire merged tree. |
Summary
90 upstream commits since the last sync:
Conflict resolutions