diff --git a/.env.example b/.env.example index 1c616e5c27..5a036a4d42 100644 --- a/.env.example +++ b/.env.example @@ -47,8 +47,13 @@ RUN_CRON="true" MCP_ONLY="false" # Disable Secure flag on cookies for non-HTTPS local development (optional) NOT_SECURED="false" -# Custom mobile application URL scheme for auth callbacks (optional) -MOBILE_APP_SCHEME="postiz://auth/callback" +# Custom mobile application URL scheme for auth callbacks. +# LEAVE EMPTY unless this deployment ships its own mobile app. The backend +# redirects a live OAuth authorization code to this scheme, so any value here +# hands that code to whichever app registered it on the device. When empty, +# GET /oauth-mobile-callback refuses with 501 instead of redirecting, and the +# frontend omits the mobile deep link entirely. +MOBILE_APP_SCHEME="" # Hourly rate limit for public API requests per organization API_LIMIT="100" diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a4628de632..ada6989534 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -22,6 +22,40 @@ jobs: matrix: node-version: ['22.12.0'] + # The bootstrap / OAuth-consent suites refuse to run unless DATABASE_URL + # points at 127.0.0.1:15491 (or :15432) and REDIS_URL at 127.0.0.1:16391 + # (or :16379) — they throw 'Use isolated local test services' otherwise, + # which used to fail this job on every push. These ports are deliberate: + # they match the disposable local services the suites were written against. + services: + postgres: + image: postgres:17 + env: + POSTGRES_USER: crove + POSTGRES_PASSWORD: crove + POSTGRES_DB: crove_test + ports: + - 15491:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 10 + redis: + image: redis:7.2 + ports: + - 16391:6379 + options: >- + --health-cmd "redis-cli ping" + --health-interval 10s + --health-timeout 5s + --health-retries 10 + + env: + DATABASE_URL: postgresql://crove:crove@127.0.0.1:15491/crove_test + DATABASE_DIRECT_URL: postgresql://crove:crove@127.0.0.1:15491/crove_test + REDIS_URL: redis://127.0.0.1:16391 + steps: - name: Checkout Code uses: actions/checkout@v4 @@ -47,8 +81,17 @@ jobs: - name: Test SSO Worker (vitest) run: pnpm run test:sso + # The suites drive the real Prisma client, so the schema must exist in + # the throwaway database first. --skip-generate because the postinstall + # hook already generated the client. No --accept-data-loss: this is an + # empty database and the step must stay non-destructive. + - name: Push Prisma schema to the test database + run: pnpm exec prisma db push --skip-generate --schema libraries/nestjs-libraries/src/database/prisma/schema.prisma + - name: Test bootstrap / OAuth consent (jest) - run: pnpm exec jest --config tests/bootstrap.jest.cjs --ci --passWithNoTests + # --runInBand: the suites share one database and create/delete rows by + # known ids, so they must not run concurrently against each other. + run: pnpm exec jest --config tests/bootstrap.jest.cjs --ci --passWithNoTests --runInBand - name: Build applications run: pnpm run build diff --git a/.github/workflows/mcp-surface-probe.yml b/.github/workflows/mcp-surface-probe.yml new file mode 100644 index 0000000000..b3211bf4b2 --- /dev/null +++ b/.github/workflows/mcp-surface-probe.yml @@ -0,0 +1,56 @@ +--- +name: MCP Surface Probe + +# Runs the read-only MCP surface probe (scripts/probe-mcp.mjs) against +# production and beta. GitHub runners have public internet access, so this is +# the only place the deployed endpoints can be checked from CI — it turns the +# 2026-09-08 incident (a database outage swallowed into an empty 401, which +# DOSClaw misread as an OAuth failure) into an alarm instead of a silent outage. +# +# Every rejection asserted here must be well-formed and intentional. A generic +# 401, a 5xx or a timeout fails the run, which is the point. +# +# To also assert the authenticated agent path (initialize with a pos_ token, +# tools/list, public API is-connected), set the MCP_PROBE_TOKEN repository +# secret. Without it the probe still verifies every endpoint fails closed. +# +# All requests are read-only, so scheduling it is safe. It is deliberately NOT +# a push/PR gate: a transient production blip should not fail unrelated work. + +on: + workflow_dispatch: + schedule: + # Every 30 minutes, deliberately off the :00/:30 grid. + - cron: '13,43 * * * *' + +permissions: + contents: read + +jobs: + probe: + name: "Probe ${{ matrix.target }}" + runs-on: ubuntu-latest + permissions: + contents: read + strategy: + # One environment being down must not hide the state of the other. + fail-fast: false + matrix: + include: + - target: production + url: https://post.crove.com/api + - target: beta + url: https://beta-post.crove.com/api + steps: + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Probe MCP surface + env: + MCP_PROBE_TOKEN: ${{ secrets.MCP_PROBE_TOKEN }} + run: node scripts/probe-mcp.mjs ${{ matrix.url }} ${{ matrix.target }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e189ebd66..e39c637257 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Fixed +- **Customer-Facing UI, SDK and MCP Announcing Upstream Infrastructure (Upstream Endpoint Leaks)**: + - Root cause: the Branding Guard scanned only AGPL attribution (repo, image, author), so it reported "0 strict branding leaks" while runtime endpoints still pointed at upstream infrastructure. + - The Developers page rendered `export POSTIZ_API_KEY=` with no `POSTIZ_API_URL`, so the upstream CLI sent Crove credentials to `api.postiz.com` and posted to the wrong account; `postiz auth:login` ran a device flow against `cli-auth.postiz.com`, signing customers into the upstream cloud. + - The rebranded `@crove/node` SDK still defaulted `_path` to `api.postiz.com` and its README documented the old `@postiz/node` package name; the MCP server announced itself as "Postiz MCP" on every OAuth path, including `/mcp-oauth-dynamic` which DOSClaw uses. + - Remediated by adding a RUNTIME pattern family to the guard (upstream endpoints, connector keys, install commands), bringing `apps/sdk/` into STRICT scope, reporting per-match line numbers, and recording deliberate upstream references via visible `branding-guard-allow:` comments. "Add to Claude" and the Affiliate menu now fail closed until `BRAND_CLAUDE_DIRECTORY_URL` / `BRAND_AFFILIATE_URL` are set; Docs links resolve through `BRAND_DOCS_URL`. +- **Mobile OAuth Callback Handing Authorization Codes to an Upstream Scheme**: + - Root cause: `GET /oauth-mobile-callback` redirected a live OAuth authorization code to `MOBILE_APP_SCHEME || 'postiz://auth/callback'`. Deployments that never set the variable (Crove leaves it empty) handed this instance's codes to whichever app on the device registered the upstream `postiz://` scheme. + - Now returns 501 when the variable is unset. The frontend derives its deep link from the same variable (`getMobileAppScheme`) instead of hardcoding `postiz://integrations`, and omits `redirectUrl` entirely when no scheme is configured. Documented in `.env.example` that the value must stay empty unless a mobile app ships. +- **Build CI Gate Red on Every Push Since 2026-09-10 (PRs #33-#36)**: + - Root cause: the bootstrap / OAuth-consent suites throw `'Use isolated local test services'` unless `DATABASE_URL` points at `127.0.0.1:15491` and `REDIS_URL` at `127.0.0.1:16391`; CI provided neither, so `beforeAll` threw, `prisma` stayed undefined, and `afterAll` failed with `Cannot read properties of undefined (reading 'oAuthAuthorization')` — surfacing as 42 failed / 11 passed of 53. + - Added Postgres 17 + Redis 7.2 service containers on exactly those ports, a `prisma db push` step, and `--runInBand`. Verified in CI: `Test Suites: 5 passed, 5 total`, `Tests: 53 passed, 53 total`. The ESLint workflow remains red (eslint 8.57 cannot consume the flat `eslint.config.mjs`); fixing it needs eslint 9 plus `@typescript-eslint` 8. - **Beta Runtime: DOS-Me First-Party Bootstrap 401 & Facebook Connect `client_id=undefined`**: - Root cause: the Beta runtime (`crove-post-beta` on `crove-server`) had no `CROVE_POST_CLIENT_ID`/`CROVE_POST_CLIENT_SECRET` configured, so `BootstrapGuard` failed closed with 401 before HMAC verification; `FACEBOOK_APP_ID`/`FACEBOOK_APP_SECRET` were also missing, producing `client_id=undefined` in the Connect Facebook URL. - Remediated by configuring `CROVE_POST_CLIENT_ID=pca_dosclaw_beta_7ef5e5f1`, `CROVE_POST_CLIENT_SECRET` (existing OAuth client secret, no rotation) and copying `FACEBOOK_APP_ID`/`FACEBOOK_APP_SECRET` from the prod env file into the Beta env file on the VM (gitignored by design via `scripts/*.env`). diff --git a/apps/backend/src/api/routes/auth.controller.ts b/apps/backend/src/api/routes/auth.controller.ts index 2ed4482447..809e441b62 100644 --- a/apps/backend/src/api/routes/auth.controller.ts +++ b/apps/backend/src/api/routes/auth.controller.ts @@ -206,7 +206,17 @@ export class AuthController { @Query('state') state: string, @Res({ passthrough: false }) response: Response ) { - const scheme = process.env.MOBILE_APP_SCHEME || 'postiz://auth/callback'; + const scheme = (process.env.MOBILE_APP_SCHEME || '').trim(); + // Fail closed. This handler holds a live OAuth authorization code, and a + // custom-scheme redirect delivers it to whichever app on the device + // registered that scheme. Falling back to an upstream default would hand + // this deployment's codes to someone else's app, so an unset + // MOBILE_APP_SCHEME must refuse rather than redirect. + if (!scheme) { + return response + .status(501) + .send('Mobile OAuth callback is not configured for this deployment'); + } const params = new URLSearchParams(); if (code) params.set('code', code); if (state) params.set('state', state); diff --git a/apps/backend/src/main.ts b/apps/backend/src/main.ts index 3c19b457d7..a7579e9bb7 100644 --- a/apps/backend/src/main.ts +++ b/apps/backend/src/main.ts @@ -1,5 +1,7 @@ import { initializeSentry } from '@gitroom/nestjs-libraries/sentry/initialize.sentry'; initializeSentry('backend', true); +import dns from 'dns'; +dns.setDefaultResultOrder('ipv4first'); import compression from 'compression'; import { loadSwagger } from '@gitroom/helpers/swagger/load.swagger'; diff --git a/apps/frontend/src/app/(app)/layout.tsx b/apps/frontend/src/app/(app)/layout.tsx index b2ca93b5e9..9495eca762 100644 --- a/apps/frontend/src/app/(app)/layout.tsx +++ b/apps/frontend/src/app/(app)/layout.tsx @@ -11,6 +11,7 @@ import PlausibleProvider from 'next-plausible'; import clsx from 'clsx'; import { VariableContextComponent } from '@gitroom/react/helpers/variable.context'; import { getBrandConfig } from '@gitroom/helpers/utils/brand.config'; +import { getMobileAppScheme } from '@gitroom/helpers/utils/mobile.app.scheme'; import { Fragment } from 'react'; import { PHProvider } from '@gitroom/react/helpers/posthog'; import UtmSaver from '@gitroom/helpers/utils/utm.saver'; @@ -77,6 +78,7 @@ export default async function AppLayout({ children }: { children: ReactNode }) { cloudflareUrl={process.env.CLOUDFLARE_BUCKET_URL || ''} mainUrl={process.env.MAIN_URL || ''} mcpUrl={process.env.MCP_URL} + mobileAppScheme={getMobileAppScheme(process.env)} dub={!!process.env.STRIPE_PUBLISHABLE_KEY} facebookPixel={process.env.NEXT_PUBLIC_FACEBOOK_PIXEL!} telegramBotName={process.env.TELEGRAM_BOT_NAME!} diff --git a/apps/frontend/src/app/(extension)/layout.tsx b/apps/frontend/src/app/(extension)/layout.tsx index 5aa92fbebd..c4d070af2f 100644 --- a/apps/frontend/src/app/(extension)/layout.tsx +++ b/apps/frontend/src/app/(extension)/layout.tsx @@ -8,6 +8,7 @@ import { Plus_Jakarta_Sans } from 'next/font/google'; import clsx from 'clsx'; import { VariableContextComponent } from '@gitroom/react/helpers/variable.context'; import { getBrandConfig } from '@gitroom/helpers/utils/brand.config'; +import { getMobileAppScheme } from '@gitroom/helpers/utils/mobile.app.scheme'; import UtmSaver from '@gitroom/helpers/utils/utm.saver'; const jakartaSans = Plus_Jakarta_Sans({ @@ -47,6 +48,7 @@ export default async function AppLayout({ children }: { children: ReactNode }) { cloudflareUrl={process.env.CLOUDFLARE_BUCKET_URL || ''} mainUrl={process.env.MAIN_URL || ''} mcpUrl={process.env.MCP_URL} + mobileAppScheme={getMobileAppScheme(process.env)} dub={false} facebookPixel={process.env.NEXT_PUBLIC_FACEBOOK_PIXEL!} telegramBotName={process.env.TELEGRAM_BOT_NAME!} diff --git a/apps/frontend/src/app/(provider)/layout.tsx b/apps/frontend/src/app/(provider)/layout.tsx index ca63a98e5d..fc1737d723 100644 --- a/apps/frontend/src/app/(provider)/layout.tsx +++ b/apps/frontend/src/app/(provider)/layout.tsx @@ -10,6 +10,7 @@ import { Plus_Jakarta_Sans } from 'next/font/google'; import clsx from 'clsx'; import { VariableContextComponent } from '@gitroom/react/helpers/variable.context'; import { getBrandConfig } from '@gitroom/helpers/utils/brand.config'; +import { getMobileAppScheme } from '@gitroom/helpers/utils/mobile.app.scheme'; import UtmSaver from '@gitroom/helpers/utils/utm.saver'; const jakartaSans = Plus_Jakarta_Sans({ @@ -49,6 +50,7 @@ export default async function AppLayout({ children }: { children: ReactNode }) { cloudflareUrl={process.env.CLOUDFLARE_BUCKET_URL || ''} mainUrl={process.env.MAIN_URL || ''} mcpUrl={process.env.MCP_URL} + mobileAppScheme={getMobileAppScheme(process.env)} dub={false} facebookPixel={process.env.NEXT_PUBLIC_FACEBOOK_PIXEL!} telegramBotName={process.env.TELEGRAM_BOT_NAME!} diff --git a/apps/frontend/src/components/launches/add.provider.component.tsx b/apps/frontend/src/components/launches/add.provider.component.tsx index 9ff651acb7..b47070b11e 100644 --- a/apps/frontend/src/components/launches/add.provider.component.tsx +++ b/apps/frontend/src/components/launches/add.provider.component.tsx @@ -406,7 +406,7 @@ export const AddProviderComponent: FC<{ isMobile?: boolean; }> = (props) => { const { update, social, article, onboarding, isMobile } = props; - const { isGeneral, extensionId } = useVariables(); + const { isGeneral, extensionId, mobileAppScheme } = useVariables(); const toaster = useToaster(); const router = useRouter(); const fetch = useFetch(); @@ -466,14 +466,19 @@ export const AddProviderComponent: FC<{ }; const gotoIntegration = async (externalUrl?: string) => { // Mobile WebView: reuse the existing `externalUrl` param to - // carry the `postiz://` deep link so the backend redirects - // back to the iOS/Android app after OAuth completes, instead - // of the default web redirect. + // carry the app deep link so the backend redirects back to the + // iOS/Android app after OAuth completes, instead of the default + // web redirect. The scheme comes from MOBILE_APP_SCHEME — the same + // variable the backend's /oauth-mobile-callback uses — so the two + // halves cannot drift. Empty scheme means no mobile app is + // configured, so no redirectUrl is sent at all. const params = [ `externalUrl=${encodeURIComponent(externalUrl)}`, onboardingParam, - isMobile - ? `redirectUrl=${encodeURIComponent('postiz://integrations')}` + isMobile && mobileAppScheme + ? `redirectUrl=${encodeURIComponent( + `${mobileAppScheme}integrations` + )}` : '', ] .filter(Boolean) @@ -511,7 +516,7 @@ export const AddProviderComponent: FC<{ // `window.open`/`location.href` aren't reliable here because // RN WebView doesn't always route them through the native // navigation intercept. The backend redirects back to the - // app via `postiz://` once OAuth completes. + // app via the MOBILE_APP_SCHEME deep link once OAuth completes. const rn = (window as any).ReactNativeWebView; if (rn && typeof rn.postMessage === 'function') { rn.postMessage(JSON.stringify({ type: 'open-external', url })); diff --git a/docs/README.md b/docs/README.md index 861852acef..1a2175c12c 100644 --- a/docs/README.md +++ b/docs/README.md @@ -15,6 +15,7 @@ Welcome to the official technical documentation for **Crove Post** (the open-sou | **[4. First-Party Provisioning API](./first-party-provisioning.md)** | Automated account & workspace provisioning API specification (`/v1/provision`). | | **[5. Beta & Production Environments](./beta-environment.md)** | Independent configurations for Production (`crove.com`, `post.crove.com`) and Beta (`beta.crove.com`, `beta-post.crove.com`). | | **[6. CI/CD & Deployment Pipeline](./cicd.md)** | GitHub Actions multi-arch container build, branching strategy (dev/main), and automated server deployment. | +| **[7. MCP Surface Probe](./mcp-surface-probe.md)** | Read-only 30-minute GitHub Actions probe asserting the deployed MCP surface serves RFC 9728 discovery and fails closed (never a swallowed generic 401) on Production and Beta. | --- diff --git a/docs/audit-2026-09-08.html b/docs/audit-2026-09-08.html index 180ea0ebb5..6a9fd99f93 100644 --- a/docs/audit-2026-09-08.html +++ b/docs/audit-2026-09-08.html @@ -143,7 +143,7 @@

Báo cáo Audit — Crove Post