From 8d81cacdc6299a78c74baf096702f8d4af3bd0c9 Mon Sep 17 00:00:00 2001 From: Gilad Resisi Date: Mon, 3 Aug 2026 09:41:37 +0700 Subject: [PATCH 01/45] fix(tags): clean up post assignments on tag deletion and stop matching 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 --- .../database/prisma/posts/posts.repository.ts | 33 +++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/libraries/nestjs-libraries/src/database/prisma/posts/posts.repository.ts b/libraries/nestjs-libraries/src/database/prisma/posts/posts.repository.ts index 2a3b2b2059..ef80e212c5 100644 --- a/libraries/nestjs-libraries/src/database/prisma/posts/posts.repository.ts +++ b/libraries/nestjs-libraries/src/database/prisma/posts/posts.repository.ts @@ -182,6 +182,11 @@ export class PostsRepository { group: true, creationMethod: true, tags: { + where: { + tag: { + deletedAt: null, + }, + }, select: { tag: true, }, @@ -294,6 +299,11 @@ export class PostsRepository { group: true, creationMethod: true, tags: { + where: { + tag: { + deletedAt: null, + }, + }, select: { tag: true, }, @@ -353,6 +363,11 @@ export class PostsRepository { include: { integration: true, tags: { + where: { + tag: { + deletedAt: null, + }, + }, select: { tag: true, }, @@ -378,6 +393,11 @@ export class PostsRepository { ? { integration: true, tags: { + where: { + tag: { + deletedAt: null, + }, + }, select: { tag: true, }, @@ -598,6 +618,7 @@ export class PostsRepository { const tagsList = await this._tags.model.tags.findMany({ where: { orgId: orgId, + deletedAt: null, name: { in: tags.map((tag) => tag.label).filter((f) => f), }, @@ -838,8 +859,8 @@ export class PostsRepository { }); } - deleteTag(id: string, orgId: string) { - return this._tags.model.tags.update({ + async deleteTag(id: string, orgId: string) { + const tag = await this._tags.model.tags.update({ where: { id, orgId, @@ -848,6 +869,14 @@ export class PostsRepository { deletedAt: new Date(), }, }); + + await this._tagsPosts.model.tagsPosts.deleteMany({ + where: { + tagId: tag.id, + }, + }); + + return tag; } createComment( From 74ac26b8ec262efdf5277ff4e57ad869a1adbcb7 Mon Sep 17 00:00:00 2001 From: Gilad Resisi Date: Fri, 18 Sep 2026 14:02:33 +0700 Subject: [PATCH 02/45] fix(facebook): surface Graph API errors in page analytics instead of 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 --- .../src/integrations/social/facebook.provider.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/libraries/nestjs-libraries/src/integrations/social/facebook.provider.ts b/libraries/nestjs-libraries/src/integrations/social/facebook.provider.ts index e009bd65b6..bbf18a82d9 100644 --- a/libraries/nestjs-libraries/src/integrations/social/facebook.provider.ts +++ b/libraries/nestjs-libraries/src/integrations/social/facebook.provider.ts @@ -985,12 +985,18 @@ export class FacebookProvider extends SocialAbstract implements SocialProvider { // require Graph API v23.0+: // - page_total_media_view_unique: total unique views on the page's media (reach) // - page_media_view: total media views, broken down between paid and organic - const { data } = await ( + const { data, error } = await ( await fetch( `https://graph.facebook.com/${META_GRAPH_API_VERSION}/${id}/insights?metric=page_total_media_view_unique,page_media_view,page_post_engagements,page_daily_follows&access_token=${accessToken}&period=day&since=${since}&until=${until}` ) ).json(); + // Throw so checkAnalytics doesn't cache the empty result for an hour. + if (error) { + console.warn('Facebook page insights returned an error:', { id, error }); + throw new Error(error.message); + } + // page_media_view returns paid/organic breakdowns as an object; sum them to // keep the single-total UI working. const sumValue = (value: any): number => { From 78f8a04b5370dea7c8e46696b85e112c7fa236d7 Mon Sep 17 00:00:00 2001 From: Gilad Resisi Date: Fri, 18 Sep 2026 14:56:42 +0700 Subject: [PATCH 03/45] fix(sentry): stop tracing GET requests to the MCP streamable routes 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 #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 --- libraries/nestjs-libraries/src/sentry/initialize.sentry.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/libraries/nestjs-libraries/src/sentry/initialize.sentry.ts b/libraries/nestjs-libraries/src/sentry/initialize.sentry.ts index 20c817ac5a..67c42b9076 100644 --- a/libraries/nestjs-libraries/src/sentry/initialize.sentry.ts +++ b/libraries/nestjs-libraries/src/sentry/initialize.sentry.ts @@ -59,6 +59,13 @@ export const initializeSentry = (appName: string, allowLogs = false) => { const path = String( normalizedRequest?.url || attributes?.['http.target'] || attributes?.['url.path'] || name || '' ); + const method = String( + normalizedRequest?.method || attributes?.['http.request.method'] || attributes?.['http.method'] || '' + ); + // MCP stream GETs are declined with 405; never trace them + if (method === 'GET' && /^(https?:\/\/[^/]+)?\/mcp(\/|-oauth|\?|$)/.test(path)) { + return 0; + } return inheritOrSampleWith( path.includes('/public/v1/analytics/') ? 0.01 : 0.2 ); From 6345ca5766f97ecacdd75f6ca46956f914d622b0 Mon Sep 17 00:00:00 2001 From: Gilad Resisi Date: Mon, 21 Sep 2026 08:47:49 +0700 Subject: [PATCH 04/45] fix(gmb): use Google's searchUrl as the post release URL The release URL for every Google My Business post was a hardcoded https://business.google.com/locations/ 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 --- .../nestjs-libraries/src/integrations/social/gmb.provider.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/libraries/nestjs-libraries/src/integrations/social/gmb.provider.ts b/libraries/nestjs-libraries/src/integrations/social/gmb.provider.ts index 6f44d3f2c8..0a8c4ebeb7 100644 --- a/libraries/nestjs-libraries/src/integrations/social/gmb.provider.ts +++ b/libraries/nestjs-libraries/src/integrations/social/gmb.provider.ts @@ -508,8 +508,9 @@ export class GmbProvider extends SocialAbstract implements SocialProvider { const postId = postData.name; const locationId = id.split('/').pop(); - // GMB posts don't have direct URLs, but we can link to the business profile - const releaseURL = `https://business.google.com/locations/${locationId}`; + const releaseURL = + postData.searchUrl || + `https://business.google.com/locations/${locationId}`; return [ { From 450f0b44865f609c595187d53ecffe75f9cc9960 Mon Sep 17 00:00:00 2001 From: Enno Gelhaus Date: Mon, 21 Sep 2026 06:35:36 +0200 Subject: [PATCH 05/45] fix(sentry): reduce tracesSampleRate to 0.1 for better performance --- libraries/nestjs-libraries/src/sentry/initialize.sentry.ts | 2 +- .../src/sentry/initialize.sentry.next.basic.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/libraries/nestjs-libraries/src/sentry/initialize.sentry.ts b/libraries/nestjs-libraries/src/sentry/initialize.sentry.ts index 20c817ac5a..44f1d711fb 100644 --- a/libraries/nestjs-libraries/src/sentry/initialize.sentry.ts +++ b/libraries/nestjs-libraries/src/sentry/initialize.sentry.ts @@ -60,7 +60,7 @@ export const initializeSentry = (appName: string, allowLogs = false) => { normalizedRequest?.url || attributes?.['http.target'] || attributes?.['url.path'] || name || '' ); return inheritOrSampleWith( - path.includes('/public/v1/analytics/') ? 0.01 : 0.2 + path.includes('/public/v1/analytics/') ? 0.01 : 0.1 ); }, enableLogs: true, diff --git a/libraries/react-shared-libraries/src/sentry/initialize.sentry.next.basic.ts b/libraries/react-shared-libraries/src/sentry/initialize.sentry.next.basic.ts index 47b047e2cf..fecf778ecb 100644 --- a/libraries/react-shared-libraries/src/sentry/initialize.sentry.next.basic.ts +++ b/libraries/react-shared-libraries/src/sentry/initialize.sentry.next.basic.ts @@ -49,7 +49,7 @@ export const initializeSentryBasic = (environment: string, dsn: string, extensio sendDefaultPii: true, ...extension, debug: environment === 'development', - tracesSampleRate: 0.2, + tracesSampleRate: 0.1, beforeSend(event, hint) { if (isWalletExtensionRejection(hint?.originalException)) { From 12978bef198a330518dff48adc1581815069732a Mon Sep 17 00:00:00 2001 From: Gilad Resisi Date: Mon, 21 Sep 2026 12:44:46 +0700 Subject: [PATCH 06/45] fix(hashnode): move to the live GraphQL endpoint and current publishPost 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 #1737 Co-Authored-By: Claude Fable 5.1 --- .../integrations/social/hashnode.provider.ts | 50 ++++++++++++------- 1 file changed, 31 insertions(+), 19 deletions(-) diff --git a/libraries/nestjs-libraries/src/integrations/social/hashnode.provider.ts b/libraries/nestjs-libraries/src/integrations/social/hashnode.provider.ts index 7eeadef06b..c0808a2c2f 100644 --- a/libraries/nestjs-libraries/src/integrations/social/hashnode.provider.ts +++ b/libraries/nestjs-libraries/src/integrations/social/hashnode.provider.ts @@ -4,7 +4,10 @@ import { PostResponse, SocialProvider, } from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface'; -import { SocialAbstract } from '@gitroom/nestjs-libraries/integrations/social.abstract'; +import { + BadBody, + SocialAbstract, +} from '@gitroom/nestjs-libraries/integrations/social.abstract'; import { tags } from '@gitroom/nestjs-libraries/integrations/social/hashnode.tags'; import { jsonToGraphQLQuery } from 'json-to-graphql-query'; import { HashnodeSettingsDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/hashnode.settings.dto'; @@ -69,7 +72,7 @@ export class HashnodeProvider extends SocialAbstract implements SocialProvider { me: { name, id, profilePicture, username }, }, } = await ( - await fetch('https://gql.hashnode.com', { + await fetch('https://gql-beta.hashnode.com', { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -122,7 +125,7 @@ export class HashnodeProvider extends SocialAbstract implements SocialProvider { }, }, } = await ( - await fetch('https://gql.hashnode.com', { + await fetch('https://gql-beta.hashnode.com', { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -174,17 +177,17 @@ export class HashnodeProvider extends SocialAbstract implements SocialProvider { ? { originalArticleURL: settings.canonical } : {}), contentMarkdown: postDetails?.[0].message, - tags: settings.tags.map((tag: any) => ({ id: tag.value })), + tags: settings.tags.map((tag: any) => ({ + slug: tags.find((t) => t.objectID === tag.value)?.slug, + })), ...(settings.subtitle ? { subtitle: settings.subtitle } : {}), ...(settings.main_image ? { - coverImageOptions: { - coverImageURL: `${ - settings?.main_image?.path?.indexOf('http') === -1 - ? `${process.env.NEXT_PUBLIC_BACKEND_URL}/${process.env.NEXT_PUBLIC_UPLOAD_STATIC_DIRECTORY}` - : `` - }${settings?.main_image?.path}`, - }, + coverImage: `${ + settings?.main_image?.path?.indexOf('http') === -1 + ? `${process.env.NEXT_PUBLIC_BACKEND_URL}/${process.env.NEXT_PUBLIC_UPLOAD_STATIC_DIRECTORY}` + : `` + }${settings?.main_image?.path}`, } : {}), }, @@ -199,14 +202,8 @@ export class HashnodeProvider extends SocialAbstract implements SocialProvider { { pretty: true } ); - const { - data: { - publishPost: { - post: { id: postId, url }, - }, - }, - } = await ( - await this.fetch('https://gql.hashnode.com', { + const { data, errors } = await ( + await this.fetch('https://gql-beta.hashnode.com', { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -218,6 +215,21 @@ export class HashnodeProvider extends SocialAbstract implements SocialProvider { }) ).json(); + if (errors?.length) { + throw new BadBody( + this.identifier, + JSON.stringify(errors), + '{}', + errors[0]?.message || 'Hashnode could not publish the post' + ); + } + + const { + publishPost: { + post: { id: postId, url }, + }, + } = data; + return [ { id: postDetails?.[0].id, From 0ffdbc9773d21a53992bf4d1ea7e7e9acc394f23 Mon Sep 17 00:00:00 2001 From: Gilad Resisi Date: Mon, 21 Sep 2026 12:53:18 +0700 Subject: [PATCH 07/45] fix(hashnode): fail once when publishPost returns no post 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 --- .../src/integrations/social/hashnode.provider.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/libraries/nestjs-libraries/src/integrations/social/hashnode.provider.ts b/libraries/nestjs-libraries/src/integrations/social/hashnode.provider.ts index c0808a2c2f..e55eee8479 100644 --- a/libraries/nestjs-libraries/src/integrations/social/hashnode.provider.ts +++ b/libraries/nestjs-libraries/src/integrations/social/hashnode.provider.ts @@ -215,12 +215,12 @@ export class HashnodeProvider extends SocialAbstract implements SocialProvider { }) ).json(); - if (errors?.length) { + if (errors?.length || !data?.publishPost?.post) { throw new BadBody( this.identifier, - JSON.stringify(errors), + JSON.stringify(errors || data || {}), '{}', - errors[0]?.message || 'Hashnode could not publish the post' + errors?.[0]?.message || 'Hashnode could not publish the post' ); } From fc14ff16dfb7f6bcc9a94d382d4e1943cb7f2533 Mon Sep 17 00:00:00 2001 From: Nevo David Date: Mon, 21 Sep 2026 14:03:40 +0700 Subject: [PATCH 08/45] feat: clipping --- libraries/nestjs-libraries/src/chat/tools/clipping.tool.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/libraries/nestjs-libraries/src/chat/tools/clipping.tool.ts b/libraries/nestjs-libraries/src/chat/tools/clipping.tool.ts index 7304b741f9..3435eba5c4 100644 --- a/libraries/nestjs-libraries/src/chat/tools/clipping.tool.ts +++ b/libraries/nestjs-libraries/src/chat/tools/clipping.tool.ts @@ -38,6 +38,9 @@ export class ClippingTool implements AgentToolInterface { description: `Turn a long YouTube video into short vertical clips with burned-in captions. The best parts of the video are picked automatically, every clip is saved to the media library, and when channels are passed a draft post is created for every clip on every channel (nothing is scheduled or published). + Before calling this tool, always ask the user how the horizontal video should fill the vertical clip, and wait for the answer: + "blur" keeps the whole picture over a blurred copy of itself, "crop" fills the clip with the middle of the picture and cuts the sides away. + Never pick one for the user, unless they already said which one they want in this conversation. It uses the clipping minutes of the subscription: one minute for every minute of the source video. Clipping takes several minutes, so this only starts it and returns a clippingId: tell the user it is running. Some apps show a widget with the progress and report the finished clips in the conversation by themselves. @@ -61,9 +64,8 @@ export class ClippingTool implements AgentToolInterface { .describe('Maximum number of clips, 5 by default'), fit: z .enum(['crop', 'blur']) - .optional() .describe( - 'How the horizontal video fills the vertical clip. "blur" (default) keeps the whole picture over a blurred copy of itself and is always safe. "crop" fills the clip with the middle of the picture and cuts the sides away: there is no face tracking, so a speaker who is not in the centre is cut out of the clip. Leave this empty unless the user explicitly asks for a cropped clip, and when they do, tell them that anything outside the centre of the picture will be lost.' + 'How the horizontal video fills the vertical clip, as answered by the user: ask them before calling this tool and never guess it. "blur" keeps the whole picture over a blurred copy of itself and is always safe. "crop" fills the clip with the middle of the picture and cuts the sides away: there is no face tracking, so a speaker who is not in the centre is cut out of the clip. When asking, tell the user that with "crop" anything outside the centre of the picture will be lost.' ), }), outputSchema: z.object({ From dac36eda7bfa6fe5683e575dea9c2c9bd902c334 Mon Sep 17 00:00:00 2001 From: Nevo David Date: Mon, 21 Sep 2026 14:52:45 +0700 Subject: [PATCH 09/45] feat(public-api): clipping endpoints 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 --- .../v1/public.integrations.controller.ts | 29 ++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/apps/backend/src/public-api/routes/v1/public.integrations.controller.ts b/apps/backend/src/public-api/routes/v1/public.integrations.controller.ts index 0258beb2cf..6a64c0752e 100644 --- a/apps/backend/src/public-api/routes/v1/public.integrations.controller.ts +++ b/apps/backend/src/public-api/routes/v1/public.integrations.controller.ts @@ -33,6 +33,8 @@ import { import { VideoDto } from '@gitroom/nestjs-libraries/dtos/videos/video.dto'; import { VideoFunctionDto } from '@gitroom/nestjs-libraries/dtos/videos/video.function.dto'; import { UploadDto } from '@gitroom/nestjs-libraries/dtos/media/upload.dto'; +import { ClippingDto } from '@gitroom/nestjs-libraries/dtos/clipping/clipping.dto'; +import { ClippingService } from '@gitroom/nestjs-libraries/database/prisma/clipping/clipping.service'; import { NotificationService } from '@gitroom/nestjs-libraries/database/prisma/notifications/notification.service'; import { GetNotificationsDto } from '@gitroom/nestjs-libraries/dtos/notifications/get.notifications.dto'; import * as Sentry from '@sentry/nestjs'; @@ -65,7 +67,8 @@ export class PublicIntegrationsController { private _refreshIntegrationService: RefreshIntegrationService, private _usersService: UsersService, private _adminStatsService: AdminStatsService, - private _organizationService: OrganizationService + private _organizationService: OrganizationService, + private _clippingService: ClippingService ) {} @Post('/upload') @@ -420,6 +423,30 @@ export class PublicIntegrationsController { ); } + @Post('/clipping') + startClipping( + @GetOrgFromRequest() org: Organization, + @Body() body: ClippingDto + ) { + Sentry.metrics.count('public_api-request', 1); + return this._clippingService.startClipping(org, body); + } + + @Get('/clipping') + getClippings( + @GetOrgFromRequest() org: Organization, + @Query('page') page: number + ) { + Sentry.metrics.count('public_api-request', 1); + return this._clippingService.getClippings(org.id, page); + } + + @Get('/clipping/:id') + getClipping(@GetOrgFromRequest() org: Organization, @Param('id') id: string) { + Sentry.metrics.count('public_api-request', 1); + return this._clippingService.getClipping(org.id, id); + } + @Delete('/integrations/:id') async deleteChannel( @GetOrgFromRequest() org: Organization, From 3d3e9eee7af09749bd90282299ed31db1bc51dd9 Mon Sep 17 00:00:00 2001 From: Nevo David Date: Tue, 22 Sep 2026 09:11:31 +0700 Subject: [PATCH 10/45] feat: update chatgpt submission --- chatgpt-app-submission.json | 46 ++++++++++++++++++++++++++++++++++++- 1 file changed, 45 insertions(+), 1 deletion(-) diff --git a/chatgpt-app-submission.json b/chatgpt-app-submission.json index 5a10433fee..62c1c5ace3 100644 --- a/chatgpt-app-submission.json +++ b/chatgpt-app-submission.json @@ -4,7 +4,7 @@ "app_info": { "display_name": "Postiz", "subtitle": "Schedule social posts", - "description": "Postiz helps users manage social media publishing from ChatGPT. Users can list connected channels and groups, inspect platform requirements, upload or generate media, create drafts or scheduled posts, update provider settings, and review upcoming posts.", + "description": "Postiz helps users manage social media publishing from ChatGPT. Users can list connected channels and groups, inspect platform requirements, upload or generate media, turn long YouTube videos into short clips, create drafts or scheduled posts, update provider settings, and review upcoming posts.", "category": "PRODUCTIVITY" }, "tools": { @@ -199,6 +199,42 @@ "open_world_justification": "Reads internal Postiz data for the user's workspace only.", "destructive_justification": "Read-only; it cannot change or delete anything." } + }, + "clippingTool": { + "annotations": { + "readOnlyHint": false, + "openWorldHint": true, + "destructiveHint": false + }, + "justifications": { + "read_only_justification": "Starts a background clipping job that downloads a public YouTube video, renders short vertical clips with captions, consumes the user's clipping minutes, and saves every clip to the media library; when channels are passed it also creates draft posts.", + "open_world_justification": "Reads a public YouTube URL and calls external rendering and storage services to create media for the user's workspace.", + "destructive_justification": "Does not delete or overwrite existing media or posts, revoke access, or publish content; any posts it creates are drafts only." + } + }, + "clippingStatusTool": { + "annotations": { + "readOnlyHint": true, + "openWorldHint": false, + "destructiveHint": false + }, + "justifications": { + "read_only_justification": "Only reads the status of a clipping job the user started and returns the hosted clip URLs once it is done.", + "open_world_justification": "Does not write to public internet state or third-party systems.", + "destructive_justification": "Does not delete, overwrite, revoke access, or send content." + } + }, + "clippingWidgetTicketTool": { + "annotations": { + "readOnlyHint": false, + "openWorldHint": false, + "destructiveHint": false + }, + "justifications": { + "read_only_justification": "Creates a short-lived ticket for the clipping widget to read the progress of a clipping job; it is only callable by the widget, not by the model.", + "open_world_justification": "The ticket only allows reading the user's own clipping job inside Postiz; no public or third-party state changes.", + "destructive_justification": "Does not delete or overwrite anything, revoke access, or publish content." + } } }, "test_cases": [ @@ -241,6 +277,14 @@ "tools_triggered": "generateImageTool", "expected_output": "Calls generateImageTool with the prompt and returns the hosted image URL saved in the media library. If the account has no AI image credits, the tool returns a clear error message that is relayed to the user. No post is created.", "expected_output_url": null + }, + { + "description": "Clip a YouTube video into short vertical clips and save them to the media library (nothing is published).", + "user_prompt": "Turn this YouTube video into short vertical clips in Postiz and save them to my media library, don't create any posts: https://www.youtube.com/watch?v=dQw4w9WgXcQ", + "file_attachment_urls": null, + "tools_triggered": "clippingTool, clippingStatusTool", + "expected_output": "First asks the user whether the horizontal video should fill the vertical clip with \"blur\" or \"crop\" and waits for the answer. Then calls clippingTool with the URL and the chosen fit, tells the user clipping is running, and calls clippingStatusTool with the returned clippingId until the status is \"completed\", then lists each clip's title and hosted video URL. If the account has no clipping minutes left, the tool returns a clear error message that is relayed to the user. No post is created.", + "expected_output_url": null } ], "negative_test_cases": [ From 6af357dd608ec623c1bf6e25308974a556120370 Mon Sep 17 00:00:00 2001 From: Nevo David Date: Tue, 22 Sep 2026 09:12:22 +0700 Subject: [PATCH 11/45] feat: update chatgpt submission --- chatgpt-app-submission.json | 8 -------- 1 file changed, 8 deletions(-) diff --git a/chatgpt-app-submission.json b/chatgpt-app-submission.json index 62c1c5ace3..05a6e68bee 100644 --- a/chatgpt-app-submission.json +++ b/chatgpt-app-submission.json @@ -277,14 +277,6 @@ "tools_triggered": "generateImageTool", "expected_output": "Calls generateImageTool with the prompt and returns the hosted image URL saved in the media library. If the account has no AI image credits, the tool returns a clear error message that is relayed to the user. No post is created.", "expected_output_url": null - }, - { - "description": "Clip a YouTube video into short vertical clips and save them to the media library (nothing is published).", - "user_prompt": "Turn this YouTube video into short vertical clips in Postiz and save them to my media library, don't create any posts: https://www.youtube.com/watch?v=dQw4w9WgXcQ", - "file_attachment_urls": null, - "tools_triggered": "clippingTool, clippingStatusTool", - "expected_output": "First asks the user whether the horizontal video should fill the vertical clip with \"blur\" or \"crop\" and waits for the answer. Then calls clippingTool with the URL and the chosen fit, tells the user clipping is running, and calls clippingStatusTool with the returned clippingId until the status is \"completed\", then lists each clip's title and hosted video URL. If the account has no clipping minutes left, the tool returns a clear error message that is relayed to the user. No post is created.", - "expected_output_url": null } ], "negative_test_cases": [ From c02384370266abf9390e7abe8510929371ab2885 Mon Sep 17 00:00:00 2001 From: Nevo David Date: Tue, 22 Sep 2026 09:57:35 +0700 Subject: [PATCH 12/45] feat: update chatgpt submission --- chatgpt-app-submission.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/chatgpt-app-submission.json b/chatgpt-app-submission.json index 05a6e68bee..c2c1758e95 100644 --- a/chatgpt-app-submission.json +++ b/chatgpt-app-submission.json @@ -3,8 +3,8 @@ "schema_version": 1, "app_info": { "display_name": "Postiz", - "subtitle": "Schedule social posts", - "description": "Postiz helps users manage social media publishing from ChatGPT. Users can list connected channels and groups, inspect platform requirements, upload or generate media, turn long YouTube videos into short clips, create drafts or scheduled posts, update provider settings, and review upcoming posts.", + "subtitle": "Schedule Social Media Posts", + "description": "Postiz helps users manage social media publishing from ChatGPT. Users can:\n- Schedule social media posts to 28+ platforms including X, LinkedIn, LinkedIn Pages, Instagram, Facebook, Threads, YouTube, TikTok, Reddit, Pinterest, Bluesky, Mastodon, Google My Business, Discord, Slack, Telegram, Twitch, Kick, Lemmy, Farcaster, Nostr, VK, MeWe, Tumblr, Skool, Whop, Moltbook, Dribbble, Medium, Dev.to, Hashnode, WordPress, and ListMonk\n- List connected channels and groups\n- Inspect platform requirements\n- Upload or generate media\n- Turn long YouTube videos into short clips\n- Create drafts or scheduled posts\n- Update provider settings\n- Review upcoming posts.", "category": "PRODUCTIVITY" }, "tools": { From 9948b072e7c9735dace239d99560f22fd01ef3b9 Mon Sep 17 00:00:00 2001 From: Gilad Resisi Date: Tue, 22 Sep 2026 10:36:56 +0700 Subject: [PATCH 13/45] fix(facebook): request the reels metrics in video post analytics 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 Claude-Session: https://claude.ai/code/session_014wvGQeFVP1UKfi9VAYYBYK --- .../integrations/social/facebook.provider.ts | 23 ++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/libraries/nestjs-libraries/src/integrations/social/facebook.provider.ts b/libraries/nestjs-libraries/src/integrations/social/facebook.provider.ts index bbf18a82d9..dcb72a669b 100644 --- a/libraries/nestjs-libraries/src/integrations/social/facebook.provider.ts +++ b/libraries/nestjs-libraries/src/integrations/social/facebook.provider.ts @@ -1135,11 +1135,17 @@ export class FacebookProvider extends SocialAbstract implements SocialProvider { // - total_video_impressions: times the video was shown // - total_video_views: 3s+ (or full, if shorter) plays // - total_video_reactions_by_type_total: reactions object, keyed by type + // Reels never return the total_video_* metrics (the edge answers with an + // empty data array), only the reels ones, so both sets are requested at + // once and Graph simply omits the metrics that don't apply: + // - fb_reels_total_plays: plays including replays + // - post_video_likes_by_reaction_type: reactions object, keyed by type + // - post_video_social_actions: comments/shares object, keyed by type // Use plain fetch (not this.fetch) so a `(#100) nonexisting field` / story // response doesn't throw an ApplicationFailure — we want a quiet `[]` instead. const { data, error } = await ( await fetch( - `https://graph.facebook.com/${META_GRAPH_API_VERSION}/${videoId}/video_insights?metric=total_video_impressions,total_video_views,total_video_reactions_by_type_total&access_token=${accessToken}` + `https://graph.facebook.com/${META_GRAPH_API_VERSION}/${videoId}/video_insights?metric=total_video_impressions,total_video_views,total_video_reactions_by_type_total,fb_reels_total_plays,post_video_likes_by_reaction_type,post_video_social_actions&access_token=${accessToken}` ) ).json(); @@ -1177,7 +1183,12 @@ export class FacebookProvider extends SocialAbstract implements SocialProvider { label = 'Views'; total = String(value); break; + case 'fb_reels_total_plays': + label = 'Plays'; + total = String(value); + break; case 'total_video_reactions_by_type_total': + case 'post_video_likes_by_reaction_type': // This returns an object with reaction types if (typeof value === 'object') { const totalReactions = Object.values( @@ -1187,6 +1198,16 @@ export class FacebookProvider extends SocialAbstract implements SocialProvider { total = String(totalReactions); } break; + case 'post_video_social_actions': + // This returns an object with action types (comments, shares) + if (typeof value === 'object') { + const totalActions = Object.values( + value as Record + ).reduce((sum: number, v: number) => sum + v, 0); + label = 'Engagement'; + total = String(totalActions); + } + break; } if (label) { From 9259cf2429e8cc0c88414e88d5e6c783d7ffba63 Mon Sep 17 00:00:00 2001 From: Nevo David Date: Tue, 22 Sep 2026 11:32:19 +0700 Subject: [PATCH 14/45] fix: CVE-2026-94455 --- .../src/api/routes/enterprise.controller.ts | 31 +++++++++++++++---- 1 file changed, 25 insertions(+), 6 deletions(-) diff --git a/apps/backend/src/api/routes/enterprise.controller.ts b/apps/backend/src/api/routes/enterprise.controller.ts index 5e9bbdc806..eebc630054 100644 --- a/apps/backend/src/api/routes/enterprise.controller.ts +++ b/apps/backend/src/api/routes/enterprise.controller.ts @@ -17,15 +17,34 @@ export class EnterpriseController { private _postsService: PostsService ) {} + private verifyEnterpriseToken(params: string): T { + const payload = AuthService.verifyJWT(params) as any; + if ( + !payload || + typeof payload !== 'object' || + 'providerName' in payload || // login token (full User row) + 'orgId' in payload || // team invite token + 'expires' in payload // password reset token + ) { + throw new Error('Invalid enterprise token'); + } + + return payload as T; + } + @Post('/create-user') async createUser(@Body('params') params: string) { try { - const { id, name, saasName, email } = AuthService.verifyJWT(params) as { + const { id, name, saasName, email } = this.verifyEnterpriseToken<{ id: string; name: string; email: string; saasName: string; - }; + }>(params); + + if (!id || !saasName) { + return { success: false }; + } try { return await this._organizationService.createMaxUser( @@ -45,13 +64,13 @@ export class EnterpriseController { @Post('/url') async redirectParams(@Body('params') params: string) { try { - const load = AuthService.verifyJWT(params) as { + const load = this.verifyEnterpriseToken<{ redirectUrl: string; apiKey: string; refreshId?: string; provider: string; webhookUrl: string; - }; + }>(params); if (!load || !load.redirectUrl || !load.apiKey || !load.provider) { return; @@ -94,10 +113,10 @@ export class EnterpriseController { @Post('/delete-channel') async deleteChannel(@Body('params') params: string) { try { - const load = AuthService.verifyJWT(params) as { + const load = this.verifyEnterpriseToken<{ apiKey: string; id: string; - }; + }>(params); if (!load || !load.apiKey || !load.id) { return { success: false }; From 4c835138aa55c1d9110b1439bf6cf404c3b17140 Mon Sep 17 00:00:00 2001 From: Nevo David Date: Tue, 22 Sep 2026 11:52:20 +0700 Subject: [PATCH 15/45] fix: CVE-2026-94456 --- apps/backend/src/api/routes/auth.controller.ts | 4 ++-- .../src/database/prisma/oauth/oauth.service.ts | 16 ++++++++-------- .../organizations/organization.repository.ts | 9 +++++---- .../src/integrations/social/bluesky.provider.ts | 6 +++--- .../src/integrations/social/dev.to.provider.ts | 6 +++--- .../src/integrations/social/discord.provider.ts | 6 +++--- .../integrations/social/dribbble.provider.ts | 6 +++--- .../integrations/social/facebook.provider.ts | 6 +++--- .../integrations/social/farcaster.provider.ts | 6 +++--- .../src/integrations/social/gmb.provider.ts | 6 +++--- .../integrations/social/hashnode.provider.ts | 6 +++--- .../integrations/social/instagram.provider.ts | 6 +++--- .../social/instagram.standalone.provider.ts | 6 +++--- .../src/integrations/social/kick.provider.ts | 3 ++- .../src/integrations/social/lemmy.provider.ts | 6 +++--- .../social/linkedin.page.provider.ts | 6 +++--- .../integrations/social/linkedin.provider.ts | 5 +++-- .../integrations/social/listmonk.provider.ts | 6 +++--- .../social/mastodon.custom.provider.ts | 6 +++--- .../integrations/social/mastodon.provider.ts | 5 +++-- .../src/integrations/social/medium.provider.ts | 6 +++--- .../src/integrations/social/mewe.provider.ts | 5 +++-- .../integrations/social/moltbook.provider.ts | 6 +++--- .../src/integrations/social/nostr.provider.ts | 6 +++--- .../integrations/social/pinterest.provider.ts | 6 +++--- .../src/integrations/social/reddit.provider.ts | 6 +++--- .../src/integrations/social/skool.provider.ts | 6 +++--- .../src/integrations/social/slack.provider.ts | 6 +++--- .../integrations/social/telegram.provider.ts | 6 +++--- .../src/integrations/social/threads.provider.ts | 6 +++--- .../social/tiktok.business.provider.ts | 3 ++- .../src/integrations/social/tiktok.provider.ts | 3 ++- .../src/integrations/social/tumblr.provider.ts | 6 +++--- .../src/integrations/social/twitch.provider.ts | 5 +++-- .../src/integrations/social/vk.provider.ts | 6 +++--- .../src/integrations/social/whop.provider.ts | 6 +++--- .../integrations/social/wordpress.provider.ts | 6 +++--- .../src/integrations/social/youtube.provider.ts | 6 +++--- .../src/services/make.secure.id.ts | 17 +++++++++++++++++ 39 files changed, 134 insertions(+), 109 deletions(-) create mode 100644 libraries/nestjs-libraries/src/services/make.secure.id.ts diff --git a/apps/backend/src/api/routes/auth.controller.ts b/apps/backend/src/api/routes/auth.controller.ts index 60b29cf0ea..e7aee4a069 100644 --- a/apps/backend/src/api/routes/auth.controller.ts +++ b/apps/backend/src/api/routes/auth.controller.ts @@ -25,7 +25,7 @@ import { EmailService } from '@gitroom/nestjs-libraries/services/email.service'; import { RealIP } from 'nestjs-real-ip'; import { UserAgent } from '@gitroom/nestjs-libraries/user/user.agent'; import { Provider } from '@prisma/client'; -import { makeId } from '@gitroom/nestjs-libraries/services/make.is'; +import { makeSecureId } from '@gitroom/nestjs-libraries/services/make.secure.id'; import * as Sentry from '@sentry/nestjs'; import { FarcasterProvider } from '@gitroom/nestjs-libraries/integrations/social/farcaster.provider'; @@ -223,7 +223,7 @@ export class AuthController { @Query() query: any, @Res({ passthrough: true }) response: Response ) { - const state = `login-${makeId(16)}`; + const state = `login-${makeSecureId(16)}`; response.cookie('oauth_state', state, { domain: getCookieUrlFromDomain(process.env.FRONTEND_URL!), ...(!process.env.NOT_SECURED diff --git a/libraries/nestjs-libraries/src/database/prisma/oauth/oauth.service.ts b/libraries/nestjs-libraries/src/database/prisma/oauth/oauth.service.ts index 3b7cffe43f..4bd1c27a9e 100644 --- a/libraries/nestjs-libraries/src/database/prisma/oauth/oauth.service.ts +++ b/libraries/nestjs-libraries/src/database/prisma/oauth/oauth.service.ts @@ -3,7 +3,7 @@ import { OAuthRepository } from '@gitroom/nestjs-libraries/database/prisma/oauth import { CreateOAuthAppDto } from '@gitroom/nestjs-libraries/dtos/oauth/create-oauth-app.dto'; import { UpdateOAuthAppDto } from '@gitroom/nestjs-libraries/dtos/oauth/update-oauth-app.dto'; import { RegisterClientDto } from '@gitroom/nestjs-libraries/dtos/oauth/register-client.dto'; -import { makeId } from '@gitroom/nestjs-libraries/services/make.is'; +import { makeSecureId } from '@gitroom/nestjs-libraries/services/make.secure.id'; import { AuthService } from '@gitroom/helpers/auth/auth.service'; import { extractBearerToken } from '@gitroom/nestjs-libraries/chat/oauth-types'; import { createHash } from 'crypto'; @@ -54,8 +54,8 @@ export class OAuthService { ); } - const clientId = 'pca_' + makeId(32); - const clientSecret = 'pcs_' + makeId(48); + const clientId = 'pca_' + makeSecureId(32); + const clientSecret = 'pcs_' + makeSecureId(48); const encryptedSecret = AuthService.fixedEncryption(clientSecret); const app = await this._oauthRepository.createApp(orgId, { @@ -95,7 +95,7 @@ export class OAuthService { throw new HttpException('No OAuth app found', HttpStatus.NOT_FOUND); } - const newSecret = 'pcs_' + makeId(48); + const newSecret = 'pcs_' + makeSecureId(48); const encrypted = AuthService.fixedEncryption(newSecret); await this._oauthRepository.updateClientSecret(orgId, encrypted); return { clientSecret: newSecret }; @@ -187,8 +187,8 @@ export class OAuthService { : dto.token_endpoint_auth_method === 'client_secret_basic' ? 'client_secret_basic' : 'client_secret_post'; - const clientId = 'pcd_' + makeId(32); - const clientSecret = isPublicClient ? undefined : 'pcs_' + makeId(48); + const clientId = 'pcd_' + makeSecureId(32); + const clientSecret = isPublicClient ? undefined : 'pcs_' + makeSecureId(48); const app = await this._oauthRepository.createDynamicApp({ name: dto.client_name?.trim().slice(0, 100) || 'MCP Client', @@ -310,7 +310,7 @@ export class OAuthService { redirectUri?: string; } ) { - const code = makeId(32); + const code = makeSecureId(32); const encryptedCode = AuthService.fixedEncryption(code); const codeExpiresAt = new Date(Date.now() + 10 * 60 * 1000); @@ -398,7 +398,7 @@ export class OAuthService { ); } - const token = 'pos_' + makeId(40); + const token = 'pos_' + makeSecureId(40); const encryptedToken = AuthService.fixedEncryption(token); const { organizationId, diff --git a/libraries/nestjs-libraries/src/database/prisma/organizations/organization.repository.ts b/libraries/nestjs-libraries/src/database/prisma/organizations/organization.repository.ts index 584317afc4..ad06901738 100644 --- a/libraries/nestjs-libraries/src/database/prisma/organizations/organization.repository.ts +++ b/libraries/nestjs-libraries/src/database/prisma/organizations/organization.repository.ts @@ -4,6 +4,7 @@ import { Injectable } from '@nestjs/common'; import { AuthService } from '@gitroom/helpers/auth/auth.service'; import { CreateOrgUserDto } from '@gitroom/nestjs-libraries/dtos/auth/create.org.user.dto'; import { makeId } from '@gitroom/nestjs-libraries/services/make.is'; +import { makeSecureId } from '@gitroom/nestjs-libraries/services/make.secure.id'; @Injectable() export class OrganizationRepository { @@ -21,7 +22,7 @@ export class OrganizationRepository { }, data: { name: name ? `${name}###${id}` : `Unnamed User###${id}`, - apiKey: AuthService.fixedEncryption(makeId(20)), + apiKey: AuthService.fixedEncryption(makeSecureId(20)), isTrailing: false, subscription: { create: { @@ -42,7 +43,7 @@ export class OrganizationRepository { : `${saasName}+` + makeId(10) + '@postiz.com', name: name ? `${name}###${id}` : `Unnamed User###${id}`, providerName: 'LOCAL', - password: AuthService.hashPassword(makeId(500)), + password: AuthService.hashPassword(makeSecureId(500)), timezone: 0, }, }, @@ -240,7 +241,7 @@ export class OrganizationRepository { id: orgId, }, data: { - apiKey: AuthService.fixedEncryption(makeId(20)), + apiKey: AuthService.fixedEncryption(makeSecureId(20)), }, }); } @@ -466,7 +467,7 @@ export class OrganizationRepository { return this._organization.model.organization.create({ data: { name: body.company, - apiKey: AuthService.fixedEncryption(makeId(20)), + apiKey: AuthService.fixedEncryption(makeSecureId(20)), allowTrial: true, isTrailing: true, users: { diff --git a/libraries/nestjs-libraries/src/integrations/social/bluesky.provider.ts b/libraries/nestjs-libraries/src/integrations/social/bluesky.provider.ts index 4efd91c26d..67521a3293 100644 --- a/libraries/nestjs-libraries/src/integrations/social/bluesky.provider.ts +++ b/libraries/nestjs-libraries/src/integrations/social/bluesky.provider.ts @@ -5,7 +5,7 @@ import { PostResponse, SocialProvider, } from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface'; -import { makeId } from '@gitroom/nestjs-libraries/services/make.is'; +import { makeSecureId } from '@gitroom/nestjs-libraries/services/make.secure.id'; import { setHeartbeatDetails } from '@gitroom/nestjs-libraries/temporal/temporal.heartbeat'; import { BadBody, @@ -301,10 +301,10 @@ export class BlueskyProvider extends SocialAbstract implements SocialProvider { } async generateAuthUrl() { - const state = makeId(6); + const state = makeSecureId(6); return { url: state, - codeVerifier: makeId(10), + codeVerifier: makeSecureId(10), state, }; } diff --git a/libraries/nestjs-libraries/src/integrations/social/dev.to.provider.ts b/libraries/nestjs-libraries/src/integrations/social/dev.to.provider.ts index f0aee14a95..c80855135f 100644 --- a/libraries/nestjs-libraries/src/integrations/social/dev.to.provider.ts +++ b/libraries/nestjs-libraries/src/integrations/social/dev.to.provider.ts @@ -7,7 +7,7 @@ import { import { SocialAbstract } from '@gitroom/nestjs-libraries/integrations/social.abstract'; import dayjs from 'dayjs'; import { Integration } from '@prisma/client'; -import { makeId } from '@gitroom/nestjs-libraries/services/make.is'; +import { makeSecureId } from '@gitroom/nestjs-libraries/services/make.secure.id'; import { DevToSettingsDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/dev.to.settings.dto'; import { Tool } from '@gitroom/nestjs-libraries/integrations/tool.decorator'; @@ -24,10 +24,10 @@ export class DevToProvider extends SocialAbstract implements SocialProvider { dto = DevToSettingsDto; async generateAuthUrl() { - const state = makeId(6); + const state = makeSecureId(6); return { url: state, - codeVerifier: makeId(10), + codeVerifier: makeSecureId(10), state, }; } diff --git a/libraries/nestjs-libraries/src/integrations/social/discord.provider.ts b/libraries/nestjs-libraries/src/integrations/social/discord.provider.ts index 61608a56fc..3cc826fe8b 100644 --- a/libraries/nestjs-libraries/src/integrations/social/discord.provider.ts +++ b/libraries/nestjs-libraries/src/integrations/social/discord.provider.ts @@ -4,7 +4,7 @@ import { PostResponse, SocialProvider, } from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface'; -import { makeId } from '@gitroom/nestjs-libraries/services/make.is'; +import { makeSecureId } from '@gitroom/nestjs-libraries/services/make.secure.id'; import { SocialAbstract } from '@gitroom/nestjs-libraries/integrations/social.abstract'; import { Integration } from '@prisma/client'; import { DiscordDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/discord.dto'; @@ -61,14 +61,14 @@ export class DiscordProvider extends SocialAbstract implements SocialProvider { }; } async generateAuthUrl() { - const state = makeId(6); + const state = makeSecureId(6); return { url: `https://discord.com/oauth2/authorize?client_id=${ process.env.DISCORD_CLIENT_ID }&permissions=377957124096&response_type=code&redirect_uri=${encodeURIComponent( `${process.env.FRONTEND_URL}/integrations/social/discord` )}&integration_type=0&scope=bot+identify+guilds&state=${state}`, - codeVerifier: makeId(10), + codeVerifier: makeSecureId(10), state, }; } diff --git a/libraries/nestjs-libraries/src/integrations/social/dribbble.provider.ts b/libraries/nestjs-libraries/src/integrations/social/dribbble.provider.ts index eab2e725aa..08f26e0930 100644 --- a/libraries/nestjs-libraries/src/integrations/social/dribbble.provider.ts +++ b/libraries/nestjs-libraries/src/integrations/social/dribbble.provider.ts @@ -5,7 +5,7 @@ import { PostResponse, SocialProvider, } from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface'; -import { makeId } from '@gitroom/nestjs-libraries/services/make.is'; +import { makeSecureId } from '@gitroom/nestjs-libraries/services/make.secure.id'; import FormData from 'form-data'; import { SocialAbstract, @@ -109,14 +109,14 @@ export class DribbbleProvider extends SocialAbstract implements SocialProvider { } async generateAuthUrl() { - const state = makeId(6); + const state = makeSecureId(6); return { url: `https://dribbble.com/oauth/authorize?client_id=${ process.env.DRIBBBLE_CLIENT_ID }&redirect_uri=${encodeURIComponent( `${process.env.FRONTEND_URL}/integrations/social/dribbble` )}&response_type=code&scope=${this.scopes.join('+')}&state=${state}`, - codeVerifier: makeId(10), + codeVerifier: makeSecureId(10), state, }; } diff --git a/libraries/nestjs-libraries/src/integrations/social/facebook.provider.ts b/libraries/nestjs-libraries/src/integrations/social/facebook.provider.ts index dcb72a669b..fff046d021 100644 --- a/libraries/nestjs-libraries/src/integrations/social/facebook.provider.ts +++ b/libraries/nestjs-libraries/src/integrations/social/facebook.provider.ts @@ -6,7 +6,7 @@ import { PostResponse, SocialProvider, } from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface'; -import { makeId } from '@gitroom/nestjs-libraries/services/make.is'; +import { makeSecureId } from '@gitroom/nestjs-libraries/services/make.secure.id'; import dayjs from 'dayjs'; import { BadBody, @@ -260,7 +260,7 @@ export class FacebookProvider extends SocialAbstract implements SocialProvider { } async generateAuthUrl() { - const state = makeId(6); + const state = makeSecureId(6); return { url: `https://www.facebook.com/${META_GRAPH_API_VERSION}/dialog/oauth` + @@ -270,7 +270,7 @@ export class FacebookProvider extends SocialAbstract implements SocialProvider { )}` + `&state=${state}` + `&scope=${this.scopes.join(',')}`, - codeVerifier: makeId(10), + codeVerifier: makeSecureId(10), state, }; } diff --git a/libraries/nestjs-libraries/src/integrations/social/farcaster.provider.ts b/libraries/nestjs-libraries/src/integrations/social/farcaster.provider.ts index c61a265ce4..dbbd137066 100644 --- a/libraries/nestjs-libraries/src/integrations/social/farcaster.provider.ts +++ b/libraries/nestjs-libraries/src/integrations/social/farcaster.provider.ts @@ -4,7 +4,7 @@ import { PostResponse, SocialProvider, } from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface'; -import { makeId } from '@gitroom/nestjs-libraries/services/make.is'; +import { makeSecureId } from '@gitroom/nestjs-libraries/services/make.secure.id'; import dayjs from 'dayjs'; import { SocialAbstract, @@ -80,10 +80,10 @@ export class FarcasterProvider } async generateAuthUrl() { - const state = makeId(17); + const state = makeSecureId(17); return { url: `${process.env.NEYNAR_CLIENT_ID}||${state}` || '', - codeVerifier: makeId(10), + codeVerifier: makeSecureId(10), state, }; } diff --git a/libraries/nestjs-libraries/src/integrations/social/gmb.provider.ts b/libraries/nestjs-libraries/src/integrations/social/gmb.provider.ts index 0a8c4ebeb7..281c1d1bf3 100644 --- a/libraries/nestjs-libraries/src/integrations/social/gmb.provider.ts +++ b/libraries/nestjs-libraries/src/integrations/social/gmb.provider.ts @@ -5,7 +5,7 @@ import { PostResponse, SocialProvider, } from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface'; -import { makeId } from '@gitroom/nestjs-libraries/services/make.is'; +import { makeSecureId } from '@gitroom/nestjs-libraries/services/make.secure.id'; import { google } from 'googleapis'; import { OAuth2Client } from 'google-auth-library/build/src/auth/oauth2client'; import { @@ -157,7 +157,7 @@ export class GmbProvider extends SocialAbstract implements SocialProvider { } async generateAuthUrl() { - const state = makeId(7); + const state = makeSecureId(7); const { client } = clientAndGmb(); return { url: client.generateAuthUrl({ @@ -167,7 +167,7 @@ export class GmbProvider extends SocialAbstract implements SocialProvider { redirect_uri: `${process.env.FRONTEND_URL}/integrations/social/gmb`, scope: this.scopes.slice(0), }), - codeVerifier: makeId(11), + codeVerifier: makeSecureId(11), state, }; } diff --git a/libraries/nestjs-libraries/src/integrations/social/hashnode.provider.ts b/libraries/nestjs-libraries/src/integrations/social/hashnode.provider.ts index e55eee8479..d2384db70e 100644 --- a/libraries/nestjs-libraries/src/integrations/social/hashnode.provider.ts +++ b/libraries/nestjs-libraries/src/integrations/social/hashnode.provider.ts @@ -13,7 +13,7 @@ import { jsonToGraphQLQuery } from 'json-to-graphql-query'; import { HashnodeSettingsDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/hashnode.settings.dto'; import dayjs from 'dayjs'; import { Integration } from '@prisma/client'; -import { makeId } from '@gitroom/nestjs-libraries/services/make.is'; +import { makeSecureId } from '@gitroom/nestjs-libraries/services/make.secure.id'; import { Tool } from '@gitroom/nestjs-libraries/integrations/tool.decorator'; export class HashnodeProvider extends SocialAbstract implements SocialProvider { @@ -29,10 +29,10 @@ export class HashnodeProvider extends SocialAbstract implements SocialProvider { dto = HashnodeSettingsDto; async generateAuthUrl() { - const state = makeId(6); + const state = makeSecureId(6); return { url: state, - codeVerifier: makeId(10), + codeVerifier: makeSecureId(10), state, }; } diff --git a/libraries/nestjs-libraries/src/integrations/social/instagram.provider.ts b/libraries/nestjs-libraries/src/integrations/social/instagram.provider.ts index f8c9aa08cb..b9eb4a2e99 100644 --- a/libraries/nestjs-libraries/src/integrations/social/instagram.provider.ts +++ b/libraries/nestjs-libraries/src/integrations/social/instagram.provider.ts @@ -6,7 +6,7 @@ import { PostResponse, SocialProvider, } from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface'; -import { makeId } from '@gitroom/nestjs-libraries/services/make.is'; +import { makeSecureId } from '@gitroom/nestjs-libraries/services/make.secure.id'; import { timer } from '@gitroom/helpers/utils/timer'; import dayjs from 'dayjs'; import { @@ -423,7 +423,7 @@ export class InstagramProvider } async generateAuthUrl() { - const state = makeId(6); + const state = makeSecureId(6); return { url: `https://www.facebook.com/${META_GRAPH_API_VERSION}/dialog/oauth` + @@ -433,7 +433,7 @@ export class InstagramProvider )}` + `&state=${state}` + `&scope=${encodeURIComponent(this.scopes.join(','))}`, - codeVerifier: makeId(10), + codeVerifier: makeSecureId(10), state, }; } diff --git a/libraries/nestjs-libraries/src/integrations/social/instagram.standalone.provider.ts b/libraries/nestjs-libraries/src/integrations/social/instagram.standalone.provider.ts index 5cbd5df80c..4c3261fbd3 100644 --- a/libraries/nestjs-libraries/src/integrations/social/instagram.standalone.provider.ts +++ b/libraries/nestjs-libraries/src/integrations/social/instagram.standalone.provider.ts @@ -4,7 +4,7 @@ import { PostResponse, SocialProvider, } from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface'; -import { makeId } from '@gitroom/nestjs-libraries/services/make.is'; +import { makeSecureId } from '@gitroom/nestjs-libraries/services/make.secure.id'; import dayjs from 'dayjs'; import { SocialAbstract, @@ -103,7 +103,7 @@ export class InstagramStandaloneProvider } async generateAuthUrl() { - const state = makeId(6); + const state = makeSecureId(6); return { url: `https://www.instagram.com/oauth/authorize?enable_fb_login=0&client_id=${ @@ -117,7 +117,7 @@ export class InstagramStandaloneProvider )}&response_type=code&scope=${encodeURIComponent( this.scopes.join(',') )}` + `&state=${state}`, - codeVerifier: makeId(10), + codeVerifier: makeSecureId(10), state, }; } diff --git a/libraries/nestjs-libraries/src/integrations/social/kick.provider.ts b/libraries/nestjs-libraries/src/integrations/social/kick.provider.ts index 8c2e66ef01..a830cdb7b0 100644 --- a/libraries/nestjs-libraries/src/integrations/social/kick.provider.ts +++ b/libraries/nestjs-libraries/src/integrations/social/kick.provider.ts @@ -5,6 +5,7 @@ import { SocialProvider, } from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface'; import { makeId } from '@gitroom/nestjs-libraries/services/make.is'; +import { makeSecureId } from '@gitroom/nestjs-libraries/services/make.secure.id'; import { SocialAbstract } from '@gitroom/nestjs-libraries/integrations/social.abstract'; import dayjs from 'dayjs'; import { Integration } from '@prisma/client'; @@ -68,7 +69,7 @@ export class KickProvider extends SocialAbstract implements SocialProvider { } async generateAuthUrl() { - const state = makeId(32); + const state = makeSecureId(32); const { codeVerifier, codeChallenge } = this.generatePKCE(); const redirectUri = `${process.env.FRONTEND_URL}/integrations/social/kick`; diff --git a/libraries/nestjs-libraries/src/integrations/social/lemmy.provider.ts b/libraries/nestjs-libraries/src/integrations/social/lemmy.provider.ts index a46b38452b..94bf8e085d 100644 --- a/libraries/nestjs-libraries/src/integrations/social/lemmy.provider.ts +++ b/libraries/nestjs-libraries/src/integrations/social/lemmy.provider.ts @@ -4,7 +4,7 @@ import { PostResponse, SocialProvider, } from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface'; -import { makeId } from '@gitroom/nestjs-libraries/services/make.is'; +import { makeSecureId } from '@gitroom/nestjs-libraries/services/make.secure.id'; import { SocialAbstract, ValidityMedia, @@ -81,10 +81,10 @@ export class LemmyProvider extends SocialAbstract implements SocialProvider { } async generateAuthUrl() { - const state = makeId(6); + const state = makeSecureId(6); return { url: state, - codeVerifier: makeId(10), + codeVerifier: makeSecureId(10), state, }; } diff --git a/libraries/nestjs-libraries/src/integrations/social/linkedin.page.provider.ts b/libraries/nestjs-libraries/src/integrations/social/linkedin.page.provider.ts index 0e933e1d97..607289ef0f 100644 --- a/libraries/nestjs-libraries/src/integrations/social/linkedin.page.provider.ts +++ b/libraries/nestjs-libraries/src/integrations/social/linkedin.page.provider.ts @@ -5,7 +5,7 @@ import { PostResponse, SocialProvider, } from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface'; -import { makeId } from '@gitroom/nestjs-libraries/services/make.is'; +import { makeSecureId } from '@gitroom/nestjs-libraries/services/make.secure.id'; import { LinkedinProvider } from '@gitroom/nestjs-libraries/integrations/social/linkedin.provider'; import dayjs from 'dayjs'; import { Integration } from '@prisma/client'; @@ -121,8 +121,8 @@ export class LinkedinPageProvider } override async generateAuthUrl() { - const state = makeId(6); - const codeVerifier = makeId(30); + const state = makeSecureId(6); + const codeVerifier = makeSecureId(30); const url = `https://www.linkedin.com/oauth/v2/authorization?response_type=code&prompt=none&client_id=${ process.env.LINKEDIN_CLIENT_ID }&redirect_uri=${encodeURIComponent( diff --git a/libraries/nestjs-libraries/src/integrations/social/linkedin.provider.ts b/libraries/nestjs-libraries/src/integrations/social/linkedin.provider.ts index a4b6a9494f..0ec0b376a3 100644 --- a/libraries/nestjs-libraries/src/integrations/social/linkedin.provider.ts +++ b/libraries/nestjs-libraries/src/integrations/social/linkedin.provider.ts @@ -6,6 +6,7 @@ import { SocialProvider, } from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface'; import { makeId } from '@gitroom/nestjs-libraries/services/make.is'; +import { makeSecureId } from '@gitroom/nestjs-libraries/services/make.secure.id'; import sharp from 'sharp'; import { lookup } from 'mime-types'; import { readOrFetch } from '@gitroom/helpers/utils/read.or.fetch'; @@ -178,8 +179,8 @@ export class LinkedinProvider extends SocialAbstract implements SocialProvider { } async generateAuthUrl() { - const state = makeId(6); - const codeVerifier = makeId(30); + const state = makeSecureId(6); + const codeVerifier = makeSecureId(30); const url = `https://www.linkedin.com/oauth/v2/authorization?response_type=code&client_id=${ process.env.LINKEDIN_CLIENT_ID }&prompt=none&redirect_uri=${encodeURIComponent( diff --git a/libraries/nestjs-libraries/src/integrations/social/listmonk.provider.ts b/libraries/nestjs-libraries/src/integrations/social/listmonk.provider.ts index 371126c229..9366e50306 100644 --- a/libraries/nestjs-libraries/src/integrations/social/listmonk.provider.ts +++ b/libraries/nestjs-libraries/src/integrations/social/listmonk.provider.ts @@ -1,4 +1,4 @@ -import { makeId } from '@gitroom/nestjs-libraries/services/make.is'; +import { makeSecureId } from '@gitroom/nestjs-libraries/services/make.secure.id'; import { SocialAbstract } from '../social.abstract'; import { AuthTokenDetails, @@ -63,10 +63,10 @@ export class ListmonkProvider extends SocialAbstract implements SocialProvider { } async generateAuthUrl() { - const state = makeId(6); + const state = makeSecureId(6); return { url: state, - codeVerifier: makeId(10), + codeVerifier: makeSecureId(10), state, }; } diff --git a/libraries/nestjs-libraries/src/integrations/social/mastodon.custom.provider.ts b/libraries/nestjs-libraries/src/integrations/social/mastodon.custom.provider.ts index 40b1c281cc..11e34e7456 100644 --- a/libraries/nestjs-libraries/src/integrations/social/mastodon.custom.provider.ts +++ b/libraries/nestjs-libraries/src/integrations/social/mastodon.custom.provider.ts @@ -4,7 +4,7 @@ import { PostResponse, } from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface'; import { MastodonProvider } from '@gitroom/nestjs-libraries/integrations/social/mastodon.provider'; -import { makeId } from '@gitroom/nestjs-libraries/services/make.is'; +import { makeSecureId } from '@gitroom/nestjs-libraries/services/make.secure.id'; import { AuthService } from '@gitroom/helpers/auth/auth.service'; import { getSsrfSafeDispatcher } from '@gitroom/nestjs-libraries/dtos/webhooks/ssrf.safe.dispatcher'; import { Integration } from '@prisma/client'; @@ -42,7 +42,7 @@ export class MastodonCustomProvider extends MastodonProvider { refresh?: string, external?: ClientInformation ) { - const state = makeId(6); + const state = makeSecureId(6); const url = this.generateUrlDynamic( external?.instanceUrl!, state, @@ -53,7 +53,7 @@ export class MastodonCustomProvider extends MastodonProvider { return { url, - codeVerifier: makeId(10), + codeVerifier: makeSecureId(10), state, }; } diff --git a/libraries/nestjs-libraries/src/integrations/social/mastodon.provider.ts b/libraries/nestjs-libraries/src/integrations/social/mastodon.provider.ts index ee97692c95..c0ec3920f9 100644 --- a/libraries/nestjs-libraries/src/integrations/social/mastodon.provider.ts +++ b/libraries/nestjs-libraries/src/integrations/social/mastodon.provider.ts @@ -6,6 +6,7 @@ import { SocialProvider, } from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface'; import { makeId } from '@gitroom/nestjs-libraries/services/make.is'; +import { makeSecureId } from '@gitroom/nestjs-libraries/services/make.secure.id'; import { BadBody, RefreshToken, @@ -96,7 +97,7 @@ export class MastodonProvider extends SocialAbstract implements SocialProvider { } async generateAuthUrl() { - const state = makeId(6); + const state = makeSecureId(6); const url = this.generateUrlDynamic( process.env.MASTODON_URL || 'https://mastodon.social', state, @@ -105,7 +106,7 @@ export class MastodonProvider extends SocialAbstract implements SocialProvider { ); return { url, - codeVerifier: makeId(10), + codeVerifier: makeSecureId(10), state, }; } diff --git a/libraries/nestjs-libraries/src/integrations/social/medium.provider.ts b/libraries/nestjs-libraries/src/integrations/social/medium.provider.ts index 5aab52d644..9f46f6fb4f 100644 --- a/libraries/nestjs-libraries/src/integrations/social/medium.provider.ts +++ b/libraries/nestjs-libraries/src/integrations/social/medium.provider.ts @@ -7,7 +7,7 @@ import { import { SocialAbstract } from '@gitroom/nestjs-libraries/integrations/social.abstract'; import dayjs from 'dayjs'; import { Integration } from '@prisma/client'; -import { makeId } from '@gitroom/nestjs-libraries/services/make.is'; +import { makeSecureId } from '@gitroom/nestjs-libraries/services/make.secure.id'; import { MediumSettingsDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/medium.settings.dto'; import { Tool } from '@gitroom/nestjs-libraries/integrations/tool.decorator'; @@ -24,10 +24,10 @@ export class MediumProvider extends SocialAbstract implements SocialProvider { } async generateAuthUrl() { - const state = makeId(6); + const state = makeSecureId(6); return { url: state, - codeVerifier: makeId(10), + codeVerifier: makeSecureId(10), state, }; } diff --git a/libraries/nestjs-libraries/src/integrations/social/mewe.provider.ts b/libraries/nestjs-libraries/src/integrations/social/mewe.provider.ts index 51c51c5350..fddd1a94a1 100644 --- a/libraries/nestjs-libraries/src/integrations/social/mewe.provider.ts +++ b/libraries/nestjs-libraries/src/integrations/social/mewe.provider.ts @@ -5,6 +5,7 @@ import { SocialProvider, } from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface'; import { makeId } from '@gitroom/nestjs-libraries/services/make.is'; +import { makeSecureId } from '@gitroom/nestjs-libraries/services/make.secure.id'; import { SocialAbstract } from '@gitroom/nestjs-libraries/integrations/social.abstract'; import dayjs from 'dayjs'; import { Integration } from '@prisma/client'; @@ -79,7 +80,7 @@ export class MeweProvider extends SocialAbstract implements SocialProvider { } async generateAuthUrl() { - const state = makeId(6); + const state = makeSecureId(6); return { url: `${this.meweHost}/login` + @@ -88,7 +89,7 @@ export class MeweProvider extends SocialAbstract implements SocialProvider { `${process.env.FRONTEND_URL}/integrations/social/mewe` )}` + `&state=${state}`, - codeVerifier: makeId(10), + codeVerifier: makeSecureId(10), state, }; } diff --git a/libraries/nestjs-libraries/src/integrations/social/moltbook.provider.ts b/libraries/nestjs-libraries/src/integrations/social/moltbook.provider.ts index 2bc8db4e30..b4c145ba6d 100644 --- a/libraries/nestjs-libraries/src/integrations/social/moltbook.provider.ts +++ b/libraries/nestjs-libraries/src/integrations/social/moltbook.provider.ts @@ -4,7 +4,7 @@ import { PostResponse, SocialProvider, } from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface'; -import { makeId } from '@gitroom/nestjs-libraries/services/make.is'; +import { makeSecureId } from '@gitroom/nestjs-libraries/services/make.secure.id'; import { SocialAbstract } from '@gitroom/nestjs-libraries/integrations/social.abstract'; import dayjs from 'dayjs'; import { Integration } from '@prisma/client'; @@ -37,10 +37,10 @@ export class MoltbookProvider extends SocialAbstract implements SocialProvider { } async generateAuthUrl() { - const state = makeId(6); + const state = makeSecureId(6); return { url: state, - codeVerifier: makeId(10), + codeVerifier: makeSecureId(10), state, }; } diff --git a/libraries/nestjs-libraries/src/integrations/social/nostr.provider.ts b/libraries/nestjs-libraries/src/integrations/social/nostr.provider.ts index c89f21dd77..873adbcb2e 100644 --- a/libraries/nestjs-libraries/src/integrations/social/nostr.provider.ts +++ b/libraries/nestjs-libraries/src/integrations/social/nostr.provider.ts @@ -4,7 +4,7 @@ import { PostResponse, SocialProvider, } from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface'; -import { makeId } from '@gitroom/nestjs-libraries/services/make.is'; +import { makeSecureId } from '@gitroom/nestjs-libraries/services/make.secure.id'; import dayjs from 'dayjs'; import { SocialAbstract } from '@gitroom/nestjs-libraries/integrations/social.abstract'; import { getPublicKey, Relay, finalizeEvent, SimplePool } from 'nostr-tools'; @@ -63,10 +63,10 @@ export class NostrProvider extends SocialAbstract implements SocialProvider { } async generateAuthUrl() { - const state = makeId(17); + const state = makeSecureId(17); return { url: state, - codeVerifier: makeId(10), + codeVerifier: makeSecureId(10), state, }; } diff --git a/libraries/nestjs-libraries/src/integrations/social/pinterest.provider.ts b/libraries/nestjs-libraries/src/integrations/social/pinterest.provider.ts index 7de3797745..7f6354b280 100644 --- a/libraries/nestjs-libraries/src/integrations/social/pinterest.provider.ts +++ b/libraries/nestjs-libraries/src/integrations/social/pinterest.provider.ts @@ -7,7 +7,7 @@ import { SocialProvider, } from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface'; import { Integration } from '@prisma/client'; -import { makeId } from '@gitroom/nestjs-libraries/services/make.is'; +import { makeSecureId } from '@gitroom/nestjs-libraries/services/make.secure.id'; import { PinterestSettingsDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/pinterest.dto'; import FormData from 'form-data'; import { timer } from '@gitroom/helpers/utils/timer'; @@ -192,7 +192,7 @@ export class PinterestProvider } async generateAuthUrl() { - const state = makeId(6); + const state = makeSecureId(6); return { url: `https://www.pinterest.com/oauth/?client_id=${ process.env.PINTEREST_CLIENT_ID @@ -201,7 +201,7 @@ export class PinterestProvider )}&response_type=code&scope=${encodeURIComponent( 'boards:read,boards:write,pins:read,pins:write,user_accounts:read' )}&state=${state}`, - codeVerifier: makeId(10), + codeVerifier: makeSecureId(10), state, }; } diff --git a/libraries/nestjs-libraries/src/integrations/social/reddit.provider.ts b/libraries/nestjs-libraries/src/integrations/social/reddit.provider.ts index 3d4eac5852..dd2c29efcf 100644 --- a/libraries/nestjs-libraries/src/integrations/social/reddit.provider.ts +++ b/libraries/nestjs-libraries/src/integrations/social/reddit.provider.ts @@ -5,7 +5,7 @@ import { PostResponse, SocialProvider, } from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface'; -import { makeId } from '@gitroom/nestjs-libraries/services/make.is'; +import { makeSecureId } from '@gitroom/nestjs-libraries/services/make.secure.id'; import { RedditSettingsDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/reddit.dto'; import { timer } from '@gitroom/helpers/utils/timer'; import { @@ -125,8 +125,8 @@ export class RedditProvider extends SocialAbstract implements SocialProvider { } async generateAuthUrl() { - const state = makeId(6); - const codeVerifier = makeId(30); + const state = makeSecureId(6); + const codeVerifier = makeSecureId(30); const url = `https://www.reddit.com/api/v1/authorize?client_id=${ process.env.REDDIT_CLIENT_ID }&response_type=code&state=${state}&redirect_uri=${encodeURIComponent( diff --git a/libraries/nestjs-libraries/src/integrations/social/skool.provider.ts b/libraries/nestjs-libraries/src/integrations/social/skool.provider.ts index a0df90f76d..c116634f7a 100644 --- a/libraries/nestjs-libraries/src/integrations/social/skool.provider.ts +++ b/libraries/nestjs-libraries/src/integrations/social/skool.provider.ts @@ -1,4 +1,4 @@ -import { makeId } from '@gitroom/nestjs-libraries/services/make.is'; +import { makeSecureId } from '@gitroom/nestjs-libraries/services/make.secure.id'; import { BadBody, SocialAbstract } from '../social.abstract'; import { getSsrfSafeDispatcher } from '@gitroom/nestjs-libraries/dtos/webhooks/ssrf.safe.dispatcher'; import { @@ -81,10 +81,10 @@ export class SkoolProvider extends SocialAbstract implements SocialProvider { } async generateAuthUrl() { - const state = makeId(6); + const state = makeSecureId(6); return { url: state, - codeVerifier: makeId(10), + codeVerifier: makeSecureId(10), state, }; } diff --git a/libraries/nestjs-libraries/src/integrations/social/slack.provider.ts b/libraries/nestjs-libraries/src/integrations/social/slack.provider.ts index bcbe01dbe8..65000fb676 100644 --- a/libraries/nestjs-libraries/src/integrations/social/slack.provider.ts +++ b/libraries/nestjs-libraries/src/integrations/social/slack.provider.ts @@ -4,7 +4,7 @@ import { PostResponse, SocialProvider, } from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface'; -import { makeId } from '@gitroom/nestjs-libraries/services/make.is'; +import { makeSecureId } from '@gitroom/nestjs-libraries/services/make.secure.id'; import { SocialAbstract } from '@gitroom/nestjs-libraries/integrations/social.abstract'; import dayjs from 'dayjs'; import { Integration } from '@prisma/client'; @@ -43,7 +43,7 @@ export class SlackProvider extends SocialAbstract implements SocialProvider { }; } async generateAuthUrl() { - const state = makeId(6); + const state = makeSecureId(6); return { url: `https://slack.com/oauth/v2/authorize?client_id=${ @@ -55,7 +55,7 @@ export class SlackProvider extends SocialAbstract implements SocialProvider { : '' }${process?.env?.FRONTEND_URL}/integrations/social/slack` )}&scope=channels:read,chat:write,users:read,groups:read,channels:join,chat:write.customize&state=${state}`, - codeVerifier: makeId(10), + codeVerifier: makeSecureId(10), state, }; } diff --git a/libraries/nestjs-libraries/src/integrations/social/telegram.provider.ts b/libraries/nestjs-libraries/src/integrations/social/telegram.provider.ts index bcdc7a096c..c6bb19dc42 100644 --- a/libraries/nestjs-libraries/src/integrations/social/telegram.provider.ts +++ b/libraries/nestjs-libraries/src/integrations/social/telegram.provider.ts @@ -4,7 +4,7 @@ import { PostResponse, SocialProvider, } from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface'; -import { makeId } from '@gitroom/nestjs-libraries/services/make.is'; +import { makeSecureId } from '@gitroom/nestjs-libraries/services/make.secure.id'; import dayjs from 'dayjs'; import { SocialAbstract } from '@gitroom/nestjs-libraries/integrations/social.abstract'; //@ts-ignore @@ -43,10 +43,10 @@ export class TelegramProvider extends SocialAbstract implements SocialProvider { } async generateAuthUrl() { - const state = makeId(17); + const state = makeSecureId(17); return { url: state, - codeVerifier: makeId(10), + codeVerifier: makeSecureId(10), state, }; } diff --git a/libraries/nestjs-libraries/src/integrations/social/threads.provider.ts b/libraries/nestjs-libraries/src/integrations/social/threads.provider.ts index 08f8988979..4538ea239e 100644 --- a/libraries/nestjs-libraries/src/integrations/social/threads.provider.ts +++ b/libraries/nestjs-libraries/src/integrations/social/threads.provider.ts @@ -6,7 +6,7 @@ import { PostResponse, SocialProvider, } from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface'; -import { makeId } from '@gitroom/nestjs-libraries/services/make.is'; +import { makeSecureId } from '@gitroom/nestjs-libraries/services/make.secure.id'; import { timer } from '@gitroom/helpers/utils/timer'; import dayjs from 'dayjs'; import { @@ -104,7 +104,7 @@ export class ThreadsProvider extends SocialAbstract implements SocialProvider { } async generateAuthUrl() { - const state = makeId(6); + const state = makeSecureId(6); return { url: 'https://www.threads.net/oauth/authorize' + @@ -118,7 +118,7 @@ export class ThreadsProvider extends SocialAbstract implements SocialProvider { )}` + `&state=${state}` + `&scope=${encodeURIComponent(this.scopes.join(','))}`, - codeVerifier: makeId(10), + codeVerifier: makeSecureId(10), state, }; } diff --git a/libraries/nestjs-libraries/src/integrations/social/tiktok.business.provider.ts b/libraries/nestjs-libraries/src/integrations/social/tiktok.business.provider.ts index 6eab54c392..3fdaeb3f7c 100644 --- a/libraries/nestjs-libraries/src/integrations/social/tiktok.business.provider.ts +++ b/libraries/nestjs-libraries/src/integrations/social/tiktok.business.provider.ts @@ -7,6 +7,7 @@ import { SocialProvider, } from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface'; import dayjs from 'dayjs'; +import { makeSecureId } from '@gitroom/nestjs-libraries/services/make.secure.id'; import { BadBody, Disconnect, @@ -344,7 +345,7 @@ export class TiktokBusinessProvider } async generateAuthUrl() { - const state = Math.random().toString(36).substring(2); + const state = makeSecureId(16); return { url: diff --git a/libraries/nestjs-libraries/src/integrations/social/tiktok.provider.ts b/libraries/nestjs-libraries/src/integrations/social/tiktok.provider.ts index 2ce190280f..63612dd2c3 100644 --- a/libraries/nestjs-libraries/src/integrations/social/tiktok.provider.ts +++ b/libraries/nestjs-libraries/src/integrations/social/tiktok.provider.ts @@ -7,6 +7,7 @@ import { SocialProvider, } from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface'; import dayjs from 'dayjs'; +import { makeSecureId } from '@gitroom/nestjs-libraries/services/make.secure.id'; import { BadBody, Disconnect, @@ -338,7 +339,7 @@ export class TiktokProvider extends SocialAbstract implements SocialProvider { } async generateAuthUrl() { - const state = Math.random().toString(36).substring(2); + const state = makeSecureId(16); return { url: diff --git a/libraries/nestjs-libraries/src/integrations/social/tumblr.provider.ts b/libraries/nestjs-libraries/src/integrations/social/tumblr.provider.ts index 9761021c80..11353cdc60 100644 --- a/libraries/nestjs-libraries/src/integrations/social/tumblr.provider.ts +++ b/libraries/nestjs-libraries/src/integrations/social/tumblr.provider.ts @@ -9,7 +9,7 @@ import { SocialAbstract, ValidityMedia, } from '@gitroom/nestjs-libraries/integrations/social.abstract'; -import { makeId } from '@gitroom/nestjs-libraries/services/make.is'; +import { makeSecureId } from '@gitroom/nestjs-libraries/services/make.secure.id'; import { TumblrDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/tumblr.dto'; import { Integration } from '@prisma/client'; import FormDataUpload from 'form-data'; @@ -264,7 +264,7 @@ export class TumblrProvider extends SocialAbstract implements SocialProvider { } async generateAuthUrl() { - const state = makeId(6); + const state = makeSecureId(6); const redirectUri = this.redirectUri(); const params = new URLSearchParams({ client_id: process.env.TUMBLR_CLIENT_ID!, @@ -276,7 +276,7 @@ export class TumblrProvider extends SocialAbstract implements SocialProvider { return { url: `https://www.tumblr.com/oauth2/authorize?${params.toString()}`, - codeVerifier: makeId(10), + codeVerifier: makeSecureId(10), state, }; } diff --git a/libraries/nestjs-libraries/src/integrations/social/twitch.provider.ts b/libraries/nestjs-libraries/src/integrations/social/twitch.provider.ts index c88480360f..37c6c2ae3a 100644 --- a/libraries/nestjs-libraries/src/integrations/social/twitch.provider.ts +++ b/libraries/nestjs-libraries/src/integrations/social/twitch.provider.ts @@ -5,6 +5,7 @@ import { SocialProvider, } from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface'; import { makeId } from '@gitroom/nestjs-libraries/services/make.is'; +import { makeSecureId } from '@gitroom/nestjs-libraries/services/make.secure.id'; import { SocialAbstract } from '@gitroom/nestjs-libraries/integrations/social.abstract'; import { Integration } from '@prisma/client'; import { TwitchDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/twitch.dto'; @@ -54,7 +55,7 @@ export class TwitchProvider extends SocialAbstract implements SocialProvider { } async generateAuthUrl() { - const state = makeId(32); + const state = makeSecureId(32); const redirectUri = `${process.env.FRONTEND_URL}/integrations/social/twitch`; @@ -68,7 +69,7 @@ export class TwitchProvider extends SocialAbstract implements SocialProvider { return { url, - codeVerifier: makeId(10), + codeVerifier: makeSecureId(10), state, }; } diff --git a/libraries/nestjs-libraries/src/integrations/social/vk.provider.ts b/libraries/nestjs-libraries/src/integrations/social/vk.provider.ts index 26fa196af2..3ef8c950fc 100644 --- a/libraries/nestjs-libraries/src/integrations/social/vk.provider.ts +++ b/libraries/nestjs-libraries/src/integrations/social/vk.provider.ts @@ -4,7 +4,7 @@ import { PostResponse, SocialProvider, } from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface'; -import { makeId } from '@gitroom/nestjs-libraries/services/make.is'; +import { makeSecureId } from '@gitroom/nestjs-libraries/services/make.secure.id'; import dayjs from 'dayjs'; import { SocialAbstract } from '@gitroom/nestjs-libraries/integrations/social.abstract'; import { createHash, randomBytes } from 'crypto'; @@ -40,7 +40,7 @@ export class VkProvider extends SocialAbstract implements SocialProvider { formData.append('refresh_token', oldRefreshToken); formData.append('client_id', process.env.VK_ID!); formData.append('device_id', device_id); - formData.append('state', makeId(32)); + formData.append('state', makeSecureId(32)); formData.append('scope', this.scopes.join(' ')); const { access_token, refresh_token, expires_in } = await ( @@ -75,7 +75,7 @@ export class VkProvider extends SocialAbstract implements SocialProvider { } async generateAuthUrl() { - const state = makeId(32); + const state = makeSecureId(32); const codeVerifier = randomBytes(64).toString('base64url'); const challenge = Buffer.from( createHash('sha256').update(codeVerifier).digest() diff --git a/libraries/nestjs-libraries/src/integrations/social/whop.provider.ts b/libraries/nestjs-libraries/src/integrations/social/whop.provider.ts index 86295a57ce..6130381d93 100644 --- a/libraries/nestjs-libraries/src/integrations/social/whop.provider.ts +++ b/libraries/nestjs-libraries/src/integrations/social/whop.provider.ts @@ -6,7 +6,7 @@ import { PostResponse, SocialProvider, } from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface'; -import { makeId } from '@gitroom/nestjs-libraries/services/make.is'; +import { makeSecureId } from '@gitroom/nestjs-libraries/services/make.secure.id'; import { timer } from '@gitroom/helpers/utils/timer'; import { BadBody, @@ -103,10 +103,10 @@ export class WhopProvider extends SocialAbstract implements SocialProvider { } async generateAuthUrl() { - const state = makeId(6); + const state = makeSecureId(6); const codeVerifier = randomBytes(32).toString('base64url'); const codeChallenge = this.generateCodeChallenge(codeVerifier); - const nonce = makeId(16); + const nonce = makeSecureId(16); return { url: diff --git a/libraries/nestjs-libraries/src/integrations/social/wordpress.provider.ts b/libraries/nestjs-libraries/src/integrations/social/wordpress.provider.ts index d7ba3d7731..2f9b597847 100644 --- a/libraries/nestjs-libraries/src/integrations/social/wordpress.provider.ts +++ b/libraries/nestjs-libraries/src/integrations/social/wordpress.provider.ts @@ -7,7 +7,7 @@ import { import { SocialAbstract } from '@gitroom/nestjs-libraries/integrations/social.abstract'; import dayjs from 'dayjs'; import { Integration } from '@prisma/client'; -import { makeId } from '@gitroom/nestjs-libraries/services/make.is'; +import { makeSecureId } from '@gitroom/nestjs-libraries/services/make.secure.id'; import { WordpressDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/wordpress.dto'; import slugify from 'slugify'; // import FormData from 'form-data'; @@ -31,10 +31,10 @@ export class WordpressProvider } async generateAuthUrl() { - const state = makeId(6); + const state = makeSecureId(6); return { url: state, - codeVerifier: makeId(10), + codeVerifier: makeSecureId(10), state, }; } diff --git a/libraries/nestjs-libraries/src/integrations/social/youtube.provider.ts b/libraries/nestjs-libraries/src/integrations/social/youtube.provider.ts index 2e9263bcae..7c5be707dd 100644 --- a/libraries/nestjs-libraries/src/integrations/social/youtube.provider.ts +++ b/libraries/nestjs-libraries/src/integrations/social/youtube.provider.ts @@ -7,7 +7,7 @@ import { SocialProvider, } from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface'; import { Integration } from '@prisma/client'; -import { makeId } from '@gitroom/nestjs-libraries/services/make.is'; +import { makeSecureId } from '@gitroom/nestjs-libraries/services/make.secure.id'; import { google } from 'googleapis'; import { OAuth2Client } from 'google-auth-library/build/src/auth/oauth2client'; import { YoutubeSettingsDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/youtube.settings.dto'; @@ -283,7 +283,7 @@ export class YoutubeProvider extends SocialAbstract implements SocialProvider { } async generateAuthUrl() { - const state = makeId(7); + const state = makeSecureId(7); const { client } = clientAndYoutube(); return { url: client.generateAuthUrl({ @@ -293,7 +293,7 @@ export class YoutubeProvider extends SocialAbstract implements SocialProvider { redirect_uri: `${process.env.FRONTEND_URL}/integrations/social/youtube`, scope: this.scopes.slice(0), }), - codeVerifier: makeId(11), + codeVerifier: makeSecureId(11), state, }; } diff --git a/libraries/nestjs-libraries/src/services/make.secure.id.ts b/libraries/nestjs-libraries/src/services/make.secure.id.ts new file mode 100644 index 0000000000..2760b2aa7b --- /dev/null +++ b/libraries/nestjs-libraries/src/services/make.secure.id.ts @@ -0,0 +1,17 @@ +import { randomInt } from 'crypto'; + +// Same alphabet and shape as makeId, but every character comes from the +// OS entropy pool instead of Math.random. Use this for anything that acts +// as a credential: tokens, secrets, api keys, oauth state and PKCE verifiers. +// makeId stays as it is because it is also imported by the frontend and by +// Temporal workflow files, where the crypto module is not available. +export const makeSecureId = (length: number) => { + let text = ''; + const possible = + 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + + for (let i = 0; i < length; i += 1) { + text += possible.charAt(randomInt(possible.length)); + } + return text; +}; From 398b41e8a4d664fc0c734982d886ded177927fde Mon Sep 17 00:00:00 2001 From: Gilad Resisi Date: Tue, 22 Sep 2026 13:36:21 +0700 Subject: [PATCH 16/45] feat(reddit): resolve a pasted r/name or subreddit URL in the subreddit search The composer's "Search Subreddit" box sent the typed text verbatim to Reddit's subreddit search. A pasted reddit.com/r/ 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/, 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 --- .../integrations/social/reddit.provider.ts | 70 ++++++++++++++++--- 1 file changed, 59 insertions(+), 11 deletions(-) diff --git a/libraries/nestjs-libraries/src/integrations/social/reddit.provider.ts b/libraries/nestjs-libraries/src/integrations/social/reddit.provider.ts index 3d4eac5852..46c1468293 100644 --- a/libraries/nestjs-libraries/src/integrations/social/reddit.provider.ts +++ b/libraries/nestjs-libraries/src/integrations/social/reddit.provider.ts @@ -10,6 +10,7 @@ import { RedditSettingsDto } from '@gitroom/nestjs-libraries/dtos/posts/provider import { timer } from '@gitroom/helpers/utils/timer'; import { BadBody, + Disconnect, RefreshToken, SocialAbstract, ValidityMedia, @@ -687,11 +688,19 @@ export class RedditProvider extends SocialAbstract implements SocialProvider { ], }) async subreddits(accessToken: string, data: any) { + // A pasted "r/name" or reddit.com/r/name URL: search by the name alone (the + // full URL matches nothing) and put that exact subreddit first if it exists. + const named = String(data.word || '').match(/(?:^|\/)r\/([A-Za-z0-9_]+)/); + const word = named ? named[1] : data.word; + const exact = named + ? await this.subredditByName(accessToken, named[1]) + : []; + const { data: { children }, } = await ( await this.fetch( - `https://oauth.reddit.com/subreddits/search?show=public&q=${data.word}&sort=activity&show_users=false&limit=10`, + `https://oauth.reddit.com/subreddits/search?show=public&q=${word}&sort=activity&show_users=false&limit=10`, { method: 'GET', headers: { @@ -705,16 +714,55 @@ export class RedditProvider extends SocialAbstract implements SocialProvider { ) ).json(); - return children - .filter( - ({ data }: { data: any }) => - data.subreddit_type === 'public' && data.submission_type !== 'image' - ) - .map(({ data: { title, url, id } }: any) => ({ - title, - name: url, - id, - })); + return [ + ...exact, + ...children + .filter( + ({ data }: { data: any }) => + data.subreddit_type === 'public' && + data.submission_type !== 'image' && + !exact.some((e) => e.id === data.id) + ) + .map(({ data: { title, url, id } }: any) => ({ + title, + name: url, + id, + })), + ]; + } + + private async subredditByName(accessToken: string, name: string) { + let about: any; + try { + about = await ( + await this.fetch( + `https://oauth.reddit.com/r/${name}/about`, + { + method: 'GET', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/x-www-form-urlencoded', + }, + }, + 'reddit', + 0, + false + ) + ).json(); + } catch (err) { + if (err instanceof RefreshToken || err instanceof Disconnect) { + throw err; + } + return []; + } + + if (about?.kind !== 't5' || about.data.submission_type === 'image') { + return []; + } + + return [ + { title: about.data.title, name: about.data.url, id: about.data.id }, + ]; } private getPermissions(submissionType: string, allow_images: string) { From 4e83569e56d5e3b1929b0ccddcbf519bf79b426d Mon Sep 17 00:00:00 2001 From: Gilad Resisi Date: Tue, 22 Sep 2026 13:36:40 +0700 Subject: [PATCH 17/45] fix(slack): surface chat.postMessage failures instead of marking the 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 --- .../src/integrations/social/slack.provider.ts | 40 +++++++++++++++++-- 1 file changed, 37 insertions(+), 3 deletions(-) diff --git a/libraries/nestjs-libraries/src/integrations/social/slack.provider.ts b/libraries/nestjs-libraries/src/integrations/social/slack.provider.ts index bcbe01dbe8..1504a61899 100644 --- a/libraries/nestjs-libraries/src/integrations/social/slack.provider.ts +++ b/libraries/nestjs-libraries/src/integrations/social/slack.provider.ts @@ -5,7 +5,11 @@ import { SocialProvider, } from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface'; import { makeId } from '@gitroom/nestjs-libraries/services/make.is'; -import { SocialAbstract } from '@gitroom/nestjs-libraries/integrations/social.abstract'; +import { + BadBody, + RefreshToken, + SocialAbstract, +} from '@gitroom/nestjs-libraries/integrations/social.abstract'; import dayjs from 'dayjs'; import { Integration } from '@prisma/client'; import { SlackDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/slack.dto'; @@ -131,6 +135,32 @@ export class SlackProvider extends SocialAbstract implements SocialProvider { })); } + // Slack answers HTTP 200 with { ok: false, error } on failures, so the post + // used to be marked completed with no message in the channel. + private checkApiError(all: any) { + if (all?.ok !== false) { + return; + } + const json = JSON.stringify(all); + const message = + [all.error, ...(all.errors || [])].filter(Boolean).join(': ') || + 'Slack rejected the request'; + if ( + [ + 'invalid_auth', + 'token_revoked', + 'token_expired', + 'account_inactive', + ].includes(all.error) + ) { + throw new RefreshToken(this.identifier, json, Buffer.from('{}'), message); + } + if (all.error === 'ratelimited') { + throw new Error(message); + } + throw new BadBody(this.identifier, json, Buffer.from('{}'), message); + } + async post( id: string, accessToken: string, @@ -153,7 +183,7 @@ export class SlackProvider extends SocialAbstract implements SocialProvider { }); // Post the main message - const { ts, channel: responseChannel } = await ( + const posted = await ( await fetch(`https://slack.com/api/chat.postMessage`, { method: 'POST', headers: { @@ -183,6 +213,8 @@ export class SlackProvider extends SocialAbstract implements SocialProvider { }), }) ).json(); + this.checkApiError(posted); + const { ts, channel: responseChannel } = posted; // Get permalink for the message const { permalink } = await ( @@ -220,7 +252,7 @@ export class SlackProvider extends SocialAbstract implements SocialProvider { const threadTs = lastCommentId || postId; // Post the threaded reply - const { ts, channel: responseChannel } = await ( + const posted = await ( await fetch(`https://slack.com/api/chat.postMessage`, { method: 'POST', headers: { @@ -251,6 +283,8 @@ export class SlackProvider extends SocialAbstract implements SocialProvider { }), }) ).json(); + this.checkApiError(posted); + const { ts, channel: responseChannel } = posted; // Get permalink for the comment const { permalink } = await ( From 678acd4adc4da261af1a54315504847f6b17800d Mon Sep 17 00:00:00 2001 From: Gilad Resisi Date: Tue, 22 Sep 2026 13:36:58 +0700 Subject: [PATCH 18/45] fix(slack): reject video attachments at save time 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 --- .../src/integrations/social/slack.provider.ts | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/libraries/nestjs-libraries/src/integrations/social/slack.provider.ts b/libraries/nestjs-libraries/src/integrations/social/slack.provider.ts index bcbe01dbe8..d786ffcd68 100644 --- a/libraries/nestjs-libraries/src/integrations/social/slack.provider.ts +++ b/libraries/nestjs-libraries/src/integrations/social/slack.provider.ts @@ -5,7 +5,10 @@ import { SocialProvider, } from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface'; import { makeId } from '@gitroom/nestjs-libraries/services/make.is'; -import { SocialAbstract } from '@gitroom/nestjs-libraries/integrations/social.abstract'; +import { + SocialAbstract, + ValidityMedia, +} from '@gitroom/nestjs-libraries/integrations/social.abstract'; import dayjs from 'dayjs'; import { Integration } from '@prisma/client'; import { SlackDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/slack.dto'; @@ -27,6 +30,20 @@ export class SlackProvider extends SocialAbstract implements SocialProvider { ]; dto = SlackDto; + // Media goes out as Block Kit image blocks, which Slack only accepts for + // png / jpg / gif; an mp4 makes chat.postMessage reject the whole message. + override async checkValidity( + posts: Array + ): Promise { + const hasVideo = posts?.some((post) => + post?.some((item) => (item?.path?.indexOf?.('mp4') ?? -1) > -1) + ); + if (hasVideo) { + return 'No video support for Slack, only images'; + } + return true; + } + maxLength() { return 400000; } From df0dd91a9d245399528baf09339839d82891d48c Mon Sep 17 00:00:00 2001 From: Gilad Resisi Date: Tue, 22 Sep 2026 13:48:07 +0700 Subject: [PATCH 19/45] feat(reddit): only offer public subreddits from the exact r/name lookup 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 --- .../src/integrations/social/reddit.provider.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/libraries/nestjs-libraries/src/integrations/social/reddit.provider.ts b/libraries/nestjs-libraries/src/integrations/social/reddit.provider.ts index 46c1468293..ecc7363c2b 100644 --- a/libraries/nestjs-libraries/src/integrations/social/reddit.provider.ts +++ b/libraries/nestjs-libraries/src/integrations/social/reddit.provider.ts @@ -756,7 +756,11 @@ export class RedditProvider extends SocialAbstract implements SocialProvider { return []; } - if (about?.kind !== 't5' || about.data.submission_type === 'image') { + if ( + about?.kind !== 't5' || + about.data.subreddit_type !== 'public' || + about.data.submission_type === 'image' + ) { return []; } From dcb5b09e0ec6619287393e34c5ce7591d78270f8 Mon Sep 17 00:00:00 2001 From: Nevo David Date: Tue, 22 Sep 2026 17:58:20 +0700 Subject: [PATCH 20/45] fix: allow mov --- libraries/nestjs-libraries/src/upload/r2.uploader.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/libraries/nestjs-libraries/src/upload/r2.uploader.ts b/libraries/nestjs-libraries/src/upload/r2.uploader.ts index 3164d5ed81..f753c4967b 100644 --- a/libraries/nestjs-libraries/src/upload/r2.uploader.ts +++ b/libraries/nestjs-libraries/src/upload/r2.uploader.ts @@ -232,9 +232,15 @@ export async function completeMultipartUpload(req: Request, res: Response) { const prefix = Buffer.concat(chunks); const detected = await fileTypeFromBuffer(prefix); - // a .mov with an ISO brand sniffs as video/mp4; the normalizer reads both + // .mov and .mp4 are the same ISO BMFF family: a .mov with an ISO brand + // sniffs as video/mp4 and a QuickTime-brand file is often named .mp4. + // The normalizer reads both and always writes an mp4, so accept both + // for either extension whenever it is on; without it .mp4 stays strict. const acceptedMimes = - safeExt === '.mov' ? ['video/quicktime', 'video/mp4'] : [expectedMime]; + UploadFactory.processorEnabled() && + (safeExt === '.mov' || safeExt === '.mp4') + ? ['video/quicktime', 'video/mp4'] + : [expectedMime]; if (!detected || !acceptedMimes.includes(detected.mime)) { await R2.send( new DeleteObjectCommand({ Bucket: CLOUDFLARE_BUCKETNAME, Key: key }) From 8b84b0dc2767b757c2355158573635600ae027c7 Mon Sep 17 00:00:00 2001 From: Nevo David Date: Tue, 22 Sep 2026 20:46:21 +0700 Subject: [PATCH 21/45] feat: onboarding --- .../onboarding/onboarding.modal.tsx | 41 ++++++------- .../public-api/public.component.tsx | 58 ++++++++++++++++--- 2 files changed, 67 insertions(+), 32 deletions(-) diff --git a/apps/frontend/src/components/onboarding/onboarding.modal.tsx b/apps/frontend/src/components/onboarding/onboarding.modal.tsx index 2f4bb8db06..e5b81009fc 100644 --- a/apps/frontend/src/components/onboarding/onboarding.modal.tsx +++ b/apps/frontend/src/components/onboarding/onboarding.modal.tsx @@ -15,12 +15,12 @@ import { AnyMcpClient, CopyButton, getMcpConfig, - getMcpOauthUrl, isChatOnlyMcpClient, localCliSteps, McpAuth, McpClient, mcpClients, + mcpConnectorUrls, } from '@gitroom/frontend/components/public-api/public.component'; import { McpClientIcon } from '@gitroom/frontend/components/public-api/mcp.client.icons'; @@ -276,24 +276,6 @@ type OnboardingTab = OnboardingAgent | typeof otherTab | typeof apiTab; const cliCommands = localCliSteps.map((step) => step.code); -// Cursor one-click install: https://cursor.com/docs/mcp/install-links -const getCursorInstallUrl = ( - auth: McpAuth, - mcpBase: string, - apiKey: string -) => { - const server = - auth === 'oauth' - ? { url: getMcpOauthUrl(mcpBase) } - : { - url: `${mcpBase}/mcp`, - headers: { Authorization: `Bearer ${apiKey}` }, - }; - return `cursor://anysphere.cursor-deeplink/mcp/install?name=postiz&config=${btoa( - JSON.stringify(server) - )}`; -}; - const OnboardingStep2: FC<{ onBack: () => void; onNext: () => void }> = ({ onBack, onNext, @@ -328,14 +310,24 @@ const OnboardingStep2: FC<{ onBack: () => void; onNext: () => void }> = ({ const connector = agent === 'Claude' && billingEnabled ? { - href: 'https://claude.ai/directory/postiz', + href: mcpConnectorUrls.Claude, label: t('add_to_claude', 'Add to Claude'), } - : agent === 'Cursor' + : agent === 'ChatGPT' && billingEnabled ? { - href: getCursorInstallUrl(auth, mcpBase, apiKey), + href: mcpConnectorUrls.ChatGPT, + label: t('add_to_chatgpt', 'Add to ChatGPT'), + } + : agent === 'Cursor' && billingEnabled + ? { + href: mcpConnectorUrls.Cursor, label: t('add_to_cursor', 'Add to Cursor'), } + : agent === 'Grok Bot' && billingEnabled + ? { + href: mcpConnectorUrls['Grok Bot'], + label: t('add_to_grok_bot', 'Add to Grok Bot'), + } : null; const maskedApiKey = revealed ? apiKey : '*'.repeat(apiKey.length); @@ -610,7 +602,10 @@ const OnboardingStep2: FC<{ onBack: () => void; onNext: () => void }> = ({ {agent === apiTab ? ( apiSection ) : isChatOnlyMcpClient(agent) ? ( - chatSection + <> + {connectorSection} + {chatSection} + ) : ( <> {connectorSection} diff --git a/apps/frontend/src/components/public-api/public.component.tsx b/apps/frontend/src/components/public-api/public.component.tsx index 24afb92bcc..a47f576ccf 100644 --- a/apps/frontend/src/components/public-api/public.component.tsx +++ b/apps/frontend/src/components/public-api/public.component.tsx @@ -21,6 +21,16 @@ export const remoteMcpClients = { 'In ChatGPT go to Settings > Connectors > Create and paste this URL.', } as const; +// Official one-click connectors listed in the assistants' directories. +// Only for the hosted Postiz (billingEnabled), they point at the public MCP server. +export const mcpConnectorUrls = { + Claude: 'https://claude.ai/directory/postiz', + ChatGPT: + 'https://chatgpt.com/plugins/plugin_asdk_app_6aaaf1a529808191a2a15fde824bb013', + Cursor: 'https://cursor.com/marketplace/postiz', + 'Grok Bot': 'https://x.ai/bot/plugin/58737848', +} as const; + // Clients with no MCP or CLI settings: you paste instructions into the chat, // the agent installs the CLI itself and asks you for the API key export const chatOnlyMcpClients = { @@ -333,14 +343,24 @@ const McpSection = ({ From 87ac77c699534eb9b3a81c7e3a0272d6cd3a4f2e Mon Sep 17 00:00:00 2001 From: Nevo David Date: Tue, 22 Sep 2026 23:56:44 +0700 Subject: [PATCH 22/45] feat: update readme --- .github/agents/ai-agents-cli.svg | 1 + .github/agents/chatgpt.svg | 1 + .github/agents/claude-code.svg | 1 + .github/agents/claude-cowork.svg | 1 + .github/agents/claude.svg | 1 + .github/agents/codex.svg | 1 + .github/agents/cursor.svg | 1 + .github/agents/grok-bot.svg | 1 + .github/agents/grok-build.svg | 1 + .github/agents/hermes-agent.svg | 1 + .github/agents/mcp-server.svg | 1 + .github/agents/muse.svg | 1 + .github/agents/nanoclaw.svg | 1 + .github/agents/openclaw.svg | 1 + .github/agents/paperclip.svg | 1 + .github/agents/perplexity-computer.svg | 1 + README.md | 22 ++++++++++++++++++++-- 17 files changed, 36 insertions(+), 2 deletions(-) create mode 100644 .github/agents/ai-agents-cli.svg create mode 100644 .github/agents/chatgpt.svg create mode 100644 .github/agents/claude-code.svg create mode 100644 .github/agents/claude-cowork.svg create mode 100644 .github/agents/claude.svg create mode 100644 .github/agents/codex.svg create mode 100644 .github/agents/cursor.svg create mode 100644 .github/agents/grok-bot.svg create mode 100644 .github/agents/grok-build.svg create mode 100644 .github/agents/hermes-agent.svg create mode 100644 .github/agents/mcp-server.svg create mode 100644 .github/agents/muse.svg create mode 100644 .github/agents/nanoclaw.svg create mode 100644 .github/agents/openclaw.svg create mode 100644 .github/agents/paperclip.svg create mode 100644 .github/agents/perplexity-computer.svg diff --git a/.github/agents/ai-agents-cli.svg b/.github/agents/ai-agents-cli.svg new file mode 100644 index 0000000000..620a2378ab --- /dev/null +++ b/.github/agents/ai-agents-cli.svg @@ -0,0 +1 @@ + diff --git a/.github/agents/chatgpt.svg b/.github/agents/chatgpt.svg new file mode 100644 index 0000000000..ae9dd30105 --- /dev/null +++ b/.github/agents/chatgpt.svg @@ -0,0 +1 @@ + diff --git a/.github/agents/claude-code.svg b/.github/agents/claude-code.svg new file mode 100644 index 0000000000..7eff60377f --- /dev/null +++ b/.github/agents/claude-code.svg @@ -0,0 +1 @@ + diff --git a/.github/agents/claude-cowork.svg b/.github/agents/claude-cowork.svg new file mode 100644 index 0000000000..a56712dbe1 --- /dev/null +++ b/.github/agents/claude-cowork.svg @@ -0,0 +1 @@ + diff --git a/.github/agents/claude.svg b/.github/agents/claude.svg new file mode 100644 index 0000000000..a56712dbe1 --- /dev/null +++ b/.github/agents/claude.svg @@ -0,0 +1 @@ + diff --git a/.github/agents/codex.svg b/.github/agents/codex.svg new file mode 100644 index 0000000000..272e7dba4d --- /dev/null +++ b/.github/agents/codex.svg @@ -0,0 +1 @@ + diff --git a/.github/agents/cursor.svg b/.github/agents/cursor.svg new file mode 100644 index 0000000000..15f4fd625e --- /dev/null +++ b/.github/agents/cursor.svg @@ -0,0 +1 @@ + diff --git a/.github/agents/grok-bot.svg b/.github/agents/grok-bot.svg new file mode 100644 index 0000000000..711a07bcb5 --- /dev/null +++ b/.github/agents/grok-bot.svg @@ -0,0 +1 @@ + diff --git a/.github/agents/grok-build.svg b/.github/agents/grok-build.svg new file mode 100644 index 0000000000..d7fe50c514 --- /dev/null +++ b/.github/agents/grok-build.svg @@ -0,0 +1 @@ + diff --git a/.github/agents/hermes-agent.svg b/.github/agents/hermes-agent.svg new file mode 100644 index 0000000000..23e1afb212 --- /dev/null +++ b/.github/agents/hermes-agent.svg @@ -0,0 +1 @@ + diff --git a/.github/agents/mcp-server.svg b/.github/agents/mcp-server.svg new file mode 100644 index 0000000000..f5b3f33d11 --- /dev/null +++ b/.github/agents/mcp-server.svg @@ -0,0 +1 @@ + diff --git a/.github/agents/muse.svg b/.github/agents/muse.svg new file mode 100644 index 0000000000..40d5cd1d9c --- /dev/null +++ b/.github/agents/muse.svg @@ -0,0 +1 @@ + diff --git a/.github/agents/nanoclaw.svg b/.github/agents/nanoclaw.svg new file mode 100644 index 0000000000..a30d9658d6 --- /dev/null +++ b/.github/agents/nanoclaw.svg @@ -0,0 +1 @@ +n diff --git a/.github/agents/openclaw.svg b/.github/agents/openclaw.svg new file mode 100644 index 0000000000..7ec7b1da33 --- /dev/null +++ b/.github/agents/openclaw.svg @@ -0,0 +1 @@ + diff --git a/.github/agents/paperclip.svg b/.github/agents/paperclip.svg new file mode 100644 index 0000000000..1a7a9bc4b2 --- /dev/null +++ b/.github/agents/paperclip.svg @@ -0,0 +1 @@ + diff --git a/.github/agents/perplexity-computer.svg b/.github/agents/perplexity-computer.svg new file mode 100644 index 0000000000..10599bbcd3 --- /dev/null +++ b/.github/agents/perplexity-computer.svg @@ -0,0 +1 @@ + diff --git a/README.md b/README.md index f982792668..ec0adfa0b9 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,6 @@

-

NEW: check out Postiz agent CLI! perfect for OpenClaw and other agents

Your ultimate AI social media scheduling tool


@@ -22,8 +21,8 @@ Postiz offers everything you need to manage your social media posts,
build an audience, capture leads, and grow your business.
+

Schedule posts to:

-
Instagram Youtube Dribbble @@ -40,6 +39,25 @@ Bluesky
+

With your favorite AI agent:

+
+ ChatGPT + Claude + Claude Code + Codex + Cursor + OpenClaw + Hermes Agent + Grok Bot + Grok Build + Muse + Perplexity Computer + nanoclaw + Paperclip + MCP Server + AI Agents CLI +
+


Explore the docs » From c33f2188f81c2d5b9a6ab93388a965f0254f569f Mon Sep 17 00:00:00 2001 From: Nevo David Date: Wed, 23 Sep 2026 00:00:12 +0700 Subject: [PATCH 23/45] feat: update readme --- README.md | 56 +++++++++++++++++++++++++++---------------------------- 1 file changed, 27 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index ec0adfa0b9..0d4ddb5ff0 100644 --- a/README.md +++ b/README.md @@ -15,12 +15,35 @@

-

Your ultimate AI social media scheduling tool


- Postiz: An alternative to: Buffer.com, Hypefury, Twitter Hunter, etc...

+ Postiz offers everything you need to manage your social media posts,
build an audience, capture leads, and grow your business.
- Postiz offers everything you need to manage your social media posts,
build an audience, capture leads, and grow your business.
+

+
+ Explore the docs » +
+ +
+ Watch the YouTube Tutorials» +
+

+ +

+ Register + · + Join Our Discord (devs only) + · + Public API
+

+

+ NodeJS SDK + · + N8N custom node + · + Make.com integration +

+

Schedule posts to:

Instagram @@ -58,32 +81,7 @@ AI Agents CLI
-

-
- Explore the docs » -
- -
- Watch the YouTube Tutorials» -
-

- -

- Register - · - Join Our Discord (devs only) - · - Public API
-

-

- NodeJS SDK - · - N8N custom node - · - Make.com integration -

- -

+
## 🔌 See the leading Postiz features From 5ff9e0b2f6f71639ea7bc52fb6d50804d9084a7b Mon Sep 17 00:00:00 2001 From: Nevo David Date: Wed, 23 Sep 2026 00:01:18 +0700 Subject: [PATCH 24/45] feat: update readme --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 0d4ddb5ff0..9fe3c39ee8 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,8 @@ Make.com integration

+
+

Schedule posts to:

Instagram From 305f7c0d13c84aca78f98b5b4eb2ce34607b4369 Mon Sep 17 00:00:00 2001 From: Nevo David <100117126+nevo-david@users.noreply.github.com> Date: Wed, 23 Sep 2026 10:10:13 +0700 Subject: [PATCH 25/45] Enhance README with Postiz Cloud vs Open-source section Added a comparison between Postiz Cloud and Open-source versions, detailing features, costs, and setup differences. --- README.md | 42 ++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 40 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 9fe3c39ee8..5924b50d00 100644 --- a/README.md +++ b/README.md @@ -133,15 +133,53 @@ To have the project up and running, please follow the [Quick Start Guide](https: ## Sponsor Postiz -We now give a few options to Sponsor Postiz: +We now offer a few options to sponsor Postiz: - Just a donation: You like what we are building, and want to buy us some coffee so we can build faster. - Main repository: Get your logo with a backlink from the main Postiz repository. Postiz has over 7M downloads and 20k views per month. Link: https://opencollective.com/postiz +
+
+ +## Postiz Cloud vs. Open-source + +[Postiz Cloud](https://postiz.com/) and Postiz self-hosted are identical. + +We do not "gate" features or limit the license. + +The main difference is the infrastructure you need to own, approval from social media providers, and deployment that might be hard at times (let your LLM deploy it) + +| Area | Postiz Cloud | Postiz Open-source (self-hosted) | +|---|---|---| +| **Cost** | Subscription per plan, 7-day free trial | Free forever (AGPL-3.0); you pay only for your own infra | +| **Setup time** | Sign up and connect channels in minutes | Deploy with Docker / Coolify / Railway / any VPS; you configure Postgres, Redis, storage and env vars | +| **Hosting & data** | Hosted by Postiz; data stored in our infrastructure | Runs on your own server; data never leaves your environment | +| **Social platform apps** | Pre-approved apps for every channel, ready to use | You create your own developer apps on each platform and go through their approval (Meta, YouTube, TikTok can take weeks) | +| **Channels** | Limited by plan tier | Unlimited, every supported provider | +| **Posts per month** | Limited by plan tier | Unlimited | +| **Team members** | Limited by plan tier | Unlimited | +| **Scheduling, calendar views, cross-posting, repeated posts, post comments & delays, sets, signatures** | Included | Included | +| **Internal & Global Plugs, RSS auto-post, customer groups** | Included per plan | Included | +| **Analytics** | Included per plan | Included (requires your own app credentials with analytics scopes) | +| **AI Copilot, AI images, AI videos** | Included with monthly quotas per plan; keys managed by Postiz | Available if you bring your own OpenAI (and other provider) API keys; no quota, you pay the provider | +| **AI video clipping** | Included with monthly clipping minutes per plan | Requires your own provider keys and extra configuration | +| **Smart Agent** | Included per plan | Available with your own LLM key | +| **Public API & webhooks** | Included per plan | Included | +| **Agentic surfaces (MCP, CLI, Claude / ChatGPT / Codex / OpenClaw / Cursor connectors)** | Included, hosted MCP endpoint | Included, you point the MCP / CLI at your own instance | +| **Custom integrations** | Included per plan | Included; you can also modify the code and add providers | +| **Updates & maintenance** | Automatic, zero downtime for you | You pull new images and run migrations yourself | +| **Uptime, backups, security patches** | Managed by Postiz | Your responsibility | +| **Support** | Priority support via Discord / email per plan | Community support on Discord and GitHub | +| **Source access & customization** | No (SaaS) | Full source code, fork and modify freely under AGPL | +| **Compliance / data residency** | Postiz-controlled regions | Any region or air-gapped environment you choose | + +
+
+ ## Postiz Compliance -- Postiz is an open-source, self-hosted social media scheduling tool that supports platforms like X (formerly Twitter), Bluesky, Mastodon, Discord, and others. +- Postiz on GitHub is an open-source, self-hosted social media scheduling tool that supports platforms like X (formerly Twitter), Bluesky, Mastodon, Discord, and others. - Postiz hosted service uses official, platform-approved OAuth flows. - Postiz does not automate or scrape content from social media platforms. - Postiz does not collect, store, or proxy API keys or access tokens from users. From 38cb6a41c7c0428e3e1fa8c57c8557122c091474 Mon Sep 17 00:00:00 2001 From: Nevo David <100117126+nevo-david@users.noreply.github.com> Date: Wed, 23 Sep 2026 10:14:08 +0700 Subject: [PATCH 26/45] Update Postiz compliance information in README Clarify the description of the Postiz project and its compliance. --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 5924b50d00..ecd109802e 100644 --- a/README.md +++ b/README.md @@ -179,6 +179,7 @@ The main difference is the infrastructure you need to own, approval from social ## Postiz Compliance +- This GitHub repository contains the open-source, self-hosted edition of Postiz. Postiz is also available as Postiz Cloud, a fully managed service at postiz.com. - Postiz on GitHub is an open-source, self-hosted social media scheduling tool that supports platforms like X (formerly Twitter), Bluesky, Mastodon, Discord, and others. - Postiz hosted service uses official, platform-approved OAuth flows. - Postiz does not automate or scrape content from social media platforms. From b90fc691c1ee6e6292ad9017db1c7210802e3554 Mon Sep 17 00:00:00 2001 From: Nevo David <100117126+nevo-david@users.noreply.github.com> Date: Wed, 23 Sep 2026 10:20:20 +0700 Subject: [PATCH 27/45] Revise README for clarity on Postiz offerings Updated the README to clarify the differences between Postiz Cloud and the self-hosted version, emphasizing the managed experience and deployment options. --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index ecd109802e..54a9d9d9d9 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,9 @@
- Postiz offers everything you need to manage your social media posts,
build an audience, capture leads, and grow your business. + Postiz is a social media management platform for scheduling, automating, and analyzing your content. + + Use Postiz Cloud for a fully managed experience, or deploy the open-source edition on your own infrastructure.
@@ -115,7 +117,6 @@ - Measure your work with analytics. - Collaborate with other team members to exchange or buy posts. - Invite your team members to collaborate, comment, and schedule posts. -- At the moment, there is no difference between the hosted version and the self-hosted version - Perfect for automation (API) with platforms like N8N, Make.com, Zapier, etc. ## Tech Stack @@ -144,7 +145,7 @@ Link: https://opencollective.com/postiz ## Postiz Cloud vs. Open-source -[Postiz Cloud](https://postiz.com/) and Postiz self-hosted are identical. +Choose [Postiz Cloud](https://postiz.com/) for a fully managed experience, or deploy Postiz Open-source on your own infrastructure. Both provide the same core Postiz product and features. We do not "gate" features or limit the license. @@ -180,7 +181,6 @@ The main difference is the infrastructure you need to own, approval from social ## Postiz Compliance - This GitHub repository contains the open-source, self-hosted edition of Postiz. Postiz is also available as Postiz Cloud, a fully managed service at postiz.com. -- Postiz on GitHub is an open-source, self-hosted social media scheduling tool that supports platforms like X (formerly Twitter), Bluesky, Mastodon, Discord, and others. - Postiz hosted service uses official, platform-approved OAuth flows. - Postiz does not automate or scrape content from social media platforms. - Postiz does not collect, store, or proxy API keys or access tokens from users. From fa93effac5b03b815c7dec8abdac9ca0b199be88 Mon Sep 17 00:00:00 2001 From: Gilad Resisi Date: Wed, 23 Sep 2026 11:12:25 +0700 Subject: [PATCH 28/45] fix(frontend): stop the Sentry report dialog on the posthog-js recorder error posthog-js throws "Called on script loaded before session recording is available" when its session-recording script's load event fires before the recorder extension is registered. The frontend beforeSend hook shows the Sentry user-report dialog for every captured exception, so users hit the "Something broke!" form on ordinary page loads (login, launches, channel connect) with nothing actually wrong. Sentry issue CLOUD-QP: 656 events, 91 users since March 2026; three user-feedback reports in one week. Add the message to ignorePatterns so the event is dropped before the dialog branch, matching the existing network-error entries. Co-Authored-By: Claude Fable 5.1 --- .../src/sentry/initialize.sentry.next.basic.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/libraries/react-shared-libraries/src/sentry/initialize.sentry.next.basic.ts b/libraries/react-shared-libraries/src/sentry/initialize.sentry.next.basic.ts index fecf778ecb..b3f067d7f0 100644 --- a/libraries/react-shared-libraries/src/sentry/initialize.sentry.next.basic.ts +++ b/libraries/react-shared-libraries/src/sentry/initialize.sentry.next.basic.ts @@ -13,6 +13,7 @@ export const initializeSentryBasic = (environment: string, dsn: string, extensio /^NetworkError when attempting to fetch resource\.$/i, /^NetworkError when attempting to fetch resource\. .*/i, /^Object captured as promise rejection with keys: code, message$/i, + /^Called on script loaded before session recording is available$/i, ]; // Browser wallet extensions (Phantom, MetaMask, etc.) reject with a plain From 28730a0c1b2d49e857c61a975711af730b2c0bd0 Mon Sep 17 00:00:00 2001 From: Gilad Resisi Date: Wed, 9 Sep 2026 18:11:14 +0700 Subject: [PATCH 29/45] fix(instagram): disconnect the channel on Meta checkpoint errors instead of failing every post Code-190 bodies "You cannot access the app till you log in to www.instagram.com" and "Session key is malformed" now map to disconnect, so the channel is flagged for reconnect once instead of every scheduled post failing with a misleading permissions message. --- .../integrations/social/instagram.provider.ts | 16 +++++++++++++++- .../social/instagram.standalone.provider.ts | 5 ++++- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/libraries/nestjs-libraries/src/integrations/social/instagram.provider.ts b/libraries/nestjs-libraries/src/integrations/social/instagram.provider.ts index b9eb4a2e99..8800a5ce99 100644 --- a/libraries/nestjs-libraries/src/integrations/social/instagram.provider.ts +++ b/libraries/nestjs-libraries/src/integrations/social/instagram.provider.ts @@ -103,7 +103,7 @@ export class InstagramProvider status: number ): | { - type: 'refresh-token' | 'bad-body' | 'retry'; + type: 'refresh-token' | 'bad-body' | 'retry' | 'disconnect'; value: string; } | undefined { @@ -330,6 +330,20 @@ export class InstagramProvider }; } + // Meta put the account behind a checkpoint: the token is still valid, so a + // refresh cannot help and every post fails until the user logs in on + // Instagram and re-connects the channel. + if ( + body.indexOf('You cannot access the app till you log in to') > -1 || + body.indexOf('Session key is malformed') > -1 + ) { + return { + type: 'disconnect' as const, + value: + 'Instagram requires you to log in at instagram.com and follow its instructions before posting can resume. After that, please reconnect this channel.', + }; + } + if (body.indexOf('190,') > -1) { return { type: 'bad-body' as const, diff --git a/libraries/nestjs-libraries/src/integrations/social/instagram.standalone.provider.ts b/libraries/nestjs-libraries/src/integrations/social/instagram.standalone.provider.ts index 4c3261fbd3..db023cdf58 100644 --- a/libraries/nestjs-libraries/src/integrations/social/instagram.standalone.provider.ts +++ b/libraries/nestjs-libraries/src/integrations/social/instagram.standalone.provider.ts @@ -68,7 +68,10 @@ export class InstagramStandaloneProvider body: string, status: number ): - | { type: 'refresh-token' | 'bad-body' | 'retry'; value: string } + | { + type: 'refresh-token' | 'bad-body' | 'retry' | 'disconnect'; + value: string; + } | undefined { return instagramProvider.handleErrors(body, status); } From 5536d8a73a9cd66d6e9b6eb72e1d259dd2800aaa Mon Sep 17 00:00:00 2001 From: Gilad Resisi Date: Wed, 9 Sep 2026 15:13:09 +0700 Subject: [PATCH 30/45] fix(threads): retry publish when Threads has not found the container yet (4279009) Threads sometimes answers threads_publish with "Media Not Found" seconds after the container was created. Map subcode 4279009 to retry so the publish call gets a few more attempts instead of failing the post with "Unknown Error". --- .../src/integrations/social/threads.provider.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/libraries/nestjs-libraries/src/integrations/social/threads.provider.ts b/libraries/nestjs-libraries/src/integrations/social/threads.provider.ts index 4538ea239e..cac4d40bc4 100644 --- a/libraries/nestjs-libraries/src/integrations/social/threads.provider.ts +++ b/libraries/nestjs-libraries/src/integrations/social/threads.provider.ts @@ -40,7 +40,7 @@ export class ThreadsProvider extends SocialAbstract implements SocialProvider { override handleErrors(body: string): | { - type: 'refresh-token' | 'bad-body'; + type: 'refresh-token' | 'bad-body' | 'retry'; value: string; } | undefined { @@ -71,6 +71,13 @@ export class ThreadsProvider extends SocialAbstract implements SocialProvider { "One of the media URLs is invalid or inaccessible, make sure it's being uploaded to Postiz first", }; } + if (body.includes('4279009')) { + return { + type: 'retry', + value: + 'Threads could not find the media container yet, please try again in a few seconds', + }; + } if (body.includes('text must be at most 500 characters')) { return { type: 'bad-body', From 002a341bcce2d4888fabd1cc99018f5382b1a877 Mon Sep 17 00:00:00 2001 From: Gilad Resisi Date: Wed, 9 Sep 2026 15:10:47 +0700 Subject: [PATCH 31/45] fix(pinterest): make the numeric board id mapping actually match The match string carried 8 literal backslashes while Pinterest's response carries 4, so the mapping never fired and posts failed with "Unknown Error". Match on backslash-free fragments of the message instead. --- .../src/integrations/social/pinterest.provider.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/libraries/nestjs-libraries/src/integrations/social/pinterest.provider.ts b/libraries/nestjs-libraries/src/integrations/social/pinterest.provider.ts index 7f6354b280..40251c71d2 100644 --- a/libraries/nestjs-libraries/src/integrations/social/pinterest.provider.ts +++ b/libraries/nestjs-libraries/src/integrations/social/pinterest.provider.ts @@ -128,7 +128,10 @@ export class PinterestProvider 'Pinterest was unable to reach the URL provided. Please check the link and try again.', }; } - if (body.indexOf(`does not match '^\\\\\\\\\\\\\\\\d+$'`) > -1) { + if ( + body.indexOf("does not match '^") > -1 && + body.indexOf("d+$'") > -1 + ) { return { type: 'bad-body' as const, value: From 6fb5df417eb87efbd0f0c3e9a6ab54f648e319a9 Mon Sep 17 00:00:00 2001 From: Gilad Resisi Date: Wed, 23 Sep 2026 14:41:10 +0700 Subject: [PATCH 32/45] fix(reddit): retry the submit after a RATELIMIT rejection instead of 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 --- .../src/integrations/social/reddit.provider.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/libraries/nestjs-libraries/src/integrations/social/reddit.provider.ts b/libraries/nestjs-libraries/src/integrations/social/reddit.provider.ts index 201f00769e..a1a6c219d5 100644 --- a/libraries/nestjs-libraries/src/integrations/social/reddit.provider.ts +++ b/libraries/nestjs-libraries/src/integrations/social/reddit.provider.ts @@ -520,6 +520,14 @@ export class RedditProvider extends SocialAbstract implements SocialProvider { // Reddit rejects submissions with a 200 and an errors array: surface the // real reason instead of failing later with an unknown outcome. if (all?.json?.errors?.length) { + // A rate limit is a refusal, nothing was submitted: disarm the marker so + // the next check re-arms this subreddit and submits it again once the + // window has passed, instead of failing the whole post. + if (all.json.errors.some((e: any[]) => e?.[0] === 'RATELIMIT')) { + data.armed = undefined; + return { status: 'pending', pendingData: data }; + } + throw new BadBody( this.identifier, JSON.stringify(all), From 70499563d200fef9394a6e6f9f81f64107319557 Mon Sep 17 00:00:00 2001 From: Gilad Resisi Date: Wed, 9 Sep 2026 15:00:24 +0700 Subject: [PATCH 33/45] fix(facebook): map five recurring Graph API rejections instead of "Unknown 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". --- .../integrations/social/facebook.provider.ts | 39 ++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/libraries/nestjs-libraries/src/integrations/social/facebook.provider.ts b/libraries/nestjs-libraries/src/integrations/social/facebook.provider.ts index fff046d021..fa911f328c 100644 --- a/libraries/nestjs-libraries/src/integrations/social/facebook.provider.ts +++ b/libraries/nestjs-libraries/src/integrations/social/facebook.provider.ts @@ -64,7 +64,7 @@ export class FacebookProvider extends SocialAbstract implements SocialProvider { status: number ): | { - type: 'refresh-token' | 'bad-body'; + type: 'refresh-token' | 'bad-body' | 'retry'; value: string; } | undefined { @@ -229,6 +229,43 @@ export class FacebookProvider extends SocialAbstract implements SocialProvider { value: 'Facebook return: No permission to publish the video', }; } + if (body.indexOf('"error_subcode":459') > -1) { + return { + type: 'bad-body' as const, + value: + 'Facebook is asking you to resolve a security check. Log in at facebook.com, complete it, then try again', + }; + } + if (body.indexOf('"error_subcode":492') > -1) { + return { + type: 'bad-body' as const, + value: + 'Your Facebook user no longer has a role on this Page. Ask a Page admin to grant you a role, then reconnect the channel', + }; + } + if (body.indexOf('must be granted before impersonating') > -1) { + return { + type: 'refresh-token' as const, + value: + 'Facebook Page permissions are missing, please reconnect the channel and allow all permissions', + }; + } + if ( + body.indexOf('"error_subcode":33') > -1 && + body.indexOf('does not exist') > -1 + ) { + return { + type: 'bad-body' as const, + value: + 'The Facebook Page or post this was targeting no longer exists, please reconnect the channel and schedule again', + }; + } + if (body.indexOf('Sorry, something went wrong') > -1) { + return { + type: 'retry' as const, + value: 'Facebook is temporarily unavailable, please try again later', + }; + } if (body.indexOf('490') > -1) { return { type: 'refresh-token' as const, From 8b5999b8d1c4bbbf0047bdab3775a03ed493496a Mon Sep 17 00:00:00 2001 From: Gilad Resisi Date: Wed, 9 Sep 2026 15:15:35 +0700 Subject: [PATCH 34/45] fix(facebook): match error subcodes on a word boundary "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. --- .../src/integrations/social/facebook.provider.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/libraries/nestjs-libraries/src/integrations/social/facebook.provider.ts b/libraries/nestjs-libraries/src/integrations/social/facebook.provider.ts index fa911f328c..33c69599b7 100644 --- a/libraries/nestjs-libraries/src/integrations/social/facebook.provider.ts +++ b/libraries/nestjs-libraries/src/integrations/social/facebook.provider.ts @@ -229,14 +229,14 @@ export class FacebookProvider extends SocialAbstract implements SocialProvider { value: 'Facebook return: No permission to publish the video', }; } - if (body.indexOf('"error_subcode":459') > -1) { + if (/"error_subcode":459\b/.test(body)) { return { type: 'bad-body' as const, value: 'Facebook is asking you to resolve a security check. Log in at facebook.com, complete it, then try again', }; } - if (body.indexOf('"error_subcode":492') > -1) { + if (/"error_subcode":492\b/.test(body)) { return { type: 'bad-body' as const, value: @@ -251,7 +251,7 @@ export class FacebookProvider extends SocialAbstract implements SocialProvider { }; } if ( - body.indexOf('"error_subcode":33') > -1 && + /"error_subcode":33\b/.test(body) && body.indexOf('does not exist') > -1 ) { return { From 9fbaf550633b69a48f6800ec0c7c2ae19c8a4050 Mon Sep 17 00:00:00 2001 From: Gilad Resisi Date: Wed, 23 Sep 2026 14:52:24 +0700 Subject: [PATCH 35/45] fix(reddit): only retry when RATELIMIT is the sole submit error 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 --- .../nestjs-libraries/src/integrations/social/reddit.provider.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libraries/nestjs-libraries/src/integrations/social/reddit.provider.ts b/libraries/nestjs-libraries/src/integrations/social/reddit.provider.ts index a1a6c219d5..6c2ea6fd58 100644 --- a/libraries/nestjs-libraries/src/integrations/social/reddit.provider.ts +++ b/libraries/nestjs-libraries/src/integrations/social/reddit.provider.ts @@ -523,7 +523,7 @@ export class RedditProvider extends SocialAbstract implements SocialProvider { // A rate limit is a refusal, nothing was submitted: disarm the marker so // the next check re-arms this subreddit and submits it again once the // window has passed, instead of failing the whole post. - if (all.json.errors.some((e: any[]) => e?.[0] === 'RATELIMIT')) { + if (all.json.errors.every((e: any[]) => e?.[0] === 'RATELIMIT')) { data.armed = undefined; return { status: 'pending', pendingData: data }; } From cd899eaad67188557d5ef528b92b658044bfe5ba Mon Sep 17 00:00:00 2001 From: Gilad Resisi Date: Wed, 9 Sep 2026 17:04:50 +0700 Subject: [PATCH 36/45] fix(pinterest): validate board as a numeric id and map the board-name 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. --- .../chat/tools/integration.schedule.post.ts | 2 +- .../src/chat/tools/post.settings.tool.ts | 2 +- .../posts/providers-settings/pinterest.dto.ts | 18 +++++++++++++++--- 3 files changed, 17 insertions(+), 5 deletions(-) diff --git a/libraries/nestjs-libraries/src/chat/tools/integration.schedule.post.ts b/libraries/nestjs-libraries/src/chat/tools/integration.schedule.post.ts index 39d11cbd7d..d7b670b2c8 100644 --- a/libraries/nestjs-libraries/src/chat/tools/integration.schedule.post.ts +++ b/libraries/nestjs-libraries/src/chat/tools/integration.schedule.post.ts @@ -110,7 +110,7 @@ If validation fails, the result contains output.errors describing what to fix; t value: z .any() .describe( - 'Value of the key, always prefer the id then label if possible' + 'Value of the key, always prefer the id then label if possible. When the settings schema says a field is an id, pass the id returned by the channel tools, never the display label' ), }) ) diff --git a/libraries/nestjs-libraries/src/chat/tools/post.settings.tool.ts b/libraries/nestjs-libraries/src/chat/tools/post.settings.tool.ts index e3812b2203..99a95d3cb5 100644 --- a/libraries/nestjs-libraries/src/chat/tools/post.settings.tool.ts +++ b/libraries/nestjs-libraries/src/chat/tools/post.settings.tool.ts @@ -41,7 +41,7 @@ If validation fails, the result contains output.errors describing what to fix; t value: z .any() .describe( - 'New value of the key, always prefer the id then label if possible' + 'New value of the key, always prefer the id then label if possible. When the settings schema says a field is an id, pass the id returned by the channel tools, never the display label' ), }) ) diff --git a/libraries/nestjs-libraries/src/dtos/posts/providers-settings/pinterest.dto.ts b/libraries/nestjs-libraries/src/dtos/posts/providers-settings/pinterest.dto.ts index aed79c8055..81779dfec9 100644 --- a/libraries/nestjs-libraries/src/dtos/posts/providers-settings/pinterest.dto.ts +++ b/libraries/nestjs-libraries/src/dtos/posts/providers-settings/pinterest.dto.ts @@ -1,5 +1,12 @@ import { - IsDefined, IsOptional, IsString, IsUrl, MaxLength, MinLength, ValidateIf + IsDefined, + IsOptional, + IsString, + IsUrl, + Matches, + MaxLength, + MinLength, + ValidateIf, } from 'class-validator'; import { JSONSchema } from 'class-validator-jsonschema'; @@ -27,8 +34,13 @@ export class PinterestSettingsDto { @MinLength(1, { message: 'Board is required', }) - @JSONSchema({ - description: 'board must be an id', + @Matches(/^\d+$/, { + message: + 'Board must be the numeric board id (use the boards list of the channel to find it), not the board name', + }) + @JSONSchema({ + description: + 'The numeric id of the board (from the boards list of the channel), not the board name', }) board: string; } From 0a2fc9607238b16eba432cb5132067aea73ccb57 Mon Sep 17 00:00:00 2001 From: Gilad Resisi Date: Wed, 9 Sep 2026 13:34:51 +0700 Subject: [PATCH 37/45] fix(lemmy): surface Lemmy API errors instead of crashing on post_view 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. --- .../src/integrations/social/lemmy.provider.ts | 129 ++++++++++++++---- 1 file changed, 103 insertions(+), 26 deletions(-) diff --git a/libraries/nestjs-libraries/src/integrations/social/lemmy.provider.ts b/libraries/nestjs-libraries/src/integrations/social/lemmy.provider.ts index 94bf8e085d..9f25a86e45 100644 --- a/libraries/nestjs-libraries/src/integrations/social/lemmy.provider.ts +++ b/libraries/nestjs-libraries/src/integrations/social/lemmy.provider.ts @@ -6,6 +6,8 @@ import { } from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface'; import { makeSecureId } from '@gitroom/nestjs-libraries/services/make.secure.id'; import { + BadBody, + RefreshToken, SocialAbstract, ValidityMedia, } from '@gitroom/nestjs-libraries/integrations/social.abstract'; @@ -28,6 +30,65 @@ export class LemmyProvider extends SocialAbstract implements SocialProvider { } dto = LemmySettingsDto; + override handleErrors( + body: string, + status: number + ): + | { type: 'refresh-token' | 'bad-body' | 'retry'; value: string } + | undefined { + if (body.includes('rate_limit_error')) { + return { + type: 'retry', + value: 'Lemmy rate limit reached, please try again later', + }; + } + + if (body.includes('not_logged_in') || body.includes('incorrect_login')) { + return { + type: 'refresh-token', + value: 'Lemmy session is no longer valid, please reconnect the channel', + }; + } + + if (body.includes('site_ban') || body.includes('"error":"banned"')) { + return { + type: 'bad-body', + value: 'This account is banned on the Lemmy instance', + }; + } + + if (body.includes('couldnt_find_community')) { + return { + type: 'bad-body', + value: + 'The selected Lemmy community no longer exists, please pick another one', + }; + } + + if (body.includes('blocked_url')) { + return { + type: 'bad-body', + value: 'The Lemmy instance blocks the URL in this post', + }; + } + + if (body.includes('"error":"deleted"')) { + return { + type: 'bad-body', + value: 'The selected Lemmy community or post was deleted', + }; + } + + if (body.includes('"error":"locked"')) { + return { + type: 'bad-body', + value: 'This Lemmy post is locked, comments cannot be added', + }; + } + + return undefined; + } + override async checkValidity( items: Array ): Promise { @@ -149,20 +210,46 @@ export class LemmyProvider extends SocialAbstract implements SocialProvider { AuthService.fixedDecryption(integration.customInstanceDetails!) ); - const { jwt } = await ( - await fetch(body.service + '/api/v3/user/login', { - // @ts-ignore - undici-only option; blocks SSRF to internal IPs - dispatcher: getSsrfSafeDispatcher(), - body: JSON.stringify({ - username_or_email: body.identifier, - password: body.password, - }), - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - }) - ).json(); + const options = { + // @ts-ignore - undici-only option; blocks SSRF to internal IPs + dispatcher: getSsrfSafeDispatcher(), + body: JSON.stringify({ + username_or_email: body.identifier, + password: body.password, + }), + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }; + + let login: Response; + try { + login = await this.fetch(body.service + '/api/v3/user/login', options); + } catch (err) { + // The request body holds the stored password, so the failure is rebuilt + // without it before it reaches the Temporal history and the Errors table. + const json = (err as any).details?.[0]?.json || '{}'; + if (err instanceof BadBody) { + throw new BadBody( + this.identifier, + json, + {} as BodyInit, + err.message || 'Unknown Error' + ); + } + if (err instanceof RefreshToken) { + throw new RefreshToken( + this.identifier, + json, + {} as BodyInit, + err.message || 'Unknown Error' + ); + } + throw err; + } + + const { jwt } = await login.json(); return { jwt, service: body.service }; } @@ -179,18 +266,8 @@ export class LemmyProvider extends SocialAbstract implements SocialProvider { const valueArray: PostResponse[] = []; for (const lemmy of firstPost.settings.subreddit) { - console.log({ - community_id: +lemmy.value.id, - name: lemmy.value.title, - body: firstPost.message, - ...(lemmy.value.url ? { url: lemmy.value.url } : {}), - ...(firstPost.media?.length - ? { custom_thumbnail: firstPost.media[0].path } - : {}), - nsfw: false, - }); const { post_view } = await ( - await fetch(service + '/api/v3/post', { + await this.fetch(service + '/api/v3/post', { // @ts-ignore - undici-only option; blocks SSRF to internal IPs dispatcher: getSsrfSafeDispatcher(), body: JSON.stringify({ @@ -253,7 +330,7 @@ export class LemmyProvider extends SocialAbstract implements SocialProvider { for (const singlePostId of postIds) { const { comment_view } = await ( - await fetch(service + '/api/v3/comment', { + await this.fetch(service + '/api/v3/comment', { // @ts-ignore - undici-only option; blocks SSRF to internal IPs dispatcher: getSsrfSafeDispatcher(), body: JSON.stringify({ From 1ae4a3b78e0ab5860e82f6cc45279e8f4eda9f81 Mon Sep 17 00:00:00 2001 From: Gilad Resisi Date: Wed, 9 Sep 2026 10:10:27 +0700 Subject: [PATCH 38/45] fix(x): readable message when Too Many Requests retries are exhausted --- .../nestjs-libraries/src/integrations/social/x.provider.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/libraries/nestjs-libraries/src/integrations/social/x.provider.ts b/libraries/nestjs-libraries/src/integrations/social/x.provider.ts index 51512fbdf2..3dad170402 100644 --- a/libraries/nestjs-libraries/src/integrations/social/x.provider.ts +++ b/libraries/nestjs-libraries/src/integrations/social/x.provider.ts @@ -161,6 +161,12 @@ export class XProvider extends SocialAbstract implements SocialProvider { value: 'X is currently unavailable, please try again later', }; } + if (body.includes('Too Many Requests')) { + return { + type: 'retry', + value: 'X rate limit reached, please try again later', + }; + } if (body.includes('maximum of one cashtag')) { return { type: 'bad-body', From 96bca725519587b145502838ab6d9b3a2fb973c8 Mon Sep 17 00:00:00 2001 From: Gilad Resisi Date: Tue, 8 Sep 2026 12:20:41 +0700 Subject: [PATCH 39/45] fix(vk): surface VK API errors instead of marking posts completed 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. --- .../src/integrations/social/vk.provider.ts | 31 +++++++++++++++++-- 1 file changed, 28 insertions(+), 3 deletions(-) diff --git a/libraries/nestjs-libraries/src/integrations/social/vk.provider.ts b/libraries/nestjs-libraries/src/integrations/social/vk.provider.ts index 3ef8c950fc..f9db95efff 100644 --- a/libraries/nestjs-libraries/src/integrations/social/vk.provider.ts +++ b/libraries/nestjs-libraries/src/integrations/social/vk.provider.ts @@ -6,7 +6,11 @@ import { } from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface'; import { makeSecureId } from '@gitroom/nestjs-libraries/services/make.secure.id'; import dayjs from 'dayjs'; -import { SocialAbstract } from '@gitroom/nestjs-libraries/integrations/social.abstract'; +import { + BadBody, + RefreshToken, + SocialAbstract, +} from '@gitroom/nestjs-libraries/integrations/social.abstract'; import { createHash, randomBytes } from 'crypto'; import FormDataNew from 'form-data'; import mime from 'mime-types'; @@ -229,6 +233,23 @@ export class VkProvider extends SocialAbstract implements SocialProvider { ); } + // VK answers HTTP 200 with { error } instead of { response } on failures, + // so this.fetch never sees them and the post used to be marked completed. + private checkApiError(all: any) { + if (!all?.error) { + return; + } + const json = JSON.stringify(all); + const message = all.error.error_msg || 'VK rejected the request'; + if (all.error.error_code === 5) { + throw new RefreshToken(this.identifier, json, Buffer.from('{}'), message); + } + if ([6, 9, 29].includes(all.error.error_code)) { + throw new Error(message); + } + throw new BadBody(this.identifier, json, Buffer.from('{}'), message); + } + async post( userId: string, accessToken: string, @@ -249,7 +270,7 @@ export class VkProvider extends SocialAbstract implements SocialProvider { ); } - const { response } = await ( + const all = await ( await this.fetch( `https://api.vk.com/method/wall.post?v=5.251&access_token=${accessToken}&client_id=${process.env.VK_ID}`, { @@ -258,6 +279,8 @@ export class VkProvider extends SocialAbstract implements SocialProvider { } ) ).json(); + this.checkApiError(all); + const { response } = all; return [ { @@ -293,7 +316,7 @@ export class VkProvider extends SocialAbstract implements SocialProvider { ); } - const { response } = await ( + const all = await ( await this.fetch( `https://api.vk.com/method/wall.createComment?v=5.251&access_token=${accessToken}&client_id=${process.env.VK_ID}`, { @@ -302,6 +325,8 @@ export class VkProvider extends SocialAbstract implements SocialProvider { } ) ).json(); + this.checkApiError(all); + const { response } = all; return [ { From 935c80a8e1aa7819ada0b9b293ad6ac93a3610ee Mon Sep 17 00:00:00 2001 From: Gilad Resisi Date: Wed, 9 Sep 2026 10:10:27 +0700 Subject: [PATCH 40/45] fix(x): mark the channel for reconnect on Unauthorized media upload errors 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. --- .../nestjs-libraries/src/integrations/social/x.provider.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/libraries/nestjs-libraries/src/integrations/social/x.provider.ts b/libraries/nestjs-libraries/src/integrations/social/x.provider.ts index 51512fbdf2..834a832670 100644 --- a/libraries/nestjs-libraries/src/integrations/social/x.provider.ts +++ b/libraries/nestjs-libraries/src/integrations/social/x.provider.ts @@ -232,6 +232,12 @@ export class XProvider extends SocialAbstract implements SocialProvider { 'The video you are trying to post is longer than 2 minutes, which is not allowed for this account', }; } + if (body.includes('"title":"Unauthorized"')) { + return { + type: 'refresh-token', + value: 'X rejected the connected account, please reconnect your account', + }; + } return undefined; } From e475160bc3328fe48c9004007741e2911c491725 Mon Sep 17 00:00:00 2001 From: Gilad Resisi Date: Wed, 23 Sep 2026 15:27:16 +0700 Subject: [PATCH 41/45] fix(lemmy): route community search through this.fetch 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. --- .../nestjs-libraries/src/integrations/social/lemmy.provider.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libraries/nestjs-libraries/src/integrations/social/lemmy.provider.ts b/libraries/nestjs-libraries/src/integrations/social/lemmy.provider.ts index 9f25a86e45..726559491b 100644 --- a/libraries/nestjs-libraries/src/integrations/social/lemmy.provider.ts +++ b/libraries/nestjs-libraries/src/integrations/social/lemmy.provider.ts @@ -382,7 +382,7 @@ export class LemmyProvider extends SocialAbstract implements SocialProvider { const { jwt, service } = await this.getJwtAndService(integration); const { communities } = await ( - await fetch( + await this.fetch( service + `/api/v3/search?type_=Communities&sort=Active&q=${data.word}`, { // @ts-ignore - undici-only option; blocks SSRF to internal IPs From f477777ee0087de6ff563e909ae912f1c2ad3ca8 Mon Sep 17 00:00:00 2001 From: Gilad Resisi Date: Wed, 9 Sep 2026 10:10:27 +0700 Subject: [PATCH 42/45] fix(x): map remaining known error responses to readable messages 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. --- .../src/integrations/social/x.provider.ts | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/libraries/nestjs-libraries/src/integrations/social/x.provider.ts b/libraries/nestjs-libraries/src/integrations/social/x.provider.ts index 51512fbdf2..baff596fe3 100644 --- a/libraries/nestjs-libraries/src/integrations/social/x.provider.ts +++ b/libraries/nestjs-libraries/src/integrations/social/x.provider.ts @@ -232,6 +232,51 @@ export class XProvider extends SocialAbstract implements SocialProvider { 'The video you are trying to post is longer than 2 minutes, which is not allowed for this account', }; } + if ( + body.includes( + 'This user is not allowed to post a video longer than 10 minutes' + ) + ) { + return { + type: 'bad-body', + value: + 'The video you are trying to post is longer than 10 minutes, which is not allowed for this account', + }; + } + if (body.includes('Your account is temporarily locked')) { + return { + type: 'bad-body', + value: + 'Your X account is temporarily locked, log in to x.com to unlock it and then try again', + }; + } + if (body.includes('Crypto addresses are prohibited')) { + return { + type: 'bad-body', + value: + 'X does not allow crypto addresses in posts for the first 7 days after connecting the account', + }; + } + if (body.includes('Your media IDs are invalid')) { + return { + type: 'bad-body', + value: + 'X rejected the attached media, please re-upload the media and try again', + }; + } + if (body.includes('not authorized to create or publish articles')) { + return { + type: 'bad-body', + value: 'Publishing articles on X requires an X Premium subscription', + }; + } + if (body.includes('Please include either text or media in your Tweet')) { + return { + type: 'bad-body', + value: + 'One of the posts in this thread has no text or media, please add some text or remove it', + }; + } return undefined; } From a63b28bf371dcf687e6cfef3ebe07534bff1e8e5 Mon Sep 17 00:00:00 2001 From: Gilad Resisi Date: Mon, 7 Sep 2026 18:24:33 +0700 Subject: [PATCH 43/45] fix(dribbble): map 4xx shot rejections to non-retryable BadBody 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 Claude-Session: https://claude.ai/code/session_0188yxtbWi6wtfJg7ydjUpfG --- .../integrations/social/dribbble.provider.ts | 34 ++++++++++++++----- 1 file changed, 25 insertions(+), 9 deletions(-) diff --git a/libraries/nestjs-libraries/src/integrations/social/dribbble.provider.ts b/libraries/nestjs-libraries/src/integrations/social/dribbble.provider.ts index 08f26e0930..dc325e09b6 100644 --- a/libraries/nestjs-libraries/src/integrations/social/dribbble.provider.ts +++ b/libraries/nestjs-libraries/src/integrations/social/dribbble.provider.ts @@ -8,6 +8,7 @@ import { import { makeSecureId } from '@gitroom/nestjs-libraries/services/make.secure.id'; import FormData from 'form-data'; import { + BadBody, SocialAbstract, ValidityMedia, } from '@gitroom/nestjs-libraries/integrations/social.abstract'; @@ -180,16 +181,31 @@ export class DribbbleProvider extends SocialAbstract implements SocialProvider { formData.append('title', postDetails[0].settings.title); formData.append('description', postDetails[0].message); - const data2 = await this.getSsrfSafeAxios().post( - 'https://api.dribbble.com/v2/shots', - formData, - { - headers: { - ...formData.getHeaders(), - Authorization: `Bearer ${accessToken}`, - }, + let data2; + try { + data2 = await this.getSsrfSafeAxios().post( + 'https://api.dribbble.com/v2/shots', + formData, + { + headers: { + ...formData.getHeaders(), + Authorization: `Bearer ${accessToken}`, + }, + } + ); + } catch (err: any) { + const status = err?.response?.status; + if (status >= 400 && status < 500 && status !== 429) { + throw new BadBody( + this.identifier, + JSON.stringify(err?.response?.data ?? {}), + '{}', + err?.response?.data?.message || + `Dribbble rejected the shot with status ${status}` + ); } - ); + throw err; + } const location = data2.headers['location']; const newId = location.split('/').at(-1); From 38a06efd0d665741864a85ca656e1ab1be8c4294 Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Thu, 24 Sep 2026 01:44:23 +0700 Subject: [PATCH 44/45] fix(sync): restore makeId import and remove upstream connector URL table - 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. --- .../src/components/onboarding/onboarding.modal.tsx | 1 - .../src/components/public-api/public.component.tsx | 13 ++++--------- .../src/integrations/social/reddit.provider.ts | 1 + 3 files changed, 5 insertions(+), 10 deletions(-) diff --git a/apps/frontend/src/components/onboarding/onboarding.modal.tsx b/apps/frontend/src/components/onboarding/onboarding.modal.tsx index becaaa2040..d1c5a5d5f6 100644 --- a/apps/frontend/src/components/onboarding/onboarding.modal.tsx +++ b/apps/frontend/src/components/onboarding/onboarding.modal.tsx @@ -21,7 +21,6 @@ import { McpAuth, McpClient, mcpClients, - mcpConnectorUrls, } from '@gitroom/frontend/components/public-api/public.component'; import { McpClientIcon } from '@gitroom/frontend/components/public-api/mcp.client.icons'; diff --git a/apps/frontend/src/components/public-api/public.component.tsx b/apps/frontend/src/components/public-api/public.component.tsx index db6d2cbd7e..f9009ce23f 100644 --- a/apps/frontend/src/components/public-api/public.component.tsx +++ b/apps/frontend/src/components/public-api/public.component.tsx @@ -22,15 +22,10 @@ export const remoteMcpClients = { 'In ChatGPT go to Settings > Connectors > Create and paste this URL.', } as const; -// Official one-click connectors listed in the assistants' directories. -// Only for the hosted Postiz (billingEnabled), they point at the public MCP server. -export const mcpConnectorUrls = { - Claude: 'https://claude.ai/directory/postiz', - ChatGPT: - 'https://chatgpt.com/plugins/plugin_asdk_app_6aaaf1a529808191a2a15fde824bb013', - Cursor: 'https://cursor.com/marketplace/postiz', - 'Grok Bot': 'https://x.ai/bot/plugin/58737848', -} as const; +// The upstream one-click connector directory URLs (claude.ai/directory, +// chatgpt.com/plugins, cursor.com/marketplace) are deliberately NOT +// reproduced here: they route this deployment's users to the upstream cloud. +// Connector cards are brand-gated via brandConfig URLs instead. // Clients with no MCP or CLI settings: you paste instructions into the chat, // the agent installs the CLI itself and asks you for the API key. diff --git a/libraries/nestjs-libraries/src/integrations/social/reddit.provider.ts b/libraries/nestjs-libraries/src/integrations/social/reddit.provider.ts index c097d293e4..34f630d0ee 100644 --- a/libraries/nestjs-libraries/src/integrations/social/reddit.provider.ts +++ b/libraries/nestjs-libraries/src/integrations/social/reddit.provider.ts @@ -6,6 +6,7 @@ import { SocialProvider, } from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface'; import { makeSecureId } from '@gitroom/nestjs-libraries/services/make.secure.id'; +import { makeId } from '@gitroom/nestjs-libraries/services/make.is'; import { RedditSettingsDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/reddit.dto'; import { timer } from '@gitroom/helpers/utils/timer'; import { From ec7194cdf8b8fcca685b6fd0ad6894946793fb07 Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Thu, 24 Sep 2026 01:51:28 +0700 Subject: [PATCH 45/45] fix(sync): restore getMcpOauthUrl import used by getCursorInstallUrl --- apps/frontend/src/components/onboarding/onboarding.modal.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/frontend/src/components/onboarding/onboarding.modal.tsx b/apps/frontend/src/components/onboarding/onboarding.modal.tsx index d1c5a5d5f6..ae20e4836a 100644 --- a/apps/frontend/src/components/onboarding/onboarding.modal.tsx +++ b/apps/frontend/src/components/onboarding/onboarding.modal.tsx @@ -16,6 +16,7 @@ import { AnyMcpClient, CopyButton, getMcpConfig, + getMcpOauthUrl, isChatOnlyMcpClient, localCliSteps, McpAuth,