Customer Account Clone (prod → stage)
Clone one customer account — the account owner, their whole team, and all of their linked data — from a read-only production MongoDB into an existing organization on the stage MongoDB, under a new email and new UIDs.
Typical use: take the customer [email protected] on prod and reproduce the whole account as [email protected] on stage so it can be exercised safely, without ever writing to prod.
Prod is never written. The source (prod) connection is handled through a read-only facade that exposes only read methods, and the run refuses to start if the source and target resolve to the same database or if the target looks like production. The endpoint is also disabled entirely when the server runs with
NODE_ENV=production. Everything is written to the stage target only. See Safety model.
At a glance
| Endpoint | POST /internal/scripts/account-clone/clone |
| Status probe | POST /internal/scripts/account-clone/status |
| Auth | header x-api-secret: <SUPERADMIN_API_SECRET> |
| Availability | stage only — both routes return 403 when NODE_ENV=production |
| Default mode | dry run (reports only, writes nothing) |
| Concurrency | one clone at a time (second concurrent request → 409) |
| Source | read-only prod, passed in the request body (sourceUri / sourceDbName) |
| Target | the server's own stage DB (MONGO_URI / DB_NAME) — never in the body |
Source files: backend/client_backend/src/routes/accountClone.routes.ts and backend/client_backend/src/scripts/account-clone/.
Prerequisites
1. Server environment variables
The source is no longer configured on the server — it is passed in the request body (see Request). Only the server's own target and auth vars are needed; they are already set.
| Variable | Required | Meaning |
|---|---|---|
MONGO_URI | Yes (already set) | The target (stage) cluster — the server's own connection. |
DB_NAME | Yes (already set) | The target (stage) database. |
SUPERADMIN_API_SECRET | Yes (already set) | Shared secret for the x-api-secret auth guard. |
NODE_ENV | Yes (already set) | When production, the endpoint is disabled (403). |
CLONE_PROD_FINGERPRINTS | No | Comma-separated extra substrings that mark a target as production. Extends the hardcoded default oktivo-production (the default is always kept; the env can never weaken it to empty). |
There are no
CLONE_SOURCE_*env vars anymore. The source connection string and database name are supplied per request. Use read-only prod credentials (a user withreadAnyDatabase) forsourceUri— read-only credentials are the primary safety enforcement; the read-only facade, the same-DB guards, the target prod-fingerprint guard, and the production403gate are the in-code layers on top.
2. An existing stage organization
The clone lands inside an organization that already exists on stage — it never creates one. You must pass its orgUID as targetOrgUID. The run refuses (422) if that org does not exist on the target.
Request
POST /internal/scripts/account-clone/clone
Body fields
| Field | Type | Default | Notes |
|---|---|---|---|
sourceUri | string | — (required) | The source (prod) MongoDB connection string. Must start with mongodb:// or mongodb+srv://. Use read-only prod credentials (readAnyDatabase). Carries a password, so it is never echoed back — only host + db appear anywhere. |
sourceDbName | string | — (required) | The source (prod) database name to read from. |
sourceEmail | string | — (required) | The prod account owner's email. Looked up case-insensitively; the owner must have role: "customer". |
targetEmail | string | — (required) | The new owner email on stage. Must not already belong to a non-clone stage user (see overwrite). |
targetOrgUID | string | — (required) | An existing stage org to clone into. |
dryRun | boolean | true | When true (or omitted) the run writes nothing and returns the full plan. Send false to apply. |
overwrite | boolean | false | When true, deletes a prior clone on stage (identified by targetEmail) before cloning. Never runs on a dry run. |
teamEmailStrategy | "alias" | "regenerate" | "alias" | How team-member emails avoid colliding with the unique stage User.email. alias adds a plus-suffix; regenerate mints fresh addresses. |
HTTP status codes
| Code | Meaning |
|---|---|
200 | Clone (or dry run) completed successfully. |
400 | Configuration/validation error — e.g. sourceUri/sourceDbName missing or malformed, the same-DB guard refused, or the target matched a production fingerprint. |
401 | Missing/invalid x-api-secret. |
403 | The server runs with NODE_ENV=production — the endpoint is disabled. |
404 | Source owner not found by sourceEmail. |
409 | Another clone is already running. |
422 | Correctness failure — e.g. targetOrgUID does not exist, targetEmail already taken (without overwrite), ambiguous owner match, or a partial-clone condition. |
500 | Unexpected error. |
curl — local testing
Assumes the backend runs on http://localhost:3000 (default PORT). Replace YOUR_SECRET with the server's SUPERADMIN_API_SECRET, and the emails / org with your real values.
On Windows, call curl.exe (not the curl PowerShell alias, which is Invoke-WebRequest), or use the Invoke-RestMethod variants shown below.
Pass the source (prod) connection with read-only credentials in sourceUri / sourceDbName. The target is the server's own stage DB and is never in the body.
0. Check availability (stage only) + target info
curl.exe -sS -X POST http://localhost:3000/internal/scripts/account-clone/status \
-H "x-api-secret: YOUR_SECRET" | jqReturns available (false on production), the target DB name, target.prodFingerprinted (true means the server's own DB looks like prod and a clone would be refused), and confirms the source is supplied per request. It never echoes a connection URI.
1. Dry run (safe — writes nothing). Always do this first.
curl.exe -sS -X POST http://localhost:3000/internal/scripts/account-clone/clone \
-H "x-api-secret: YOUR_SECRET" \
-H "Content-Type: application/json" \
-d '{
"sourceUri": "mongodb+srv://READONLY_USER:READONLY_PASS@prod-host/?retryWrites=true",
"sourceDbName": "oktivo-prod",
"sourceEmail": "[email protected]",
"targetEmail": "[email protected]",
"targetOrgUID": "org_XXXXXXXXXXXX"
}' | jqdryRun is true by default, so this reports exactly what would be created per collection, the full identity remap table, and any dangling-reference reassignments — without touching the stage database.
2. Apply the clone (writes to stage)
curl.exe -sS -X POST http://localhost:3000/internal/scripts/account-clone/clone \
-H "x-api-secret: YOUR_SECRET" \
-H "Content-Type: application/json" \
-d '{
"sourceUri": "mongodb+srv://READONLY_USER:READONLY_PASS@prod-host/?retryWrites=true",
"sourceDbName": "oktivo-prod",
"sourceEmail": "[email protected]",
"targetEmail": "[email protected]",
"targetOrgUID": "org_XXXXXXXXXXXX",
"dryRun": false
}' | jq3. Re-clone over a previous stage clone
curl.exe -sS -X POST http://localhost:3000/internal/scripts/account-clone/clone \
-H "x-api-secret: YOUR_SECRET" \
-H "Content-Type: application/json" \
-d '{
"sourceUri": "mongodb+srv://READONLY_USER:READONLY_PASS@prod-host/?retryWrites=true",
"sourceDbName": "oktivo-prod",
"sourceEmail": "[email protected]",
"targetEmail": "[email protected]",
"targetOrgUID": "org_XXXXXXXXXXXX",
"dryRun": false,
"overwrite": true
}' | jqPowerShell (Invoke-RestMethod)
# Dry run first (dryRun defaults true):
$body = @{
sourceUri = "mongodb+srv://READONLY_USER:READONLY_PASS@prod-host/?retryWrites=true"
sourceDbName = "oktivo-prod"
sourceEmail = "[email protected]"
targetEmail = "[email protected]"
targetOrgUID = "org_XXXXXXXXXXXX"
} | ConvertTo-Json
Invoke-RestMethod -Method Post `
-Uri "http://localhost:3000/internal/scripts/account-clone/clone" `
-Headers @{ "x-api-secret" = "YOUR_SECRET" } `
-ContentType "application/json" -Body $body
# Then apply (dryRun:false):
$body = @{
sourceUri = "mongodb+srv://READONLY_USER:READONLY_PASS@prod-host/?retryWrites=true"
sourceDbName = "oktivo-prod"
sourceEmail = "[email protected]"
targetEmail = "[email protected]"
targetOrgUID = "org_XXXXXXXXXXXX"
dryRun = $false
} | ConvertTo-Json
Invoke-RestMethod -Method Post `
-Uri "http://localhost:3000/internal/scripts/account-clone/clone" `
-Headers @{ "x-api-secret" = "YOUR_SECRET" } `
-ContentType "application/json" -Body $bodyResponse shape
Both dry-run and apply return the same structure. Representative fields:
{
"success": true,
"correctnessFailure": false, // true => HTTP 422; a problem was detected
"dryRun": true, // echoes the mode actually run
"source": { "database": "oktivo-prod", "sameCluster": false },
"target": { "database": "Oktivo-Stage", "targetOrgUID": "org_XXXX" },
"remap": { /* owner + team old-UID -> new-UID, old orgUID -> targetOrgUID */ },
"plan": [ // one entry per cloned collection
{ "collection": "User", "matched": 3, "created": 3 },
{ "collection": "CrmLead", "matched": 42, "created": 42,
"danglingReassigned": 2 } // e.g. assignedToUID pointed outside the account
],
"skipped": [ // denylisted / ignored collections, with reason
{ "collection": "ApiToken", "reason": "live secret (denylist)" }
],
"warnings": [ /* non-fatal notes, e.g. out-of-boundary array members dropped */ ],
"overwrite": [ /* present only when overwrite:true — per-collection delete counts */ ],
"log": [ /* ordered human-readable step log */ ]
}On a dry run, created counts are what would be created. On an apply, they are what was actually inserted.
What gets cloned
The set of collections is a reviewable allowlist in src/scripts/account-clone/clone-config.ts, derived from the real Mongoose schemas. For each allowlisted collection the config records which UID fields are used to select the customer's documents (matchOn) and which are rewritten to the new identities (uidFields).
Identity remapping
| Source (prod) | Target (stage) |
|---|---|
Owner userUID | fresh userUID (via generateUserUID()) |
Each team-member userUID | fresh userUID |
Owner email | targetEmail |
| Team-member emails | plus-alias or regenerated (per teamEmailStrategy) |
Source orgUID (everywhere) | targetOrgUID |
customerUID / accountOwnerUID / assignedToUID / createdByUID / updatedByUID / array UID fields | rewritten consistently to the new UIDs |
_id (every document) | fresh _id |
- Passwords are carried over verbatim (inserts use
insertMany, which skips thepre('save')re-hash hook, so cloned logins keep working). - Credit/money figures on the owner (
creditBalance,totalCreditsReceived, etc.) are reset to0— seeRESET_CREDIT_FIELDS. - Dangling ownership scalars: a lead (or similar) assigned to a prod user who is not part of the cloned account has its
assignedToUIDreassigned to the new owner so it never becomes invisible on stage. These reassignments are counted inplan[].danglingReassigned. - Array-membership UIDs (e.g.
assignedUserUIDs,bookmarkedByUIDs) that point outside the account are dropped with a warning, not reassigned.
What is NOT cloned (skip-by-default denylist)
Defined in the DENYLIST in clone-config.ts. These are skipped because copying them into stage is a security or correctness hazard:
- Live secrets / tokens / credentials — OAuth clients and tokens, API tokens, email tokens, BYOK keys, and all external provider connections (Gmail, Meta, Google Ads, WhatsApp, Shopify, GA4, CRM provider bindings).
- Telephony — phone numbers, number assignments, provisioned numbers, SIP trunks, provisioning records.
- Money ledgers — credit transactions, payment orders/config, fee and add-on pricing.
- PII — KYC records.
The Organization itself is also never cloned (the clone reuses the existing targetOrgUID). Any model that has no per-customer ownership field, or whose data is derived/global, is in the IGNORE list and skipped by design. A model that is registered but classified in none of the three lists triggers a startup warning and is treated as skipped (fail-safe).
Safety model
The invariants enforced in code (see _clone-shared.ts, read-only-source.ts, clone-account.ts, accountClone.routes.ts):
- Read-only source facade — the only object ever bound to the source connection exposes
find/findOne/countDocuments(and a cursor). No write-capable Mongoose model is ever constructed on the source connection. A unit test asserts the facade has no write methods. - Pre-connect same-DB guard — refuses before opening anything if the source and target resolve to the same database on the same cluster (string check).
- Post-connect same-DB guard — after connecting, compares the resolved host + database name of both connections and refuses if they coincide, catching equivalent-but-differently-spelled URIs (
?retryWrites, SRV vs non-SRV, trailing slash). - Disabled on production — when
NODE_ENV=production, both routes return403before any work (auth is still checked first). The clone is a stage-only QA tool. - Target prod-fingerprint guard — refuses if the target (
MONGO_URIorDB_NAME, and the resolved target host after connecting) contains a prod fingerprint. The default fingerprint isoktivo-production; extend it withCLONE_PROD_FINGERPRINTS(the default can never be removed). The source is deliberately exempt — reading prod is the whole point; only the write destination is checked. - Credentials are scrubbed — the source URI carries a password. It is never placed into the captured log, the result object, the
/statusresponse, or any error message; onlyhost/dbis reported, andcreateConnectionerrors are sanitized before they propagate. - Dry run by default — omitting
dryRunwrites nothing. - Overwrite is explicit and stage-only — deletion only happens with
overwrite: true, never on a dry run, and only against the stage target. - Only the source connection is closed — the server's default (stage) connection is never opened or closed by this feature.
Operator notes / gotchas
- Always dry-run first and read the
plan+remapbefore applying. overwritekeys ontargetEmail. It deletes the stage user holding that email and cascades to their team and matched collections. Do not pointoverwriteat atargetEmailowned by a real (non-clone) stage user.KnowledgeBaseDatais matched byorgUIDalone because the model has no per-customer field. This is correct while org↔customer is 1:1 (today's invariant); revisit if that cardinality ever changes.- One clone at a time — a concurrent request returns
409.
Files
| Path | Purpose |
|---|---|
src/routes/accountClone.routes.ts | The /clone and /status routes + auth. |
src/scripts/account-clone/_clone-shared.ts | Args, DB settings, both same-DB guards, in-process runner. |
src/scripts/account-clone/read-only-source.ts | The read-only source facade. |
src/scripts/account-clone/clone-config.ts | ALLOWLIST / DENYLIST / IGNORE + RESET_CREDIT_FIELDS. |
src/scripts/account-clone/remap.ts | Pure UID-rewrite + BSON-safe cloneLean. |
src/scripts/account-clone/clone-account.ts | Orchestrator: discover → remap → validate → copy → report. |
src/scripts/account-clone/__tests__/ | Vitest unit tests (remap, read-only facade, config invariants). |
src/app.ts | Mounts the route (2-line addition). |
Run the tests:
cd backend/client_backend
npx vitest --run src/scripts/account-clone