AFFAANhandClaude Opus 5 a5086a5b02 fix(mcp): explain the LinkedIn callback URL instead of "Missing code or state"
Watched a real operator copy the callback URL — as the MCP page tells
them to — and then open it in a browser tab, landing here with no query
string and getting "Missing code or state." Accurate, and completely
useless to the person reading it.

Only LinkedIn should ever call this URL, carrying ?code=...&state=...
after a real login, so a bare visit is a strong signal that somebody
misread the copy step. The response is the last place left to tell them,
so it now says what the URL is for and where it actually belongs
(Developer Portal -> app -> Auth -> Authorized redirect URLs), and that
Authenticate LinkedIn is the button that starts a real login.

LinkedIn's own errors are untouched: a genuine refusal still shows
LinkedIn's wording, not this guidance, since that is a different failure
and the operator needs LinkedIn's own words. Pinned by tests, including
one asserting the old bare symptom message cannot come back.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 11:50:00 +05:30
2026-08-01 10:28:22 +05:30
2026-08-01 10:28:22 +05:30
2026-08-01 10:28:22 +05:30
2026-08-01 10:28:22 +05:30
2026-08-01 10:28:22 +05:30
2026-08-01 10:28:22 +05:30
2026-08-01 10:28:22 +05:30
2026-08-01 10:28:22 +05:30
2026-08-01 10:28:22 +05:30

MaskanX Backend

Independent Python and FastAPI backend for MaskanX.

Service Contract

  • Default API URL: http://127.0.0.1:8088
  • PostgreSQL database: campaign
  • Frontend origin: configured with MASKANX_CORS_ORIGINS
  • Maskan CRM: connected through its versioned REST API and optional MCP tools

This repository never connects directly to the Maskan CRM PostgreSQL database.

Local Setup

  1. Create the database:

    CREATE DATABASE campaign;
    
  2. Install dependencies and configure the environment:

    python -m venv .venv
    .\.venv\Scripts\python.exe -m pip install -e ".[dev]"
    npm install
    Copy-Item .env.local.example .env.local
    
  3. Migrate, seed, and start:

    npm run local:migrate
    npm run local:seed
    npm run local
    

Open API documentation at http://127.0.0.1:8088/docs when documentation is enabled.

Running the tests

npm run test

npm run test loads .env.testing, which points at a separate database so a test run cannot touch development data. Create it once:

CREATE DATABASE campaign_test;
npm run test:migrate

Without it, the repository integration tests skip with "PostgreSQL is not reachable" and the suite still passes — so check the skip count, not just the exit code.

Upgrading from JSON storage

Chats and cron jobs were originally stored as JSON files in the working directory (~/.adclaw/chats.json and ~/.adclaw/jobs.json). PostgreSQL is now the default backend, so those records are no longer read and existing chats, sessions, and scheduled jobs appear to be missing even though nothing was deleted.

Copy them into PostgreSQL once:

npm run local:import-json -- --dry-run   # report what would be imported
npm run local:import-json                # perform the import

The import upserts by id and never deletes, so it is safe to re-run. The JSON files are left untouched as a backup.

Campaigns

Campaign records live in PostgreSQL. Apply the schema before first use:

npm run local:migrate

Set META_ADS_ACCESS_TOKEN and META_PAGE_ID in Settings > Environments, and enable the meta_ads MCP client if you also want campaign tools in chat. META_PAGE_ID is the Facebook Page ads are published from; sync refuses to start without it, rather than failing part-way through building the object chain. A campaign can override it with advanced.page_id.

The campaign lifecycle

draft -> pending_approval -> approved -> synced -> live
                                            |       |
                                            +-> paused <-+
                                                  |
                                                stopped

approved is where a human signs off. synced means the objects exist in Meta but are paused. Only live spends money.

Ad previews create nothing

Previews are rendered from a creative spec via generatepreviews, so the wizard shows exactly how an ad will look across desktop feed, mobile feed, Instagram feed, Instagram story, Facebook story and right column before any Meta object exists.

Sync creates paused objects only

