diff --git a/.env.example b/.env.example index f02d9b8f5b..1c616e5c27 100644 --- a/.env.example +++ b/.env.example @@ -143,6 +143,11 @@ LISTMONK_WELCOME_TEMPLATE_ID="1" BRAND_NAME="Crove Post" # Short application brand name BRAND_SHORT_NAME="Crove" +# Machine identifier for the MCP server in generated client config +# (`claude mcp add `, `{ mcpServers: { : … } }`, Cursor deep links). +# Must be a lowercase hyphenated slug. Leave EMPTY to derive it automatically +# from BRAND_SHORT_NAME ("Crove" -> "crove", "Crove (Beta)" -> "crove-beta"). +BRAND_MCP_CONNECTOR_NAME="" # Brand meta description BRAND_DESCRIPTION="The AI-powered multi-channel social media management platform" # Company legal entity name @@ -181,6 +186,10 @@ BRAND_EXTENSION_STORE_URL="https://chromewebstore.google.com/detail/sample-exten BRAND_TUTORIAL_URL="https://example.com/tutorials" # Affiliate program URL BRAND_AFFILIATE_URL="https://example.com/affiliates" +# Claude connector directory listing URL. Leave EMPTY to hide the +# "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="" # ============================================================================== # 8. Single Sign-On (Generic OAuth 2.0 / DOS ID via PKCE Bridge) diff --git a/apps/frontend/src/components/developer/developer.component.tsx b/apps/frontend/src/components/developer/developer.component.tsx index 65414061f3..a49e00c802 100644 --- a/apps/frontend/src/components/developer/developer.component.tsx +++ b/apps/frontend/src/components/developer/developer.component.tsx @@ -8,6 +8,8 @@ import { useDecisionModal, useModals } from '@gitroom/frontend/components/layout import { MediaBox } from '@gitroom/frontend/components/media/media.component'; import copy from 'copy-to-clipboard'; import { useT } from '@gitroom/react/translation/get.transation.service.client'; +import { useVariables } from '@gitroom/react/helpers/variable.context'; +import { joinBrandUrl } from '@gitroom/helpers/utils/brand.config'; const useOAuthApp = () => { const fetch = useFetch(); @@ -67,6 +69,7 @@ export const DeveloperComponent: FC = () => { const decision = useDecisionModal(); const modals = useModals(); const t = useT(); + const { brandConfig } = useVariables(); const { data: app, mutate } = useOAuthApp(); const [plaintextSecret, setPlaintextSecret] = useState(null); const [creating, setCreating] = useState(false); @@ -244,7 +247,7 @@ export const DeveloperComponent: FC = () => {
@@ -407,7 +410,7 @@ export const DeveloperComponent: FC = () => {
diff --git a/apps/frontend/src/components/layout/top.menu.tsx b/apps/frontend/src/components/layout/top.menu.tsx index defa47bb65..435fb385d9 100644 --- a/apps/frontend/src/components/layout/top.menu.tsx +++ b/apps/frontend/src/components/layout/top.menu.tsx @@ -19,7 +19,7 @@ interface MenuItemInterface { } export const useMenuItem = () => { - const { isGeneral } = useVariables(); + const { isGeneral, brandConfig } = useVariables(); const t = useT(); const { openModal } = useModals(); @@ -246,7 +246,10 @@ export const useMenuItem = () => { /> ), - path: 'https://affiliate.postiz.com', + path: brandConfig?.affiliateUrl || '#', + // No affiliate program configured for this deployment — hide the entry + // rather than sending customers to the upstream program. + hide: !brandConfig?.affiliateUrl, role: ['ADMIN', 'SUPERADMIN', 'USER'], requireBilling: true, }, diff --git a/apps/frontend/src/components/onboarding/onboarding.modal.tsx b/apps/frontend/src/components/onboarding/onboarding.modal.tsx index 7e95193f34..ed74769750 100644 --- a/apps/frontend/src/components/onboarding/onboarding.modal.tsx +++ b/apps/frontend/src/components/onboarding/onboarding.modal.tsx @@ -2,6 +2,7 @@ import React, { FC, Fragment, useCallback, useMemo, useState } from 'react'; import { useFetch } from '@gitroom/helpers/utils/custom.fetch'; +import { joinBrandUrl } from '@gitroom/helpers/utils/brand.config'; import useSWR from 'swr'; import { orderBy } from 'lodash'; import clsx from 'clsx'; @@ -274,13 +275,23 @@ const otherAgents = mcpClients.filter( const apiTab = 'API' as const; type OnboardingTab = OnboardingAgent | typeof otherTab | typeof apiTab; -const cliCommands = localCliSteps.map((step) => step.code); +// localCliSteps carries {API_URL} / {API_KEY} placeholders. This panel +// substitutes the instance URL but deliberately not the key: the API tab +// already reveals it behind its own toggle, and echoing the secret into a +// second panel puts it in screenshots and screen-shares. +const getCliCommands = (apiBaseUrl: string) => + localCliSteps.map((step) => + step.code + .replace('{API_URL}', apiBaseUrl) + .replace('{API_KEY}', '') + ); // Cursor one-click install: https://cursor.com/docs/mcp/install-links const getCursorInstallUrl = ( auth: McpAuth, mcpBase: string, - apiKey: string + apiKey: string, + connectorName: string ) => { const server = auth === 'oauth' @@ -289,9 +300,9 @@ const getCursorInstallUrl = ( url: `${mcpBase}/mcp`, headers: { Authorization: `Bearer ${apiKey}` }, }; - return `cursor://anysphere.cursor-deeplink/mcp/install?name=postiz&config=${btoa( - JSON.stringify(server) - )}`; + return `cursor://anysphere.cursor-deeplink/mcp/install?name=${encodeURIComponent( + connectorName + )}&config=${btoa(JSON.stringify(server))}`; }; const OnboardingStep2: FC<{ onBack: () => void; onNext: () => void }> = ({ @@ -300,7 +311,7 @@ const OnboardingStep2: FC<{ onBack: () => void; onNext: () => void }> = ({ }) => { const t = useT(); const user = useUser(); - const { backendUrl, mcpUrl, billingEnabled } = useVariables(); + const { backendUrl, mcpUrl, billingEnabled, brandConfig } = useVariables(); const [tab, setTab] = useState('Claude'); const [otherAgent, setOtherAgent] = useState(otherAgents[0]); // The client the cards describe: the tab itself, or the pick inside "Other agents" @@ -311,11 +322,13 @@ const OnboardingStep2: FC<{ onBack: () => void; onNext: () => void }> = ({ const mcpBase = mcpUrl || backendUrl; const apiKey = user?.publicApi || ''; const available = !!apiKey && !!user?.tier?.public_api; + const cliCommands = getCliCommands(mcpBase); + const connectorName = brandConfig?.mcpConnectorName || 'mcp'; const { config, hint } = agent === apiTab ? { config: '', hint: '' } - : getMcpConfig(agent, auth, mcpBase, apiKey); + : getMcpConfig(agent, auth, mcpBase, apiKey, connectorName); const maskedConfig = revealed || auth === 'oauth' || !apiKey @@ -326,14 +339,14 @@ const OnboardingStep2: FC<{ onBack: () => void; onNext: () => void }> = ({ ); const connector = - agent === 'Claude' && billingEnabled + agent === 'Claude' && billingEnabled && brandConfig?.claudeDirectoryUrl ? { - href: 'https://claude.ai/directory/postiz', + href: brandConfig.claudeDirectoryUrl, label: t('add_to_claude', 'Add to Claude'), } : agent === 'Cursor' ? { - href: getCursorInstallUrl(auth, mcpBase, apiKey), + href: getCursorInstallUrl(auth, mcpBase, apiKey, connectorName), label: t('add_to_cursor', 'Add to Cursor'), } : null; @@ -398,7 +411,7 @@ const OnboardingStep2: FC<{ onBack: () => void; onNext: () => void }> = ({
diff --git a/apps/frontend/src/components/public-api/public.component.tsx b/apps/frontend/src/components/public-api/public.component.tsx index 24afb92bcc..5261826ceb 100644 --- a/apps/frontend/src/components/public-api/public.component.tsx +++ b/apps/frontend/src/components/public-api/public.component.tsx @@ -8,6 +8,7 @@ import { useToaster } from '@gitroom/react/toaster/toaster'; import { useVariables } from '@gitroom/react/helpers/variable.context'; import { useT } from '@gitroom/react/translation/get.transation.service.client'; import { useFetch } from '@gitroom/helpers/utils/custom.fetch'; +import { joinBrandUrl } from '@gitroom/helpers/utils/brand.config'; import { useDecisionModal } from '@gitroom/frontend/components/layout/new-modal'; import { DeveloperComponent } from '@gitroom/frontend/components/developer/developer.component'; import { McpClientIcon } from '@gitroom/frontend/components/public-api/mcp.client.icons'; @@ -22,10 +23,13 @@ export const remoteMcpClients = { } 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 +// the agent installs the CLI itself and asks you for the API key. +// A function of the API base, because the upstream CLI defaults to the +// upstream cloud — omitting POSTIZ_API_URL sends this deployment's API key to +// api.postiz.com and posts to the wrong account. export const chatOnlyMcpClients = { - 'Grok Bot': - 'Install the Postiz CLI with `npm install -g postiz`, then install the Postiz skill with `npx skills add gitroomhq/postiz-agent`. Ask me for my Postiz API key and set it as the POSTIZ_API_KEY environment variable before using the CLI.', + 'Grok Bot': (apiBase: string) => + `Install the CLI with \`npm install -g postiz\`, then install the skill with \`npx skills add gitroomhq/postiz-agent\`. Ask me for my API key, then set BOTH environment variables before using the CLI: POSTIZ_API_URL="${apiBase}" and POSTIZ_API_KEY. Do not skip POSTIZ_API_URL.`, // branding-guard-allow: upstream publishes the only CLI and skill package; Crove has no equivalent yet } as const; export const mcpClients = [ @@ -65,11 +69,15 @@ export const getMcpConfig = ( client: AnyMcpClient, auth: McpAuth, mcpBase: string, - apiKey: string + apiKey: string, + // brandConfig.mcpConnectorName — the key every generated snippet registers + // this server under. Passed in rather than read from context so the helper + // stays pure and testable. + connectorName: string ): { config: string; hint: string } => { if (isChatOnlyMcpClient(client)) { return { - config: chatOnlyMcpClients[client], + config: chatOnlyMcpClients[client](mcpBase), hint: 'Paste this into the chat. The agent will ask you for your API key.', }; } @@ -91,62 +99,62 @@ export const getMcpConfig = ( switch (client) { case 'Claude Code': return { - config: `claude mcp add postiz --transport http "${oauthUrl}"`, + config: `claude mcp add ${connectorName} --transport http "${oauthUrl}"`, hint: 'Run this command in your terminal.', }; case 'Cursor': return { - config: json({ mcpServers: { postiz: { url: oauthUrl } } }), + config: json({ mcpServers: { [connectorName]: { url: oauthUrl } } }), hint: 'Add to .cursor/mcp.json in your project root.', }; case 'VS Code / Copilot': return { config: json({ - servers: { postiz: { type: 'http', url: oauthUrl } }, + servers: { [connectorName]: { type: 'http', url: oauthUrl } }, }), hint: 'Add to .vscode/mcp.json in your project root.', }; case 'Windsurf': return { config: json({ - mcpServers: { postiz: { serverUrl: oauthUrl } }, + mcpServers: { [connectorName]: { serverUrl: oauthUrl } }, }), hint: 'Add to ~/.codeium/windsurf/mcp_config.json', }; case 'Amp': return { - config: `amp mcp add postiz ${oauthUrl}`, + config: `amp mcp add ${connectorName} ${oauthUrl}`, hint: 'Run this command in your terminal.', }; case 'Codex': return { - config: `# ~/.codex/config.toml\n\n[mcp_servers.postiz]\nurl = "${oauthUrl}"`, - hint: 'Add to ~/.codex/config.toml, then run: codex mcp login postiz', + config: `# ~/.codex/config.toml\n\n[mcp_servers.${connectorName}]\nurl = "${oauthUrl}"`, + hint: `Add to ~/.codex/config.toml, then run: codex mcp login ${connectorName}`, }; case 'Gemini CLI': return { - config: json({ mcpServers: { postiz: { url: oauthUrl } } }), + config: json({ mcpServers: { [connectorName]: { url: oauthUrl } } }), hint: 'Add to ~/.gemini/settings.json', }; case 'Warp': return { - config: json({ postiz: { url: oauthUrl } }), + config: json({ [connectorName]: { url: oauthUrl } }), hint: 'Settings > MCP Servers > + Add, then paste this config.', }; case 'Hermes': return { - config: `# ~/.hermes/config.yaml\n\nmcp_servers:\n postiz:\n url: "${oauthUrl}"\n auth: oauth`, + config: `# ~/.hermes/config.yaml\n\nmcp_servers:\n ${connectorName}:\n url: "${oauthUrl}"\n auth: oauth`, hint: 'Add to ~/.hermes/config.yaml, then run /reload-mcp in the chat.', }; case 'OpenClaw': return { - config: `openclaw mcp add postiz --url ${oauthUrl} --transport streamable-http --auth oauth && openclaw mcp login postiz`, + config: `openclaw mcp add ${connectorName} --url ${oauthUrl} --transport streamable-http --auth oauth && openclaw mcp login ${connectorName}`, hint: 'Run this command in your terminal.', }; case 'NanoClaw': return { - config: `ncl groups config add-mcp-server --id --name postiz --url ${oauthUrl}`, - hint: 'Run this in your terminal, replace with the agent group that should get Postiz.', + config: `ncl groups config add-mcp-server --id --name ${connectorName} --url ${oauthUrl}`, + hint: 'Run this in your terminal, replace with the agent group that should get this server.', }; } } @@ -154,14 +162,17 @@ export const getMcpConfig = ( switch (client) { case 'Claude Code': return { - config: `claude mcp add --transport http postiz ${urlBase} --header "Authorization: ${bearer}"`, + config: `claude mcp add --transport http ${connectorName} ${urlBase} --header "Authorization: ${bearer}"`, hint: 'Run this command in your terminal.', }; case 'Cursor': return { config: json({ mcpServers: { - postiz: { url: urlBase, headers: { Authorization: bearer } }, + [connectorName]: { + url: urlBase, + headers: { Authorization: bearer }, + }, }, }), hint: 'Add to .cursor/mcp.json in your project root.', @@ -170,7 +181,7 @@ export const getMcpConfig = ( return { config: json({ servers: { - postiz: { + [connectorName]: { type: 'http', url: urlBase, headers: { Authorization: bearer }, @@ -183,7 +194,7 @@ export const getMcpConfig = ( return { config: json({ mcpServers: { - postiz: { + [connectorName]: { serverUrl: urlBase, headers: { Authorization: bearer }, }, @@ -195,21 +206,27 @@ export const getMcpConfig = ( return { config: json({ 'amp.mcpServers': { - postiz: { url: urlBase, headers: { Authorization: bearer } }, + [connectorName]: { + url: urlBase, + headers: { Authorization: bearer }, + }, }, }), hint: 'Add to your Amp settings.json', }; case 'Codex': return { - config: `# ~/.codex/config.toml\n\n[mcp_servers.postiz]\nurl = "${urlBase}"\nhttp_headers = { "Authorization" = "${bearer}" }`, + config: `# ~/.codex/config.toml\n\n[mcp_servers.${connectorName}]\nurl = "${urlBase}"\nhttp_headers = { "Authorization" = "${bearer}" }`, hint: 'Add to ~/.codex/config.toml', }; case 'Gemini CLI': return { config: json({ mcpServers: { - postiz: { url: urlBase, headers: { Authorization: bearer } }, + [connectorName]: { + url: urlBase, + headers: { Authorization: bearer }, + }, }, }), hint: 'Add to ~/.gemini/settings.json', @@ -217,13 +234,13 @@ export const getMcpConfig = ( case 'Warp': return { config: json({ - postiz: { url: urlBase, headers: { Authorization: bearer } }, + [connectorName]: { url: urlBase, headers: { Authorization: bearer } }, }), hint: 'Settings > MCP Servers > + Add, then paste this config.', }; case 'Hermes': return { - config: `# ~/.hermes/config.yaml\n\nmcp_servers:\n postiz:\n url: "${urlBase}"\n headers:\n Authorization: "${bearer}"`, + config: `# ~/.hermes/config.yaml\n\nmcp_servers:\n ${connectorName}:\n url: "${urlBase}"\n headers:\n Authorization: "${bearer}"`, hint: 'Add to ~/.hermes/config.yaml, then run /reload-mcp in the chat.', }; case 'OpenClaw': @@ -231,7 +248,7 @@ export const getMcpConfig = ( config: json({ mcp: { servers: { - postiz: { + [connectorName]: { url: urlBase, transport: 'streamable-http', headers: { Authorization: bearer }, @@ -244,8 +261,8 @@ export const getMcpConfig = ( case 'NanoClaw': // No headers flag, the key travels inside the URL like remote clients return { - config: `ncl groups config add-mcp-server --id --name postiz --url ${mcpBase}/mcp/${apiKey}`, - hint: 'Run this in your terminal, replace with the agent group that should get Postiz.', + config: `ncl groups config add-mcp-server --id --name ${connectorName} --url ${mcpBase}/mcp/${apiKey}`, + hint: 'Run this in your terminal, replace with the agent group that should get this server.', }; } }; @@ -293,16 +310,19 @@ const McpSection = ({ mcpBase: string; }) => { const t = useT(); - const { billingEnabled } = useVariables(); + const { billingEnabled, brandConfig } = useVariables(); const [activeClient, setActiveClient] = useState('Claude'); const [auth, setAuth] = useState('oauth'); const [revealed, setRevealed] = useState(false); + const connectorName = brandConfig?.mcpConnectorName || 'mcp'; + const { config, hint } = getMcpConfig( activeClient, auth, mcpBase, - user.publicApi + user.publicApi, + connectorName ); const baseUrl = auth === 'oauth' ? getMcpOauthUrl(mcpBase) : `${mcpBase}/mcp`; @@ -332,10 +352,10 @@ const McpSection = ({
- {billingEnabled && ( + {!!brandConfig?.claudeDirectoryUrl && billingEnabled && ( @@ -344,7 +364,7 @@ const McpSection = ({ )} @@ -458,16 +478,18 @@ const McpSection = ({ {!isRemoteMcpClient(activeClient) && !chatOnly && ( )} - {activeClient === 'Claude' && billingEnabled && ( - - - {t('add_to_claude', 'Add to Claude')} - - )} + {activeClient === 'Claude' && + billingEnabled && + !!brandConfig?.claudeDirectoryUrl && ( + + + {t('add_to_claude', 'Add to Claude')} + + )}
@@ -475,59 +497,64 @@ const McpSection = ({ ); }; +// One canonical list, not separate "Locally" and "CI" variants. This +// deployment runs no CLI auth server, so the upstream `postiz auth:login` +// device flow would authenticate against cli-auth.postiz.com — into the +// upstream cloud, not this instance. Every step is therefore API-key based. +// +// POSTIZ_API_URL is mandatory, not optional: without it the CLI defaults to +// api.postiz.com, so the key in step 3 is transmitted upstream and the posts +// land on the wrong account. +// +// Placeholders are substituted at render time by CliSection and by the +// onboarding modal, which is why this stays a module-level constant. export const localCliSteps = [ { label: 'Install the CLI', - code: 'npm install -g postiz', + code: 'npm install -g postiz', // branding-guard-allow: upstream publishes the only CLI; Crove ships no equivalent yet }, { - label: 'Run: postiz auth:login', - code: 'postiz auth:login', - }, - { - label: 'Install the Postiz skill for your AI agent', - code: 'npx skills add gitroomhq/postiz-agent', - }, -] as const; - -const ciCliSteps = [ - { - label: 'Install the CLI', - code: 'npm install -g postiz', + label: 'Point the CLI at this instance (required)', + code: 'export POSTIZ_API_URL="{API_URL}"', }, { label: 'Set your API key as an environment variable', code: 'export POSTIZ_API_KEY="{API_KEY}"', }, { - label: 'Install the Postiz skill for your AI agent', - code: 'npx skills add gitroomhq/postiz-agent', + label: 'Install the skill for your AI agent', + code: 'npx skills add gitroomhq/postiz-agent', // branding-guard-allow: upstream publishes the only agent skill; Crove ships no equivalent yet }, ] as const; -const CliSection = ({ apiKey }: { apiKey: string }) => { +const CliSection = ({ + apiKey, + apiBaseUrl, +}: { + apiKey: string; + apiBaseUrl: string; +}) => { const t = useT(); - const [mode, setMode] = useState<'local' | 'ci'>('local'); + const { brandConfig } = useVariables(); const [revealed, setRevealed] = useState(false); - const steps = - mode === 'local' - ? localCliSteps.map((step) => ({ ...step })) - : ciCliSteps.map((step) => ({ - ...step, - code: step.code.replace('{API_KEY}', apiKey), - })); + const steps = localCliSteps.map((step) => ({ + ...step, + code: step.code + .replace('{API_URL}', apiBaseUrl) + .replace('{API_KEY}', apiKey), + })); const displaySteps = - mode === 'ci' && !revealed - ? steps.map((step) => ({ + revealed || !apiKey + ? steps + : steps.map((step) => ({ ...step, code: step.code.replace( new RegExp(apiKey.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), '*'.repeat(apiKey.length) ), - })) - : steps; + })); return (
@@ -546,7 +573,7 @@ const CliSection = ({ apiKey }: { apiKey: string }) => {
@@ -555,25 +582,6 @@ const CliSection = ({ apiKey }: { apiKey: string }) => {
-
- {(['local', 'ci'] as const).map((m) => ( - - ))} -
{displaySteps.map((step, i) => (
@@ -585,38 +593,36 @@ const CliSection = ({ apiKey }: { apiKey: string }) => {
))}
- {mode === 'ci' && ( - - )} + {revealed ? ( + <> + + + + + ) : ( + <> + + + + )} + + {revealed ? t('hide', 'Hide') : t('reveal', 'Reveal')} + s.code).join(' && ')} label={t('copy_all', 'Copy All')} @@ -629,7 +635,7 @@ const CliSection = ({ apiKey }: { apiKey: string }) => { const PublicApiContent = () => { const user = useUser(); - const { backendUrl, frontEndUrl, mcpUrl } = useVariables(); + const { backendUrl, frontEndUrl, mcpUrl, brandConfig } = useVariables(); const toaster = useToaster(); const fetch = useFetch(); const decision = useDecisionModal(); @@ -702,7 +708,7 @@ const PublicApiContent = () => {
- +
diff --git a/apps/sdk/README.md b/apps/sdk/README.md index c5b77c7447..1779a40ca6 100644 --- a/apps/sdk/README.md +++ b/apps/sdk/README.md @@ -1,24 +1,29 @@ -# Postiz NodeJS SDK +# Crove NodeJS SDK -This is the NodeJS SDK for [Postiz](https://postiz.com). +This is the NodeJS SDK for [Crove](https://crove.com). You can start by installing the package: ```bash -npm install @postiz/node +npm install @crove/node ``` ## Usage ```typescript -import Postiz from '@postiz/node'; -const postiz = new Postiz('your api key', 'your self-hosted instance (optional)'); +import Crove from '@crove/node'; +const crove = new Crove('your api key', 'your self-hosted instance (optional)'); ``` +The second argument defaults to `https://post.crove.com/api`. Pass your own +base URL if you run a self-hosted instance. + The available methods are: -- `post(posts: CreatePostDto)` - Schedule a post to Postiz +- `post(posts: CreatePostDto)` - Schedule a post to Crove - `postList(filters: GetPostsDto)` - Get a list of posts -- `upload(file: Buffer, extension: string)` - Upload a file to Postiz +- `upload(file: Buffer, extension: string)` - Upload a file to Crove - `integrations()` - Get a list of connected channels - `deletePost(id: string)` - Delete a post by ID -Alternatively you can use the SDK with curl, check the [Postiz API documentation](https://docs.postiz.com/public-api) for more information. \ No newline at end of file +Your API key is available in the application under **Settings → Developer**. +Alternatively you can use the SDK with curl against the same +`/public/v1/*` routes. diff --git a/apps/sdk/src/index.ts b/apps/sdk/src/index.ts index d92d0d0ca7..5d414e89e7 100644 --- a/apps/sdk/src/index.ts +++ b/apps/sdk/src/index.ts @@ -12,10 +12,12 @@ function toQueryString(obj: Record): string { return params.toString(); } -export default class Postiz { +export default class Crove { constructor( private _apiKey: string, - private _path = 'https://api.postiz.com' + // Matches NEXT_PUBLIC_BACKEND_URL: the public API is served under /api, + // so every call below resolves to /public/v1/... + private _path = 'https://post.crove.com/api' ) {} async post(posts: CreatePostDto) { diff --git a/docker-compose.yaml b/docker-compose.yaml index 8141c200a5..2da1aa402f 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -1,5 +1,5 @@ services: - postiz: + postiz: # branding-guard-allow: compose service name — container_name, the postiz-postgres dependency, the volume names and scripts/validate-beta-compose.mjs all key off it. Renaming is an infra migration, not a branding fix. image: ghcr.io/dos/crove-post:latest container_name: postiz restart: always diff --git a/docs/upstream-sync.md b/docs/upstream-sync.md index 6ff3ee05c9..e4f4a521b2 100644 --- a/docs/upstream-sync.md +++ b/docs/upstream-sync.md @@ -23,15 +23,19 @@ File kịch bản `scripts/branding-guard.ts` thực thi các kiểm tra nghiêm ### Chạy Branding Guard cục bộ: ```powershell -pnpm exec tsx scripts/branding-guard.ts +npx tsx scripts/branding-guard.ts ``` +> `tsx` không nằm trong `dependencies` của repo, nên `pnpm exec tsx` sẽ fail +> với `ERR_PNPM_RECURSIVE_EXEC_FIRST_FAIL`. Dùng `npx tsx` — đúng cách mà +> `branding-guard.yml` đang chạy trong CI. + --- ## 3. GitHub Actions Workflows ### 3.1. Branding Guard CI (`.github/workflows/branding-guard.yml`) -- Kích hoạt khi có `push` hoặc `pull_request` vào nhánh `main` hoặc `master`. +- Kích hoạt khi có `push` hoặc `pull_request` vào nhánh `main` hoặc `dev`. - Đảm bảo không có bất kỳ commit nào phá vỡ các quy tắc branding và bảo mật. ### 3.2. Upstream Sync Workflow (`.github/workflows/sync-upstream.yml`) diff --git a/libraries/helpers/src/utils/brand.config.ts b/libraries/helpers/src/utils/brand.config.ts index d1d5417b63..5944d3020c 100644 --- a/libraries/helpers/src/utils/brand.config.ts +++ b/libraries/helpers/src/utils/brand.config.ts @@ -1,6 +1,13 @@ export interface BrandConfig { name: string; shortName: string; + /** + * Machine identifier for this deployment's MCP server, used as the key in + * generated client config (`claude mcp add `, `{ mcpServers: { : … } }`). + * Always a lowercase hyphenated slug so it stays safe as a shell argument, + * JSON key, YAML key and TOML table name. + */ + mcpConnectorName: string; description: string; companyName: string; logoUrl?: string; @@ -20,6 +27,7 @@ export interface BrandConfig { extensionStoreUrl?: string; tutorialUrl?: string; affiliateUrl?: string; + claudeDirectoryUrl?: string; } export interface PublicBrandConfig extends BrandConfig { @@ -29,6 +37,7 @@ export interface PublicBrandConfig extends BrandConfig { export const DEFAULT_BRAND_CONFIG: BrandConfig = { name: 'Postiz', shortName: 'Postiz', + mcpConnectorName: 'postiz', description: 'The open-source social media management platform', companyName: 'Postiz', logoUrl: '', @@ -48,6 +57,9 @@ export const DEFAULT_BRAND_CONFIG: BrandConfig = { extensionStoreUrl: '', tutorialUrl: '', affiliateUrl: '', + // Deliberately empty rather than the upstream listing: a fork that forgets + // to set this must hide the button, not install a competitor's connector. + claudeDirectoryUrl: '', }; const DANGEROUS_PROTOCOLS = ['javascript:', 'data:', 'vbscript:', 'file:']; @@ -94,9 +106,43 @@ export function sanitizeHexColor(color?: string | null): string | undefined { return undefined; } +/** + * Reduce a display name to a machine-safe identifier: lowercase, hyphenated, + * no leading/trailing hyphen. "Crove (Beta)" -> "crove-beta". + * Returns '' when nothing alphanumeric survives, so callers can fall back. + */ +export function slugifyIdentifier(value?: string | null): string { + if (!value || typeof value !== 'string') return ''; + return value + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); +} + +/** + * Join a brand URL base with a path, tolerating a trailing slash on the base + * and a leading slash on the path. Needed because sanitizeUrl normalises + * through the URL constructor, so a bare origin such as + * BRAND_DOCS_URL=https://docs.example.com comes back as + * "https://docs.example.com/" and naive concatenation yields a double slash. + */ +export function joinBrandUrl(base?: string | null, path?: string | null): string { + const trimmedBase = (base || '').replace(/\/+$/, ''); + const trimmedPath = (path || '').replace(/^\/+/, ''); + if (!trimmedBase) return trimmedPath ? `/${trimmedPath}` : ''; + return trimmedPath ? `${trimmedBase}/${trimmedPath}` : trimmedBase; +} + export function getBrandConfig(env: Record = process.env): PublicBrandConfig { const brandName = (env.BRAND_NAME || env.NEXT_PUBLIC_BRAND_NAME || '').trim() || DEFAULT_BRAND_CONFIG.name; const brandShortName = (env.BRAND_SHORT_NAME || env.NEXT_PUBLIC_BRAND_SHORT_NAME || '').trim() || brandName; + // Explicit override wins; otherwise derive from the short name so a + // deployment that never sets it still gets a correctly branded connector + // key ("Crove" -> "crove") instead of the upstream one. + const mcpConnectorName = + slugifyIdentifier(env.BRAND_MCP_CONNECTOR_NAME || env.NEXT_PUBLIC_BRAND_MCP_CONNECTOR_NAME) || + slugifyIdentifier(brandShortName) || + 'mcp'; const brandDescription = (env.BRAND_DESCRIPTION || env.NEXT_PUBLIC_BRAND_DESCRIPTION || '').trim() || DEFAULT_BRAND_CONFIG.description; const brandCompanyName = (env.BRAND_COMPANY_NAME || env.NEXT_PUBLIC_BRAND_COMPANY_NAME || '').trim() || brandName; @@ -121,12 +167,14 @@ export function getBrandConfig(env: Record = process const extensionStoreUrl = sanitizeUrl(env.BRAND_EXTENSION_STORE_URL || env.NEXT_PUBLIC_BRAND_EXTENSION_STORE_URL); const tutorialUrl = sanitizeUrl(env.BRAND_TUTORIAL_URL || env.NEXT_PUBLIC_BRAND_TUTORIAL_URL); const affiliateUrl = sanitizeUrl(env.BRAND_AFFILIATE_URL || env.NEXT_PUBLIC_BRAND_AFFILIATE_URL); + const claudeDirectoryUrl = sanitizeUrl(env.BRAND_CLAUDE_DIRECTORY_URL || env.NEXT_PUBLIC_BRAND_CLAUDE_DIRECTORY_URL); const isCustomBrand = brandName.toLowerCase() !== 'postiz' && brandName.toLowerCase() !== 'gitroom'; return { name: brandName, shortName: brandShortName, + mcpConnectorName, description: brandDescription, companyName: brandCompanyName, logoUrl, @@ -146,6 +194,7 @@ export function getBrandConfig(env: Record = process extensionStoreUrl, tutorialUrl, affiliateUrl, + claudeDirectoryUrl, isCustomBrand, }; } diff --git a/libraries/nestjs-libraries/src/chat/mastra.service.ts b/libraries/nestjs-libraries/src/chat/mastra.service.ts index e95f2dd582..39d8d062b4 100644 --- a/libraries/nestjs-libraries/src/chat/mastra.service.ts +++ b/libraries/nestjs-libraries/src/chat/mastra.service.ts @@ -14,6 +14,7 @@ export class MastraService { new Mastra({ storage: pStore, agents: { + // branding-guard-allow: 'postiz' is a backward-compatibility agent alias. Clients that registered the server under the upstream name must keep resolving; crove_post and post are the branded equivalents (docs/architecture.md §8.2). postiz: await this._loadToolsService.agent('postiz'), crove_post: await this._loadToolsService.agent('crove_post'), post: await this._loadToolsService.agent('post'), diff --git a/libraries/nestjs-libraries/src/chat/start.mcp.ts b/libraries/nestjs-libraries/src/chat/start.mcp.ts index ab2c771902..1d52aa83f2 100644 --- a/libraries/nestjs-libraries/src/chat/start.mcp.ts +++ b/libraries/nestjs-libraries/src/chat/start.mcp.ts @@ -71,6 +71,7 @@ export const startMcp = async (app: INestApplication) => { version: '1.0.0', tools, agents: { + // branding-guard-allow: 'postiz' is a backward-compatibility agent alias. Clients that registered the server under the upstream name must keep resolving; crove_post and post are the branded equivalents (docs/architecture.md §8.2). postiz: agent, crove_post: agent, post: agent, @@ -83,13 +84,13 @@ export const startMcp = async (app: INestApplication) => { // exposed as an annotation-less catch-all ask_postiz tool, which the // ChatGPT and Claude directory reviews reject const oauthServer = new MCPServer({ - name: 'Postiz MCP', + name: `${brand.name} MCP`, version: '1.0.0', tools, }); const claudeOauthServer = new MCPServer({ - name: 'Postiz MCP', + name: `${brand.name} MCP`, version: '1.0.0', tools: claudeTools, }); diff --git a/package.json b/package.json index 848251f570..43fc495fe7 100644 --- a/package.json +++ b/package.json @@ -326,13 +326,14 @@ "@types/react-dom": "19.1.6", "tar": "^7.5.19", "form-data": "^2.5.4", - "protobufjs": "^7.5.5", + "protobufjs": "7.5.5", "shell-quote": "^1.8.4", "fast-uri": "^3.1.6", "hono": "^4.12.34", "immutable": "^5.1.8", "handlebars": "^4.7.9", - "happy-dom": "^20.0.0" + "happy-dom": "^20.0.0", + "@grpc/grpc-js": "1.12.4" }, "onlyBuiltDependencies": [ "bcrypt", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b313a3f59d..3e9254d401 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,13 +14,14 @@ overrides: '@types/react-dom': 19.1.6 tar: ^7.5.19 form-data: ^2.5.4 - protobufjs: ^7.5.5 + protobufjs: 7.5.5 shell-quote: ^1.8.4 fast-uri: ^3.1.6 hono: ^4.12.34 immutable: ^5.1.8 handlebars: ^4.7.9 happy-dom: ^20.0.0 + '@grpc/grpc-js': 1.12.4 importers: @@ -118,7 +119,7 @@ importers: version: 11.1.21(@nestjs/common@11.1.21(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@11.1.21)(@nestjs/platform-express@11.1.21)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/microservices': specifier: ^11.1.21 - version: 11.1.21(@grpc/grpc-js@1.14.3)(@nestjs/common@11.1.21(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.21)(cache-manager@7.2.8)(ioredis@5.10.0)(reflect-metadata@0.2.2)(rxjs@7.8.2) + version: 11.1.21(@grpc/grpc-js@1.12.4)(@nestjs/common@11.1.21(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.21)(cache-manager@7.2.8)(ioredis@5.10.0)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/platform-express': specifier: ^11.1.21 version: 11.1.21(@nestjs/common@11.1.21(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.21) @@ -2992,12 +2993,12 @@ packages: resolution: {integrity: sha512-ZpJxMqB+Qfe3rp6uszCQoag4nSw42icURnBRfFYSOmTgEeOe4rD0vYlbA8spvCu2TlCesNTlEN9BLWtQqLxabA==} engines: {node: '>=18.0.0'} - '@grpc/grpc-js@1.14.3': - resolution: {integrity: sha512-Iq8QQQ/7X3Sac15oB6p0FmUg/klxQvXLeileoqrTRGJYLV+/9tubbr9ipz0GKHjmXVsgFPo/+W+2cA8eNcR+XA==} + '@grpc/grpc-js@1.12.4': + resolution: {integrity: sha512-NBhrxEWnFh0FxeA0d//YP95lRFsSx2TNLEUQg4/W+5f/BMxcCjgOOIT24iD+ZB/tZw057j44DaIxja7w4XMrhg==} engines: {node: '>=12.10.0'} - '@grpc/proto-loader@0.8.0': - resolution: {integrity: sha512-rc1hOQtjIWGxcxpb9aHAfLpIctjEnsDehj0DAiVfBlmT84uvR0uUtN2hEi/ecvWVjXUGf5qPF4qEgiLOx1YIMQ==} + '@grpc/proto-loader@0.7.15': + resolution: {integrity: sha512-tMXdRCfYVixjuFK+Hk0Q1s38gV9zDiDJfWL3h1rv4Qc39oILCu1TRTDt7+fGUI8K4G1Fj125Hx/ru3azECWTyQ==} engines: {node: '>=6'} hasBin: true @@ -5105,7 +5106,7 @@ packages: '@nestjs/microservices@11.1.21': resolution: {integrity: sha512-NRDK/lCSD5ul53NE5YHuXKygI7P73OLSR5gljFBrUfLip37L/+s96MqUBHCxWt2YCd0GhPJfqO9zttJBxpX5sA==} peerDependencies: - '@grpc/grpc-js': '*' + '@grpc/grpc-js': 1.12.4 '@nestjs/common': ^11.0.0 '@nestjs/core': ^11.0.0 '@nestjs/websockets': ^11.0.0 @@ -6259,6 +6260,9 @@ packages: '@protobufjs/float@1.0.2': resolution: {integrity: sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==} + '@protobufjs/inquire@1.1.2': + resolution: {integrity: sha512-pa0vFRuws4wkvaXKK1uXZMAwAX4/t8ANaJo45iw/oQHNQ9q5xUzwgFmVJGXiga2BeN+zpX7Vf9vmsiIa2J+MUw==} + '@protobufjs/path@1.1.2': resolution: {integrity: sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==} @@ -14678,8 +14682,8 @@ packages: resolution: {integrity: sha512-SAzp/O4Yh02jGdRc+uIrGoe87dkN/XtwxfZ4ZyafJHymd79ozp5VG5nyZ7ygqPM5+cpLDjjGnYFUkngonyDPOQ==} engines: {node: '>=14.0.0'} - protobufjs@7.6.6: - resolution: {integrity: sha512-dYDWdjSl5RNb7SgPxGQcRU+GtvP7s2fpkrY0r432PcOIaZ0/rBcxEZnQN67iJhFuQiVw754JDoPruPCNdGsbjg==} + protobufjs@7.5.5: + resolution: {integrity: sha512-3wY1AxV+VBNW8Yypfd1yQY9pXnqTAN+KwQxL8iYm3/BjKYMNg4i0owhEe26PWDOMaIrzeeF98Lqd5NGz4omiIg==} engines: {node: '>=12.0.0'} proxy-addr@2.0.7: @@ -20297,16 +20301,16 @@ snapshots: '@repeaterjs/repeater': 3.0.6 tslib: 2.8.1 - '@grpc/grpc-js@1.14.3': + '@grpc/grpc-js@1.12.4': dependencies: - '@grpc/proto-loader': 0.8.0 + '@grpc/proto-loader': 0.7.15 '@js-sdsl/ordered-map': 4.4.2 - '@grpc/proto-loader@0.8.0': + '@grpc/proto-loader@0.7.15': dependencies: lodash.camelcase: 4.3.0 long: 5.3.2 - protobufjs: 7.6.6 + protobufjs: 7.5.5 yargs: 17.7.2 '@headlessui/react@2.2.9(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': @@ -22041,7 +22045,7 @@ snapshots: tslib: 2.8.1 uid: 2.0.2 optionalDependencies: - '@nestjs/microservices': 11.1.21(@grpc/grpc-js@1.14.3)(@nestjs/common@11.1.21(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.21)(cache-manager@7.2.8)(ioredis@5.10.0)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/microservices': 11.1.21(@grpc/grpc-js@1.12.4)(@nestjs/common@11.1.21(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.21)(cache-manager@7.2.8)(ioredis@5.10.0)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/platform-express': 11.1.21(@nestjs/common@11.1.21(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.21) '@nestjs/core@11.1.21(@nestjs/common@11.1.21(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@11.1.21)(@nestjs/platform-express@11.1.21)(reflect-metadata@0.2.2)(rxjs@7.8.2)': @@ -22056,7 +22060,7 @@ snapshots: tslib: 2.8.1 uid: 2.0.2 optionalDependencies: - '@nestjs/microservices': 11.1.21(@grpc/grpc-js@1.14.3)(@nestjs/common@11.1.21(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.21)(cache-manager@7.2.8)(ioredis@5.10.0)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/microservices': 11.1.21(@grpc/grpc-js@1.12.4)(@nestjs/common@11.1.21(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.21)(cache-manager@7.2.8)(ioredis@5.10.0)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/platform-express': 11.1.21(@nestjs/common@11.1.21(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.21) '@nestjs/mapped-types@2.1.1(@nestjs/common@11.1.21(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)': @@ -22067,7 +22071,7 @@ snapshots: class-transformer: 0.5.1 class-validator: 0.14.4 - '@nestjs/microservices@11.1.21(@grpc/grpc-js@1.14.3)(@nestjs/common@11.1.21(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.21)(cache-manager@7.2.8)(ioredis@5.10.0)(reflect-metadata@0.2.2)(rxjs@7.8.2)': + '@nestjs/microservices@11.1.21(@grpc/grpc-js@1.12.4)(@nestjs/common@11.1.21(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.21)(cache-manager@7.2.8)(ioredis@5.10.0)(reflect-metadata@0.2.2)(rxjs@7.8.2)': dependencies: '@nestjs/common': 11.1.21(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/core': 11.1.21(@nestjs/common@11.1.21(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@11.1.21)(@nestjs/platform-express@11.1.21)(reflect-metadata@0.2.2)(rxjs@7.8.2) @@ -22076,7 +22080,7 @@ snapshots: rxjs: 7.8.2 tslib: 2.8.1 optionalDependencies: - '@grpc/grpc-js': 1.14.3 + '@grpc/grpc-js': 1.12.4 cache-manager: 7.2.8 ioredis: 5.10.0 @@ -22145,7 +22149,7 @@ snapshots: '@nestjs/core': 11.1.21(@nestjs/common@11.1.21(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@11.1.21)(@nestjs/platform-express@11.1.21)(reflect-metadata@0.2.2)(rxjs@7.8.2) tslib: 2.8.1 optionalDependencies: - '@nestjs/microservices': 11.1.21(@grpc/grpc-js@1.14.3)(@nestjs/common@11.1.21(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.21)(cache-manager@7.2.8)(ioredis@5.10.0)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/microservices': 11.1.21(@grpc/grpc-js@1.12.4)(@nestjs/common@11.1.21(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.21)(cache-manager@7.2.8)(ioredis@5.10.0)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/platform-express': 11.1.21(@nestjs/common@11.1.21(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.21) '@nestjs/throttler@6.5.0(@nestjs/common@11.1.21(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.21)(reflect-metadata@0.2.2)': @@ -22402,7 +22406,7 @@ snapshots: '@opentelemetry/exporter-logs-otlp-grpc@0.203.0(@opentelemetry/api@1.9.0)': dependencies: - '@grpc/grpc-js': 1.14.3 + '@grpc/grpc-js': 1.12.4 '@opentelemetry/api': 1.9.0 '@opentelemetry/core': 2.0.1(@opentelemetry/api@1.9.0) '@opentelemetry/otlp-exporter-base': 0.203.0(@opentelemetry/api@1.9.0) @@ -22441,7 +22445,7 @@ snapshots: '@opentelemetry/exporter-metrics-otlp-grpc@0.203.0(@opentelemetry/api@1.9.0)': dependencies: - '@grpc/grpc-js': 1.14.3 + '@grpc/grpc-js': 1.12.4 '@opentelemetry/api': 1.9.0 '@opentelemetry/core': 2.0.1(@opentelemetry/api@1.9.0) '@opentelemetry/exporter-metrics-otlp-http': 0.203.0(@opentelemetry/api@1.9.0) @@ -22479,7 +22483,7 @@ snapshots: '@opentelemetry/exporter-trace-otlp-grpc@0.203.0(@opentelemetry/api@1.9.0)': dependencies: - '@grpc/grpc-js': 1.14.3 + '@grpc/grpc-js': 1.12.4 '@opentelemetry/api': 1.9.0 '@opentelemetry/core': 2.0.1(@opentelemetry/api@1.9.0) '@opentelemetry/otlp-exporter-base': 0.203.0(@opentelemetry/api@1.9.0) @@ -23103,7 +23107,7 @@ snapshots: '@opentelemetry/otlp-grpc-exporter-base@0.203.0(@opentelemetry/api@1.9.0)': dependencies: - '@grpc/grpc-js': 1.14.3 + '@grpc/grpc-js': 1.12.4 '@opentelemetry/api': 1.9.0 '@opentelemetry/core': 2.0.1(@opentelemetry/api@1.9.0) '@opentelemetry/otlp-exporter-base': 0.203.0(@opentelemetry/api@1.9.0) @@ -23118,7 +23122,7 @@ snapshots: '@opentelemetry/sdk-logs': 0.203.0(@opentelemetry/api@1.9.0) '@opentelemetry/sdk-metrics': 2.0.1(@opentelemetry/api@1.9.0) '@opentelemetry/sdk-trace-base': 2.0.1(@opentelemetry/api@1.9.0) - protobufjs: 7.6.6 + protobufjs: 7.5.5 '@opentelemetry/otlp-transformer@0.208.0(@opentelemetry/api@1.9.0)': dependencies: @@ -23129,7 +23133,7 @@ snapshots: '@opentelemetry/sdk-logs': 0.208.0(@opentelemetry/api@1.9.0) '@opentelemetry/sdk-metrics': 2.2.0(@opentelemetry/api@1.9.0) '@opentelemetry/sdk-trace-base': 2.2.0(@opentelemetry/api@1.9.0) - protobufjs: 7.6.6 + protobufjs: 7.5.5 '@opentelemetry/propagator-b3@2.0.1(@opentelemetry/api@1.9.0)': dependencies: @@ -23511,6 +23515,8 @@ snapshots: '@protobufjs/float@1.0.2': {} + '@protobufjs/inquire@1.1.2': {} + '@protobufjs/path@1.1.2': {} '@protobufjs/pool@1.1.0': {} @@ -25131,7 +25137,7 @@ snapshots: '@temporalio/client@1.15.0': dependencies: - '@grpc/grpc-js': 1.14.3 + '@grpc/grpc-js': 1.12.4 '@temporalio/common': 1.15.0 '@temporalio/proto': 1.15.0 abort-controller: 3.0.0 @@ -25148,7 +25154,7 @@ snapshots: '@temporalio/core-bridge@1.15.0': dependencies: - '@grpc/grpc-js': 1.14.3 + '@grpc/grpc-js': 1.12.4 '@temporalio/common': 1.15.0 '@temporalio/nexus@1.15.0': @@ -25162,11 +25168,11 @@ snapshots: '@temporalio/proto@1.15.0': dependencies: long: 5.3.2 - protobufjs: 7.6.6 + protobufjs: 7.5.5 '@temporalio/worker@1.15.0(@swc/helpers@0.5.13)(esbuild@0.27.7)(tslib@2.8.1)': dependencies: - '@grpc/grpc-js': 1.14.3 + '@grpc/grpc-js': 1.12.4 '@swc/core': 1.5.7(@swc/helpers@0.5.13) '@temporalio/activity': 1.15.0 '@temporalio/client': 1.15.0 @@ -25180,7 +25186,7 @@ snapshots: memfs: 4.56.11(tslib@2.8.1) nexus-rpc: 0.0.1 proto3-json-serializer: 2.0.2 - protobufjs: 7.6.6 + protobufjs: 7.5.5 rxjs: 7.8.2 source-map: 0.7.6 source-map-loader: 4.0.2(webpack@5.105.4(@swc/core@1.5.7(@swc/helpers@0.5.13))(esbuild@0.27.7)) @@ -32564,7 +32570,7 @@ snapshots: nice-grpc@2.1.14: dependencies: - '@grpc/grpc-js': 1.14.3 + '@grpc/grpc-js': 1.12.4 abort-controller-x: 0.4.3 nice-grpc-common: 2.0.2 @@ -33581,9 +33587,9 @@ snapshots: proto3-json-serializer@2.0.2: dependencies: - protobufjs: 7.6.6 + protobufjs: 7.5.5 - protobufjs@7.6.6: + protobufjs@7.5.5: dependencies: '@protobufjs/aspromise': 1.1.2 '@protobufjs/base64': 1.1.2 @@ -33591,6 +33597,7 @@ snapshots: '@protobufjs/eventemitter': 1.1.1 '@protobufjs/fetch': 1.1.1 '@protobufjs/float': 1.0.2 + '@protobufjs/inquire': 1.1.2 '@protobufjs/path': 1.1.2 '@protobufjs/pool': 1.1.0 '@protobufjs/utf8': 1.1.2 diff --git a/scripts/branding-guard.ts b/scripts/branding-guard.ts index d36c82fe07..5ef04c6065 100644 --- a/scripts/branding-guard.ts +++ b/scripts/branding-guard.ts @@ -9,6 +9,8 @@ import { sanitizeUrl, sanitizeHexColor, applyBrandToString, + slugifyIdentifier, + joinBrandUrl, DEFAULT_BRAND_CONFIG, } from '../libraries/helpers/src/utils/brand.config'; import { readdirSync, readFileSync, statSync } from 'fs'; @@ -34,6 +36,9 @@ console.log('=== Running Branding Guard Validations ===\n'); assert(defaults.isCustomBrand === false, 'Default isCustomBrand should be false'); assert(defaults.sourceUrl === DEFAULT_BRAND_CONFIG.sourceUrl, 'Default sourceUrl must point to upstream repository'); assert(defaults.primaryColor === '#612BD3', 'Default primaryColor should be #612BD3'); + // Fail closed: a deployment that never sets BRAND_CLAUDE_DIRECTORY_URL must + // hide the button, not fall back to the upstream listing. + assert(!defaults.claudeDirectoryUrl, 'Default claudeDirectoryUrl must be empty so the Add-to-Claude button fails closed'); } // 2. Custom branding test @@ -46,6 +51,7 @@ console.log('=== Running Branding Guard Validations ===\n'); BRAND_LOGO_URL: 'https://crove.app/logo.png', BRAND_DEFAULT_EMAIL_DOMAIN: 'crove.app', MAIN_URL: 'https://crove.app', + BRAND_CLAUDE_DIRECTORY_URL: 'https://claude.ai/directory/crove', }); assert(custom.name === 'Crove', 'Custom brand name matches'); @@ -58,6 +64,60 @@ console.log('=== Running Branding Guard Validations ===\n'); custom.websiteUrl === 'https://crove.app' || custom.websiteUrl === 'https://crove.app/', 'websiteUrl auto fallbacks to MAIN_URL when BRAND_WEBSITE_URL omitted' ); + assert( + custom.claudeDirectoryUrl === 'https://claude.ai/directory/crove', + 'claudeDirectoryUrl parsed from BRAND_CLAUDE_DIRECTORY_URL' + ); +} + +// 2b. MCP connector name. This string is emitted into generated client config +// as a shell argument, a JSON key, a YAML key, a TOML table name and a URL +// query param, so it must always be a safe lowercase slug — never the display +// name, which can contain spaces and punctuation. +{ + assert(slugifyIdentifier('Crove (Beta)') === 'crove-beta', 'slugifyIdentifier hyphenates spaces and punctuation'); + assert(slugifyIdentifier(' --Crove Post-- ') === 'crove-post', 'slugifyIdentifier trims leading/trailing hyphens'); + assert(slugifyIdentifier('!!!') === '', 'slugifyIdentifier returns empty when nothing alphanumeric survives'); + assert(slugifyIdentifier(undefined) === '', 'slugifyIdentifier tolerates undefined'); + + const derived = getBrandConfig({ BRAND_SHORT_NAME: 'Crove' }); + assert(derived.mcpConnectorName === 'crove', 'mcpConnectorName derives from BRAND_SHORT_NAME when unset'); + + const beta = getBrandConfig({ BRAND_SHORT_NAME: 'Crove (Beta)' }); + assert(beta.mcpConnectorName === 'crove-beta', 'mcpConnectorName slugifies a short name that is not shell-safe'); + + const explicit = getBrandConfig({ + BRAND_SHORT_NAME: 'Crove', + BRAND_MCP_CONNECTOR_NAME: 'crove-mcp', + }); + assert(explicit.mcpConnectorName === 'crove-mcp', 'BRAND_MCP_CONNECTOR_NAME overrides the derived slug'); + + const dirty = getBrandConfig({ BRAND_MCP_CONNECTOR_NAME: ' --Crove Post-- ' }); + assert(dirty.mcpConnectorName === 'crove-post', 'An unsafe BRAND_MCP_CONNECTOR_NAME is still slugified, not passed through'); + + const unusable = getBrandConfig({ BRAND_NAME: '!!!', BRAND_SHORT_NAME: '!!!' }); + assert(unusable.mcpConnectorName === 'mcp', 'mcpConnectorName falls back to "mcp" rather than emitting an empty key'); + + assert( + /^[a-z0-9]+(-[a-z0-9]+)*$/.test(getBrandConfig({}).mcpConnectorName), + 'Default mcpConnectorName is a safe slug' + ); +} + +// 2c. joinBrandUrl. sanitizeUrl normalises a bare origin through the URL +// constructor, so BRAND_DOCS_URL="https://docs.example.com" is stored as +// "https://docs.example.com/". Naive `${docsUrl}/path` concatenation then +// produces a double slash — this is the regression the helper exists to stop. +{ + const normalised = sanitizeUrl('https://docs.example.com')!; + assert(normalised === 'https://docs.example.com/', 'sanitizeUrl appends a trailing slash to a bare origin (precondition for the join tests)'); + assert(joinBrandUrl(normalised, 'public-api') === 'https://docs.example.com/public-api', 'joinBrandUrl never emits a double slash'); + assert(joinBrandUrl('https://docs.example.com', 'public-api') === 'https://docs.example.com/public-api', 'joinBrandUrl handles a base without a trailing slash'); + assert(joinBrandUrl('https://docs.example.com/', '/public-api') === 'https://docs.example.com/public-api', 'joinBrandUrl handles a leading slash on the path'); + assert(joinBrandUrl('https://docs.example.com//', '//public-api') === 'https://docs.example.com/public-api', 'joinBrandUrl collapses repeated slashes at the join'); + assert(joinBrandUrl('https://docs.example.com/', '') === 'https://docs.example.com', 'joinBrandUrl with an empty path returns the base'); + assert(joinBrandUrl(undefined, 'public-api') === '/public-api', 'joinBrandUrl with no base degrades to a root-relative path'); + assert(joinBrandUrl(undefined, undefined) === '', 'joinBrandUrl with nothing returns an empty string rather than "undefined"'); } // 3. Security sanitization tests @@ -128,7 +188,6 @@ console.log('=== Running Branding Guard Validations ===\n'); 'sonar-project.properties', // upstream tenant key, pending deletion 'Jenkins/', 'railway.toml', '.devcontainer/', // dead upstream infra '.github/workflows/issue-label-triggers.yml', // upstream automation - 'apps/sdk/package.json', // upstream author field, pending rewrite 'libraries/nestjs-libraries/src/sentry/initialize.sentry.ts', 'CHANGELOG.md', 'ROADMAP.md', ]; @@ -142,16 +201,43 @@ console.log('=== Running Branding Guard Validations ===\n'); // Directories we own and actively edit — leaks here are strict. const STRICT_PREFIXES = [ 'apps/backend/src/', 'apps/frontend/src/', 'apps/crove-sso/', + 'apps/sdk/', 'libraries/nestjs-libraries/src/', 'libraries/helpers/src/', 'libraries/react-shared-libraries/src/', 'scripts/', ]; + // Two families of leak, deliberately separate: + // ATTRIBUTION — AGPL-3.0 obligations (repo, image, author). Must survive + // in LICENSE/README/docs, so those stay in INHERITED. + // RUNTIME — endpoints and install commands a customer actually + // executes. These are never attribution: they send Crove + // traffic or Crove credentials to upstream infrastructure. + // The RUNTIME family was missing entirely, which is how a rebranded + // @crove/node SDK shipped with a default _path of https://api.postiz.com. const PATTERNS: Array<[RegExp, string]> = [ + // --- attribution --- [/platform\.postiz\.com/gi, 'upstream platform domain'], [/gitroomhq\/postiz-app/gi, 'upstream container image'], [/github\.com\/gitroomhq/gi, 'upstream repository URL'], [/\bNevo David\b/g, 'upstream author name'], [/\bpostiz-app\b/gi, 'upstream repository name'], + // --- runtime: upstream endpoints --- + [/\b(?:api|docs|affiliate|cli-auth)\.postiz\.com\b/gi, 'upstream runtime endpoint'], + // --- runtime: upstream install/skill instructions --- + [/gitroomhq\/postiz-agent/gi, 'upstream agent skill package'], + [/install\s+-g\s+postiz\b/gi, 'upstream CLI install command'], + // --- runtime: upstream-branded destinations shown to customers --- + [/claude\.ai\/directory\/postiz/gi, 'upstream Claude directory listing'], + [/'Postiz MCP'|"Postiz MCP"/g, 'upstream MCP server name'], + // --- runtime: upstream identifier baked into generated client config --- + // These are the connector keys customers paste into Claude/Cursor/Codex/ + // etc. They must come from brandConfig.mcpConnectorName, not a literal. + // `postiz://` is deliberately NOT matched: it is a registered mobile URL + // scheme, not a connector key. [ \t] rather than \s so a match can never + // span a newline and report the wrong line number. + [/(?:^|[{,])[ \t]*postiz[ \t]*:/gm, 'upstream MCP connector key'], + [/\b(?:add|login)\s+postiz\b|(?:--name|name=)postiz\b/g, 'upstream MCP connector name in a command'], + [/mcp_servers\.postiz\b/g, 'upstream MCP connector name in a TOML table'], ]; const TEXT_EXTS = new Set([ '.ts', '.tsx', '.js', '.mjs', '.cjs', '.json', '.jsonc', '.yaml', @@ -179,6 +265,7 @@ console.log('=== Running Branding Guard Validations ===\n'); const repoRoot = join(__dirname, '..'); let strictHits = 0; let inheritedHits = 0; + let allowedHits = 0; for (const file of walk(repoRoot)) { const rel = file.slice(repoRoot.length + 1).replace(/\\/g, '/'); const ext = extname(file); @@ -190,28 +277,37 @@ console.log('=== Running Branding Guard Validations ===\n'); } catch { continue; } + const strict = + STRICT_EXTRA.includes(rel) || + STRICT_PREFIXES.some((p) => rel.startsWith(p)); + const lines = content.split('\n'); for (const [pattern, label] of PATTERNS) { - const matches = content.match(pattern); - if (!matches) continue; - const strict = - STRICT_EXTRA.includes(rel) || - STRICT_PREFIXES.some((p) => rel.startsWith(p)); - const line = content - .slice(0, content.search(pattern)) - .split('\n').length; - // Commented-out examples of BRAND_* attribution are documentation of - // the AGPL knob, not shipped config — never a strict leak. - const matchedLine = content.split('\n')[line - 1] ?? ''; - if (/^\s*(#|\/\/|\/\*|\{\/\*)/.test(matchedLine)) continue; - if (strict) { - strictHits += matches.length; - console.error( - `[FAIL] ${rel}:${line} — ${label} (${matches.length}×)` - ); - failed = true; - } else { - inheritedHits += matches.length; - console.warn(`[INHERITED] ${rel} — ${label} (${matches.length}×)`); + // Per-match line numbers: the previous version reported one line for + // every match in the file, so a file with three leaks on three lines + // showed as "(3×)" against the first line only. + for (const match of content.matchAll(pattern)) { + const lineNo = content.slice(0, match.index ?? 0).split('\n').length; + const matchedLine = lines[lineNo - 1] ?? ''; + const prevLine = lines[lineNo - 2] ?? ''; + // Commented-out examples of BRAND_* attribution are documentation of + // the AGPL knob, not shipped config — never a strict leak. + if (/^\s*(#|\/\/|\/\*|\{\/\*)/.test(matchedLine)) continue; + // Deliberate upstream reference on the matched line or the line above + // it. Must carry a reason so the exception is reviewable, and is + // still counted and printed — exceptions stay visible, never silent. + if (/branding-guard-allow:/.test(`${prevLine}\n${matchedLine}`)) { + allowedHits += 1; + console.warn(`[ALLOWED] ${rel}:${lineNo} — ${label}`); + continue; + } + if (strict) { + strictHits += 1; + console.error(`[FAIL] ${rel}:${lineNo} — ${label}`); + failed = true; + } else { + inheritedHits += 1; + console.warn(`[INHERITED] ${rel}:${lineNo} — ${label}`); + } } } } @@ -219,6 +315,9 @@ console.log('=== Running Branding Guard Validations ===\n'); console.log( `Repo scan: ${inheritedHits} inherited upstream mentions (tolerated, see INHERITED list)` ); + console.log( + `Repo scan: ${allowedHits} deliberate upstream references (see branding-guard-allow comments)` + ); } console.log('\n=== Branding Guard Summary ===');