diff --git a/.env.example b/.env.example
index ee1246cb99..8b5f9897ca 100644
--- a/.env.example
+++ b/.env.example
@@ -217,6 +217,18 @@ BRAND_AFFILIATE_URL="https://example.com/affiliates"
# "Add to Claude" button — it defaults to empty on purpose so a deployment
# that forgets to set it cannot offer the upstream listing by accident.
BRAND_CLAUDE_DIRECTORY_URL=""
+# Only needed if you run your own Postiz Canva app: its origin from the Canva
+# Developer Portal (Security -> Credentials), allowed by CORS on the backend.
+#CANVA_APP_ORIGIN="https://app-xxxxxxxxxxx.canva-apps.com"
+
+# Sign in with Apple (login provider)
+# APPLE_BUNDLE_ID: iOS app bundle id (native mobile sign-in)
+# APPLE_SERVICE_ID / TEAM_ID / KEY_ID / PRIVATE_KEY: web sign-in (Services ID + .p8 key)
+APPLE_BUNDLE_ID=""
+APPLE_SERVICE_ID=""
+APPLE_TEAM_ID=""
+APPLE_KEY_ID=""
+APPLE_PRIVATE_KEY=""
# ==============================================================================
# 8. Single Sign-On (Generic OAuth 2.0 / DOS ID via PKCE Bridge)
@@ -383,6 +395,30 @@ DRIBBBLE_CLIENT_SECRET="sample_dribbble_client_secret"
# --- Tumblr ---
TUMBLR_CLIENT_ID="sample_tumblr_client_id"
TUMBLR_CLIENT_SECRET="sample_tumblr_client_secret"
+# Misc Settings
+OPENAI_API_KEY=""
+# OAuth client configured for the ChatGPT app. Only this client can receive
+# verified user email claims for ChatGPT Enterprise domain restrictions.
+OPENAI_OAUTH_CLIENT_ID=""
+# MCP OAuth Dynamic Client Registration (RFC 7591) redirect-domain allowlist.
+# When set (comma separated), POST /oauth/register only accepts redirect_uris
+# whose host matches a listed domain or one of its subdomains; unset or empty
+# means any client can self-register. Only https callbacks are checked:
+# loopback (http://localhost:8787/callback - Grok, Claude Code) and
+# private-use scheme callbacks (cursor://...) stay on the user's machine
+# and are always accepted. Example locks web callbacks to the claude.ai
+# connector; add cursor.com for Cursor.
+# DCR_VERIFIED_DOMAINS="claude.ai,claude.com"
+# Postiz Cloud only: lets people connect a self-hosted Postiz on the MCP
+# consent screen ("Use self-hosted"). The MCP tool calls of that connection
+# are relayed to the instance with its API key. Leave unset on self-hosted
+# installs; set it on both the API and the MCP backend.
+# MCP_SELF_HOSTED_RELAY=true
+# EVOLINK_API_KEY="" # EvoLink API key for Seedance AI video generation (https://evolink.ai)
+NEXT_PUBLIC_DISCORD_SUPPORT=""
+NEXT_PUBLIC_POLOTNO=""
+# NOT_SECURED=false
+API_LIMIT=30 # The limit of the public API hour limit
# --- VK (VKontakte) ---
VK_ID="12345678"
diff --git a/CLAUDE.md b/CLAUDE.md
index 087fbf9a9a..ee5b8ee247 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -88,3 +88,10 @@ The backend app is mostly used to write controllers and import from the librarie
- Linting of the project can run only from the root.
- Use only pnpm.
- Branding guard (`scripts/branding-guard.ts`, enforced in CI) blocks reintroducing upstream endpoints or branding; use `branding-guard-allow:` comments only for deliberate references.
+- Workflows files can never be changed if they are already in origin/main, because changing a workflow will fail all its activities, instead create a new workflow with the version, and everywhere the workflow being called, change it to the new workflow version.
+- Workflows activities parameters cannot be changed, as it will break the workflow, if we need to change the parameters, if we need to change the parameters, we need to create a new activity with the new parameters, and then create a new workflow that uses the new activity.
+- Code must always be generic, there can't be a way that a specific logic, let's say facebook or instagram, appear in a file that use a generic logic, instead, we need to edit the interface of the provider, add another function, and then generically call it from the generic code, and then implement the specific logic in the provider implementation. we can't have something like if(facebookProvider) {} inside a non facebook provider file.
+- Before adding a field to a shared repository/service `select` or changing a shared method's return shape, grep for all its consumers (frontend, public API, MCP/agent tools, orchestrator, webhooks) and confirm the change is intended for each.
+- Exposing a stored field to a new external surface (public API, MCP, webhooks) is a data-exposure decision — ask first, and check what existing production rows hold for that column before shipping it.
+- Never silently change the meaning or format of a value persisted in an existing DB column; that affects every reader and all historical rows, so ask first.
+- Never return a raw Prisma row from a model that holds credentials (`Integration`, `User` and similar) in a controller response. Repository writes on those models must use a `select` with only the fields the caller needs (like `setTimes` with `select: { id: true }`), or the controller returns nothing. This applies even where nearby code looks different.
diff --git a/README.md b/README.md
index 7bfdd3fe11..da851d4f54 100644
--- a/README.md
+++ b/README.md
@@ -108,8 +108,8 @@
| [Hostinger](https://www.hostinger.com/vps/docker/postiz?ref=postiz) | | Hostinger is on a mission to make online success possible for anyone – from developers to aspiring bloggers and business owners |
| [Virlo](https://dev.virlo.ai/?ref=postiz) | | Virlo is the #1 social media trend spotting and all-in-one GTM tool for teams leveraging short-form video |
| [ChatbotX](https://chatbotx.io/?ref=postiz) | | The ManyChat alternative that you can self-host, white-label, and resell to your clients. Bring your own OpenClaw, Hermes, or Claude agents! |
+| [RapidProxy](https://www.rapidproxy.io/?ref=postiz) | | RapidProxy provides 90M+ residential IPs for social media, browser automation, and AI workflows, with smart rotation and stable sessions. From $0.55/GB; use RAPID10 for 10% off.
-
# Intro
diff --git a/apps/backend/src/api/api.module.ts b/apps/backend/src/api/api.module.ts
index 34a1aee779..87d0d523ff 100644
--- a/apps/backend/src/api/api.module.ts
+++ b/apps/backend/src/api/api.module.ts
@@ -46,6 +46,7 @@ import {
OAuthController,
OAuthAuthorizedController,
} from '@gitroom/backend/api/routes/oauth.controller';
+import { OAuthSelfHostedController } from '@gitroom/backend/api/routes/oauth.selfhosted.controller';
import { AnnouncementsController } from '@gitroom/backend/api/routes/announcements.controller';
import { AdminController } from '@gitroom/backend/api/routes/admin.controller';
import { EcosystemModule } from '@gitroom/backend/ecosystem/ecosystem.module';
@@ -88,6 +89,7 @@ const authenticatedController = [
? [
RootController,
OAuthController,
+ OAuthSelfHostedController,
MediaWidgetController,
ClippingWidgetController,
]
@@ -101,6 +103,7 @@ const authenticatedController = [
EnterpriseController,
NoAuthIntegrationsController,
OAuthController,
+ OAuthSelfHostedController,
MediaWidgetController,
ClippingWidgetController,
...authenticatedController,
diff --git a/apps/backend/src/api/routes/enterprise.controller.ts b/apps/backend/src/api/routes/enterprise.controller.ts
index eebc630054..c14eebdb52 100644
--- a/apps/backend/src/api/routes/enterprise.controller.ts
+++ b/apps/backend/src/api/routes/enterprise.controller.ts
@@ -20,8 +20,7 @@ export class EnterpriseController {
private verifyEnterpriseToken(params: string): T {
const payload = AuthService.verifyJWT(params) as any;
if (
- !payload ||
- typeof payload !== 'object' ||
+ !payload || typeof payload !== 'object' ||
'providerName' in payload || // login token (full User row)
'orgId' in payload || // team invite token
'expires' in payload // password reset token
diff --git a/apps/backend/src/api/routes/integrations.controller.ts b/apps/backend/src/api/routes/integrations.controller.ts
index 3b719f2a62..46acab6c4b 100644
--- a/apps/backend/src/api/routes/integrations.controller.ts
+++ b/apps/backend/src/api/routes/integrations.controller.ts
@@ -20,6 +20,7 @@ import { ApiTags } from '@nestjs/swagger';
import { GetUserFromRequest } from '@gitroom/nestjs-libraries/user/user.from.request';
import { PostsService } from '@gitroom/nestjs-libraries/database/prisma/posts/posts.service';
import { IntegrationTimeDto } from '@gitroom/nestjs-libraries/dtos/integrations/integration.time.dto';
+import { CustomerNameDto } from '@gitroom/nestjs-libraries/dtos/integrations/customer.name.dto';
import { PlugDto } from '@gitroom/nestjs-libraries/dtos/plugs/plug.dto';
import {
Disconnect,
@@ -66,6 +67,15 @@ export class IntegrationsController {
return this._integrationService.customers(org.id);
}
+ @Put('/customers/:id')
+ async updateCustomerName(
+ @GetOrgFromRequest() org: Organization,
+ @Param('id') id: string,
+ @Body() body: CustomerNameDto
+ ) {
+ return this._integrationService.updateCustomerName(org.id, id, body.name);
+ }
+
@Put('/:id/group')
async updateIntegrationGroup(
@GetOrgFromRequest() org: Organization,
diff --git a/apps/backend/src/api/routes/oauth.controller.ts b/apps/backend/src/api/routes/oauth.controller.ts
index 822e8bf1c5..2cdf8e143a 100644
--- a/apps/backend/src/api/routes/oauth.controller.ts
+++ b/apps/backend/src/api/routes/oauth.controller.ts
@@ -17,6 +17,7 @@ import { BootstrapService } from '@gitroom/backend/ecosystem/bootstrap.service';
import { AuthService as AuthChecker } from '@gitroom/helpers/auth/auth.service';
import { getCookieUrlFromDomain } from '@gitroom/helpers/subdomain/subdomain.management';
import { ApiTags } from '@nestjs/swagger';
+import { Throttle } from '@nestjs/throttler';
import { OAuthService } from '@gitroom/nestjs-libraries/database/prisma/oauth/oauth.service';
import { GetUserFromRequest } from '@gitroom/nestjs-libraries/user/user.from.request';
import { GetOrgFromRequest } from '@gitroom/nestjs-libraries/user/org.from.request';
@@ -53,6 +54,11 @@ export class OAuthController {
}
);
+ const selfHosted = this._oauthService.allowsSelfHosted(
+ app,
+ query.resource
+ );
+
return {
app: {
name: app.name,
@@ -62,10 +68,20 @@ export class OAuthController {
redirectUrl: app.redirectUrl,
},
state: query.state,
+ selfHosted,
+ selfHostedEmail:
+ selfHosted && this._oauthService.selfHostedRequiresEmail(app),
};
}
+ // Public (the person may have no account here) and capped per client,
+ // since every attempt sends requests to the instance. Moved to
+ // OAuthSelfHostedController (routes/oauth.selfhosted.controller.ts) so this
+ // file no longer pulls McpRelayService/@mastra into the consent test graph.
+
@Post('/token')
+ // RFC 6749 §5.1: successful token responses are 200; strict clients (Canva) reject Nest's default 201
+ @HttpCode(200)
async token(
@Body() body: TokenExchangeDto,
@Headers('authorization') authorization?: string
diff --git a/apps/backend/src/api/routes/oauth.selfhosted.controller.ts b/apps/backend/src/api/routes/oauth.selfhosted.controller.ts
new file mode 100644
index 0000000000..0e1c68d4f5
--- /dev/null
+++ b/apps/backend/src/api/routes/oauth.selfhosted.controller.ts
@@ -0,0 +1,68 @@
+import { Body, Controller, Post, UseGuards } from '@nestjs/common';
+import { ApiTags } from '@nestjs/swagger';
+import { Throttle } from '@nestjs/throttler';
+import { OAuthService } from '@gitroom/nestjs-libraries/database/prisma/oauth/oauth.service';
+import { AuthorizeSelfHostedDto } from '@gitroom/nestjs-libraries/dtos/oauth/authorize-oauth.dto';
+import { McpRelayService } from '@gitroom/nestjs-libraries/chat/mcp.relay.service';
+import { ThrottlerRealIpGuard } from '@gitroom/nestjs-libraries/throttler/throttler.provider';
+
+// Split out of OAuthController so the bootstrap/consent test suite (which
+// imports OAuthAuthorizedController) does not pull McpRelayService - and
+// through it @mastra/core and ESM-only dependencies - into its module graph.
+@ApiTags('OAuth')
+@Controller('/oauth')
+export class OAuthSelfHostedController {
+ constructor(
+ private _oauthService: OAuthService,
+ private _mcpRelayService: McpRelayService
+ ) {}
+
+ // Public (the person may have no account here) and capped per client,
+ // since every attempt sends requests to the instance
+ @UseGuards(ThrottlerRealIpGuard)
+ @Throttle({ default: { limit: 30, ttl: 3600000 } })
+ @Post('/authorize/self-hosted')
+ async authorizeSelfHosted(@Body() body: AuthorizeSelfHostedDto) {
+ const app = await this._oauthService.validateAuthorizationRequest(
+ body.client_id,
+ {
+ redirectUri: body.redirect_uri,
+ codeChallenge: body.code_challenge,
+ codeChallengeMethod: body.code_challenge_method,
+ }
+ );
+
+ const email = body.email?.trim();
+ this._oauthService.validateSelfHostedRequest(app, {
+ resource: body.resource,
+ email,
+ });
+
+ const instance = await this._mcpRelayService.connect(
+ body.instance_url,
+ body.api_key
+ );
+
+ const code = await this._oauthService.createSelfHostedAuthorizationCode(
+ app.id,
+ { ...instance, email },
+ app.dynamic
+ ? {
+ codeChallenge: body.code_challenge,
+ codeChallengeMethod: body.code_challenge_method,
+ redirectUri: body.redirect_uri,
+ }
+ : undefined
+ );
+
+ // Same redirect as an approved cloud authorization
+ const redirectUrl = new URL(
+ app.dynamic ? body.redirect_uri! : app.redirectUrl
+ );
+ redirectUrl.searchParams.set('code', code);
+ if (body.state) {
+ redirectUrl.searchParams.set('state', body.state);
+ }
+ return { redirect: redirectUrl.toString() };
+ }
+}
diff --git a/apps/backend/src/main.ts b/apps/backend/src/main.ts
index a35569b1a9..d43cce04e2 100644
--- a/apps/backend/src/main.ts
+++ b/apps/backend/src/main.ts
@@ -48,6 +48,15 @@ async function start() {
process.env.FRONTEND_URL,
'http://localhost:6274',
...(process.env.MAIN_URL ? [process.env.MAIN_URL] : []),
+ // Optional: the Canva app calls the public API from its iframe origin
+ // (browsers send it lowercase, e.g. https://app-aabbcc.canva-apps.com)
+ ...(process.env.CANVA_APP_ORIGIN
+ ? [
+ process.env.CANVA_APP_ORIGIN.trim()
+ .replace(/\/+$/, '')
+ .toLowerCase(),
+ ]
+ : []),
],
},
});
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 6a64c0752e..99ac591814 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
@@ -17,6 +17,7 @@ import { streamUploadOptions } from '@gitroom/nestjs-libraries/upload/multer.str
import { ApiTags } from '@nestjs/swagger';
import { GetOrgFromRequest } from '@gitroom/nestjs-libraries/user/org.from.request';
import { GetIncludeDeletedFromRequest } from '@gitroom/nestjs-libraries/user/include.deleted.from.request';
+import { GetOAuthUserIdFromRequest } from '@gitroom/nestjs-libraries/user/oauth.user.id.from.request';
import { Organization } from '@prisma/client';
import { IntegrationService } from '@gitroom/nestjs-libraries/database/prisma/integrations/integration.service';
import { CheckPolicies } from '@gitroom/backend/services/auth/permissions/permissions.ability';
@@ -229,6 +230,24 @@ export class PublicIntegrationsController {
return { connected: true };
}
+ // `user` is only known for OAuth app tokens; an API key belongs to the
+ // whole organization
+ @Get('/me')
+ async getMe(
+ @GetOrgFromRequest() org: Organization,
+ @GetOAuthUserIdFromRequest() userId?: string
+ ) {
+ Sentry.metrics.count('public_api-request', 1);
+ const user = userId ? await this._usersService.getPersonal(userId) : null;
+
+ return {
+ organization: { id: org.id, name: org.name },
+ user: user
+ ? { id: user.id, name: user.name, picture: user.picture?.path || null }
+ : null,
+ };
+ }
+
@Get('/groups')
async listGroups(@GetOrgFromRequest() org: Organization) {
Sentry.metrics.count('public_api-request', 1);
diff --git a/apps/backend/src/services/auth/public.auth.middleware.ts b/apps/backend/src/services/auth/public.auth.middleware.ts
index 7cbcee3868..526757b684 100644
--- a/apps/backend/src/services/auth/public.auth.middleware.ts
+++ b/apps/backend/src/services/auth/public.auth.middleware.ts
@@ -44,6 +44,9 @@ export class PublicAuthMiddleware implements NestMiddleware {
}
org = authorization.organization;
+ // The user who approved the OAuth app, so /me can show who is connected
+ // @ts-ignore
+ req.oauthUserId = authorization.userId;
} else {
org = await this._organizationService.getOrgByApiKey(auth);
if (!org) {
diff --git a/apps/frontend/AGENTS.md b/apps/frontend/AGENTS.md
new file mode 100644
index 0000000000..643577dfae
--- /dev/null
+++ b/apps/frontend/AGENTS.md
@@ -0,0 +1,9 @@
+
+
+# This is NOT the Next.js you know
+
+This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
+
+This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
+
+
diff --git a/apps/frontend/CLAUDE.md b/apps/frontend/CLAUDE.md
new file mode 100644
index 0000000000..43c994c2d3
--- /dev/null
+++ b/apps/frontend/CLAUDE.md
@@ -0,0 +1 @@
+@AGENTS.md
diff --git a/apps/frontend/src/app/(app)/api/uploads/[[...path]]/route.ts b/apps/frontend/src/app/(app)/api/uploads/[[...path]]/route.ts
index 7dc3d678e5..a4b093e81f 100644
--- a/apps/frontend/src/app/(app)/api/uploads/[[...path]]/route.ts
+++ b/apps/frontend/src/app/(app)/api/uploads/[[...path]]/route.ts
@@ -36,20 +36,43 @@ export const GET = async (
if (filePath !== base && !filePath.startsWith(base + sep)) {
return new NextResponse('Not found', { status: 404 });
}
- const response = createReadStream(filePath);
const fileStats = statSync(filePath);
const contentType = mime.getType(filePath) || 'application/octet-stream';
+
+ // Honor ranged requests: providers that push video in chunks (TikTok,
+ // YouTube, LinkedIn, X) fetch byte windows with a Range header and reject
+ // anything but a 206, so ignoring Range breaks their uploads.
+ const range = /^bytes=(\d+)-(\d*)$/.exec(request.headers.get('range') || '');
+ const start = range ? Number(range[1]) : 0;
+ const end =
+ range && range[2]
+ ? Math.min(Number(range[2]), fileStats.size - 1)
+ : fileStats.size - 1;
+
+ if (range && (start >= fileStats.size || start > end)) {
+ return new NextResponse(null, {
+ status: 416,
+ headers: { 'Content-Range': `bytes */${fileStats.size}` },
+ });
+ }
+
+ const response = createReadStream(filePath, range ? { start, end } : {});
const iterator = nodeStreamToIterator(response);
const webStream = iteratorToStream(iterator);
return new Response(webStream, {
+ status: range ? 206 : 200,
headers: {
'Content-Type': contentType,
// Set the appropriate content-type header
- 'Content-Length': fileStats.size.toString(),
+ 'Content-Length': (end - start + 1).toString(),
// Set the content-length header
'Last-Modified': fileStats.mtime.toUTCString(),
// Set the last-modified header
'Cache-Control': 'public, max-age=31536000, immutable', // Example cache-control header
+ 'Accept-Ranges': 'bytes',
+ ...(range
+ ? { 'Content-Range': `bytes ${start}-${end}/${fileStats.size}` }
+ : {}),
},
});
};
diff --git a/apps/frontend/src/app/global-error.tsx b/apps/frontend/src/app/global-error.tsx
index db4f81ebc0..720d750d16 100644
--- a/apps/frontend/src/app/global-error.tsx
+++ b/apps/frontend/src/app/global-error.tsx
@@ -2,31 +2,17 @@
import * as Sentry from '@sentry/nextjs';
import NextError from 'next/error';
import { useEffect } from 'react';
-import { useVariables } from '@gitroom/react/helpers/variable.context';
export default function GlobalError({
error,
}: {
error: Error & { digest?: string };
}) {
- const { sentryDsn } = useVariables();
-
useEffect(() => {
- if (!sentryDsn) {
- return;
- }
- const eventId = Sentry.captureException(error);
- Sentry.showReportDialog({
- eventId,
- title: 'Something broke!',
- subtitle: 'Please help us fix the issue by providing some details.',
- labelComments: 'What happened?',
- labelName: 'Your name',
- labelEmail: 'Your email',
- labelSubmit: 'Send Report',
- lang: 'en',
- });
-
+ // The variables context is not mounted here (this replaces the root
+ // layout), so don't gate on its DSN. Without a client this is a no-op, and
+ // beforeSend already opens the report dialog for captured exceptions
+ Sentry.captureException(error);
}, [error]);
return (
diff --git a/apps/frontend/src/components/agents/agent.tsx b/apps/frontend/src/components/agents/agent.tsx
index af6d3af634..f304109525 100644
--- a/apps/frontend/src/components/agents/agent.tsx
+++ b/apps/frontend/src/components/agents/agent.tsx
@@ -107,12 +107,12 @@ export const AgentList: FC<{ onChange: (arr: any[]) => void }> = ({
return (