POST /campaigns/{id}/sync builds the Meta chain — campaign, ad set, creative, ad — and every object is created PAUSED. Nothing sync does can start spending. The guard is enforced in three places: the typed create_* methods, MetaClient._post itself (which refuses a non-PAUSED status on any create path), and the launch endpoint being the only caller allowed to send ACTIVE.

Each id is persisted the moment it is obtained, so a sync that fails part-way is resumable: re-running it reuses the ids already stored and creates only what is missing. That is what stops a retry from leaving a second set of objects in a real ad account.

Launching requires an operator and a payment method

POST /campaigns/{id}/launch sets the whole object chain active — ad, then ad set, then campaign. Meta only delivers when all three are active, so activating just the campaign would launch nothing. The campaign goes last on purpose: nothing under a paused campaign delivers, so a failure part-way leaves it unable to spend. That is also why pause and stop only have to flip the campaign.

It is the only action that starts spend, and it is refused unless:

  • the campaign is synced or paused,
  • approved_by names a resolved operator — an approval nobody can be named for does not authorise spend, and
  • the ad account has a funding_source.

Set MASKANX_OPERATOR_TOKENS to a comma-separated list of name:token pairs. Approve, sync, launch, pause, stop and adopt read the token from the X-MaskanX-Operator header and record the resolved name as the actor; any actor in the request body is ignored. With no tokens configured the operator resolves to unauthenticated, which is enough to draft and approve but not to launch. The Campaigns page has a field for storing the token in the browser.

Importing campaigns made in Ads Manager

GET /campaigns/discover?ad_account_id=act_... lists Meta campaigns MaskanX does not know about; POST /campaigns/adopt imports one. Adopting twice is safe — the second call returns the existing record.

Imported campaigns are marked origin=imported and treated as Ads-Manager-owned: they are never synced, launching one touches only the campaign (its ad sets and ads keep the statuses set in Ads Manager), and they cannot be deleted from MaskanX.

Deleting

DELETE /campaigns/{id} deletes the campaign on Meta first and only then removes the local row. If the Meta delete fails the local row is kept and the call answers 502: a campaign forgotten here but still live on Meta would keep spending with nothing left to show it exists. Deleting a campaign cascades to its ad sets and ads; the creative is left behind, being an account-level asset other ads may reference.

Deleting an imported campaign is refused with 409. Deleting it on Meta would destroy work MaskanX did not author, and deleting only the local row would achieve nothing — the reconciler would import it again on the next cycle. Delete it in Ads Manager instead; the reconciler then archives the local record, keeping its spend history reportable.

Drift reconciliation

A background loop reconciles each ad account with Meta: campaigns created in Ads Manager are imported, statuses are copied back so a campaign paused directly in Meta stops showing as live here, and campaigns that have disappeared are archived (never hard-deleted, so their spend history stays reportable).

Meta is authoritative for delivery status; MaskanX is authoritative for its own metadata — guardrails, approvals and audit history are never overwritten. Campaigns that have not reached Meta yet (draft, pending_approval, approved) are left alone entirely.

The interval defaults to 120 seconds and is set with MASKANX_CAMPAIGN_RECONCILE_SECONDS; 0 disables the loop.

Guardrails

Guardrails are the only thing in MaskanX that halts spending without a human. A background sweep reads each live campaign's spend from Meta and applies the campaign's own rules:

Rule Window Action
max_campaign_spend lifetime stop
auto_pause_if_spend_reaches today pause
max_cost_per_lead today pause
max_cost_per_click today pause

The most severe breach wins, so a campaign over both its lifetime cap and its cost per lead is stopped rather than merely paused. stop_loss_enabled gates only max_campaign_spend, the one rule that ends a campaign permanently; turning it off leaves the pause rules working.

Every amount is in minor currency units (paise, cents), matching the budget fields. Cost rules are skipped until the campaign has its first lead or click — no leads yet is not an infinite cost per lead, and pausing for that would kill every campaign in its first hour.

