Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
45 changes: 44 additions & 1 deletion .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:[email protected]:15491/crove_test
DATABASE_DIRECT_URL: postgresql://crove:[email protected]:15491/crove_test
REDIS_URL: redis://127.0.0.1:16391

steps:
- name: Checkout Code
uses: actions/checkout@v4
Expand All @@ -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
56 changes: 56 additions & 0 deletions .github/workflows/mcp-surface-probe.yml
Original file line number Diff line number Diff line change
@@ -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 }}
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<real 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`).
Expand Down
12 changes: 11 additions & 1 deletion apps/backend/src/api/routes/auth.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
2 changes: 2 additions & 0 deletions apps/backend/src/main.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
2 changes: 2 additions & 0 deletions apps/frontend/src/app/(app)/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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!}
Expand Down
2 changes: 2 additions & 0 deletions apps/frontend/src/app/(extension)/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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!}
Expand Down
2 changes: 2 additions & 0 deletions apps/frontend/src/app/(provider)/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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!}
Expand Down
19 changes: 12 additions & 7 deletions apps/frontend/src/components/launches/add.provider.component.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 }));
Expand Down
1 change: 1 addition & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |

---

Expand Down
Loading
Loading