The campaign is paused on Meta first, then recorded locally: a campaign recorded as paused but still delivering is the outcome the whole mechanism exists to prevent. Each action writes an event naming the rule and the observed value, so a campaign that stopped overnight can be explained the next morning, and the Campaigns page tags it.

Interval: MASKANX_GUARDRAIL_INTERVAL_SECONDS, default 900 (15 minutes). 0 disables enforcement, which is logged as a warning at startup — a safety system that is not running should be loud.

This is a deterministic loop, not a maskanx_cron_jobs entry. That scheduler runs prompts through an agent, and asking a language model whether a budget has been exceeded would make an arithmetic guarantee probabilistic.

require_approval_for_budget_increase is enforced at the API layer instead: raising a budget on a campaign that is awaiting approval returns 409, because it would change the figure the approver is reviewing. Reject it back to draft, edit, and resubmit.

Analytics

Insights are stored in maskanx_campaign_insights and read from there, so the dashboard stays fast and keeps working while Meta is rate-limiting. Only POST /campaigns/{id}/insights/refresh goes out to Meta.

Route Returns
GET /campaigns/analytics/dashboard Yesterday and last-7-days spend, leads and cost per lead, plus the most and least efficient campaign
GET /campaigns/{id}/insights A daily series and the total for one campaign
GET /campaigns/{id}/insights?breakdown=age The same metrics split by one breakdown
POST /campaigns/{id}/insights/refresh Re-fetches from Meta now

Three things about how this works are worth knowing before changing it:

Insights are re-fetched, not appended. Meta revises a day's figures for about 28 days as conversions are attributed late, so each sync re-reads a trailing window and upserts on (level, object_id, date, breakdown_key, breakdown_value). Appending would double spend the first time a day was refreshed. Window: MASKANX_INSIGHTS_WINDOW_DAYS, default 28 — shortening it freezes late conversions out permanently.

Breakdowns multiply rows, not columns. The same spend appears in every breakdown's rows, so they are tagged with a breakdown_key and every aggregate filters to breakdown_key = ''. A query that summed across breakdowns would count the same spend once per age bracket.

Both dashboard windows end yesterday. Today's figures are partial; including them would make every "vs. last week" comparison read as a collapse each morning.

Costs are always computed from summed totals, never averaged from per-day costs — the average of daily costs per lead is not the cost per lead over the period. Cost fields are null, not 0, when there is nothing to divide by.

Sync interval: MASKANX_INSIGHTS_SYNC_SECONDS, default 3600. 0 disables the loop; the dashboard then only updates when a campaign is refreshed by hand.

Maskan CRM

Leads and campaign figures are pushed to Maskan CRM server to server, over its integration API — not through MCP. MCP is for an agent deciding to do something; leads have to arrive whether or not anyone is talking to the agent.

  • Leads: Meta lead-ad submissions, attributed to the campaign, ad set and ad that produced them. Meta's own lead id is the external id, so re-sending is harmless. A trailing window is re-sent on every run rather than tracking a high-water mark, which makes a half-failed run self-healing.
  • Campaigns: spend, clicks, leads and cost per lead, upserted so the CRM shows current figures rather than the first ones it saw.

Configure MASKAN_CRM_API_URL and MASKAN_CRM_INTEGRATION_KEY in Settings > Environments. Interval: MASKANX_CRM_SYNC_SECONDS, default 300. Lead re-read window: MASKANX_CRM_LEAD_WINDOW_DAYS, default 7. 0 on the interval disables the loop.

Custom fields, created from chat

MCP's job is the other half: changing the CRM's shape on request. Two tools on the maskan_crm server let an agent add a field mid-conversation, the way HubSpot's custom properties work:

  • list_maskan_crm_properties — what fields already exist, with their types and allowed values
  • create_maskan_crm_property — define a new one

The definition is stored in the CRM; values live in each record's existing JSON attributes, so adding a field is an INSERT rather than a migration and the next record can use it immediately. The field appears in the CRM's Custom fields screen straight away, marked as MaskanX-created so a person can tell which fields came out of a conversation.

The CRM validates every definition and every value written against one, and an agent gets no shortcut around those rules — if anything it needs them more, since it will happily invent a field name from half a sentence. A value for an undefined property is rejected rather than stored: a typo left in the database looks exactly like data.

name and data_type cannot be changed after creation. Renaming would orphan every value already stored under the old key, and retyping would leave values that no longer satisfy the type.

Live smoke test

The unit suite runs entirely against a fake transport: it proves the code sends what we think it sends, not that Meta accepts it. One opt-in test closes that gap by syncing a real campaign, reading back from Graph that all three objects are PAUSED, then deleting them:

$env:MASKANX_LIVE_TESTS = "1"
$env:MASKANX_LIVE_AD_ACCOUNT_ID = "act_<your account id>"
npm run test -- tests/test_campaign_sync_live.py

It creates and deletes real objects. They are paused for their whole life, so it cannot spend money, and cleanup runs in a finally block.

Budget minimums

Each ad account publishes a min_daily_budget in minor units. The API rejects a lower daily budget with 422 and a readable message rather than letting Meta reject it opaquely later. If the account cannot be read (no token, rate limit), the check is skipped and a warning is logged: the budget guardrail is not enforced in that case.

Delivery requires a payment method

Meta will not deliver ads until a funding source is attached to the ad account. This cannot be done from MaskanX — the Marketing API does not allow creating funding instruments, so it must be done in Meta Business Manager. The Campaigns page shows a warning when no payment method is present.

Clearing and restoring credentials

Stop MaskanX before clearing credentials. A running app holds its configuration in memory and can rewrite config.json and providers.json after the clear, silently restoring the values that were just removed. The clear script now verifies the result and exits non-zero if anything secret-looking survives.

Credentials live in three places, and all of them are cleared:

  • ~/.adclaw.secret/providers.json - LLM provider keys
  • ~/.adclaw.secret/envs.json - integration keys
  • ~/.adclaw/companies/<id>/ - a per-company copy of both, plus LinkedIn OAuth

Because each company keeps its own copy, an intact company store can be used to recover the active one:

npm run secrets:restore -- --from default --dry-run
npm run secrets:restore -- --from default

Credential files are copied wholesale; config.json is patched surgically, so only missing MCP env values and disabled clients are restored and company-specific settings are preserved. A backup is written to ~/.adclaw.secret/restore-backups/ first.

Commands

Command Purpose
npm start Start using .env or injected environment variables
npm run build Build a Python wheel in dist/
npm run local Local API with reload
npm run local:migrate Apply local PostgreSQL migrations
npm run local:migrate:undo Roll back the newest migration
npm run local:migrate:undo:all Roll back every migration
npm run local:seed Add starter data
npm run local:seed:undo Remove starter data
npm run local:import-json Import legacy JSON chats/cron jobs into PostgreSQL
npm run secrets:dry-run Report which credentials a clear would remove
npm run secrets:clear Clear runtime credentials (creates a backup)
npm run secrets:clear:all Also blank secret lines in .env files (creates a backup)
npm run secrets:delete:all Clear everything including .env files (creates a backup)
npm run secrets:delete:all:no-backup Same, with no backup - irreversible
npm run secrets:restore Restore credentials from an intact company store
npm run local:reset Rebuild and seed the local schema
npm run dev Development API with reload
npm run dev:migrate Apply development migrations
npm run dev:reset Reset the development schema
npm test Run Pytest using .env.testing
npm run test:migrate Apply test database migrations
npm run test:reset Reset the test schema
npm run prod Start the production API
npm run prod:migrate Apply production migrations
npm run prod:seed Apply production starter data

The command names match the platform deployment contract. They invoke the native MaskanX Python migration runner, not Sequelize.

Docker

Copy-Item .env.local.example .env
docker compose up -d --build

For a managed PostgreSQL deployment, provide DATABASE_URL through the hosting secret manager and run npm run prod:migrate as a release step.

S
Description
No description provided
Readme
7.6 MiB
Languages
Python 97.7%
HTML 1.1%
JavaScript 0.5%
Shell 0.4%
Dockerfile 0.2%
Other 0.1%