Stay Updated

New tutorials, tips, and Atlassian insights. No spam, unsubscribe anytime.

L
LeanZero

An approachable expert helping teams simplify their Atlassian ecosystems. Sharing knowledge and building community, one solution at a time.

Services

  • Atlassian Migrations
  • Atlassian FastShift
  • Atlassian Maintenance
  • Forge App Development
  • AI Development Consultation

Topics

  • Jira
  • Jira Service Management
  • Confluence
  • Bitbucket
  • Atlassian Forge
  • Cloud Migration
  • Local AI
  • AI Coding
  • All topics

Company

  • Blog
  • Tutorials
  • Contact

Community

  • Join Discord
  • Support this site

© 2026 LeanZero. All rights reserved.

Privacy Policy|Terms of Service|Service Level Agreement|Trust Center
LZ·/PORTFOLIO·REV 2.6
  1. Home
  2. Portfolio
  3. Confluence Migration Toolkit
Open-source migration toolkit

Confluence Migration Toolkit

Seven Node.js tools that repair Confluence Cloud content after a Data Center migration — the macros, page structure and permissions the migration assistant does not carry across intact.

View on GitHubRead the manual
DC to Cloud Backup before every write Idempotent and resumable Apache-2.0

What silently breaks

A Confluence migration moves pages. It does not guarantee that what is inside the pages still works — and none of the four failures below appear in the migration report.

Macros that half-survive

The macro name in storage format changes, or the Cloud build of the app expects different parameters. The page renders an "Unknown macro" placeholder where the content used to be, and the migration reports success.

Structure Cloud cannot represent

Nested bodied macros are legal on Data Center and unsupported by Cloud's Fabric editor. The page renders wrong and the editor refuses to open it at all.

Identity that no longer resolves

Anything storing a DC username or group name stops matching, because Cloud is keyed on accountId and groupId. Visibility macros fail open — content meant for one group becomes visible to everyone, silently.

What is in the box

One tool per failure mode. Every one of them is a storage-format rewrite: discover the affected pages, parse the XHTML, splice in the change, back up the original, PUT it back. Nothing is regenerated; everything else on the page stays byte-identical.

grant-space-admin

The precondition for everything else

Makes your API-token account a space administrator on every space, so the PUTs are actually allowed. Site admin is not enough — run this first or the other six spend a long run collecting 403s.

html-macro

HTML macros that arrived empty

Extracts the raw HTML from the Data Center storage format and replaces the broken macro block on Cloud with the original content. Plan-driven, resumable, retryable.

nested-macro

Nested bodied macros

Rewrites nested bodied macros so the inner macro becomes a sibling, using a split-around-child strategy that handles arbitrary depth and multiple siblings. Containers that cannot be split safely are reported, not mangled.

visibility-macro

Show If / Hide If that hides nothing

Reads ground truth from the DC page, resolves DC usernames and group names to Cloud accountId and groupId, and rewrites the macro parameters so the condition matches real people again.

composition-tabs

Tabbed pages that stopped rendering

Rewrites Deck-of-Cards and Card macros whose names collide with Cloud natives to the Cloud-compatible legacy equivalents. Default-deny: an ambiguous card with no signal is skipped with a recorded reason.

responsibility-to-aura

Apps with no Cloud build at all

The original macro XML survives in page storage even when the app does not exist on Cloud. Discovers it by CQL, resolves the named users to Cloud identities, and PUTs an equivalent Cloud macro in its place.

page-restrictions

App migrations blocked by restricted pages

Backs up and strips DC page restrictions so a blocked app migration can run, then restores them on Cloud afterwards — translating usernames to accountId and reporting every one it could not resolve.

How every tool in this repository behaves

The same operating model throughout, deliberately — so that knowing one tool means knowing all of them.

Two phases, always

A read-only plan phase discovers affected pages and writes a reviewable JSON plan. An execute phase acts on it. --dry-run runs the second phase without the PUT. You are expected to read the plan before you run it.

Backups before every write

Each modified page gets its original storage XHTML, a unified diff and a metadata JSON written under backups/ before the new version is sent. Recovery is per page, not all-or-nothing.

Default-deny on ambiguity

Where a page could legitimately be either shape, the tool skips it and records the reason rather than guessing. A skipped page you can fix by hand is cheaper than a corrupted page nobody notices.

Idempotent and resumable

Re-running is safe — already-converted pages are detected and skipped — and an interrupted run resumes from its plan file rather than starting over.

Start here

git clone https://github.com/leanzero-srl/leanzero-confluence-migration-toolkit.git
cd leanzero-confluence-migration-toolkit/grant-space-admin
npm install
cp .env.example .env        # CLOUD_BASE_URL (with /wiki), CLOUD_EMAIL, CLOUD_API_TOKEN

node main/grant_space_admin.js --dry-run
node main/grant_space_admin.js --skip-personal

The manual

Every question this repository raises, answered in order: what to set up before you touch a page, how to repair each class of broken macro, how to fix page structure and identity, and how to roll back and prove a run actually worked. 23 sections.

Contents
Before you touch a page
  • 01What do I need before I start?
  • 02How do I make my token allowed to edit every page?
  • 03What is the safe order of operations?
Repairing broken macros
  • 04My HTML macros came across empty. How do I get the content back?
  • 05My tabbed pages stopped rendering entirely. What happened?
  • 06An app has no Cloud version at all. Is that content gone?
Repairing page structure and identity
  • 07The editor refuses to open a page. Why?
  • 08My Show If / Hide If macros stopped hiding anything. Why?
The seven tools, one at a time
  • 09grant-space-admin — how do I run it?
  • 10html-macro — how do I run it?
  • 11nested-macro — how do I run it?
  • 12visibility-macro — how do I run it?
  • 13composition-tabs — how do I run it, and how do I undo it?
  • 14responsibility-to-aura — how do I run it?
  • 15page-restrictions — how do I run it?
Restrictions, rollback and proof
  • 16An app migration keeps failing on restricted pages. What now?
  • 17Something went wrong. How do I roll a page back?
  • 18How do I actually prove a run worked?
When it goes wrong
  • 19Everything fails with 403. What is it?
  • 20The plan phase finds nothing. Is that good news?
  • 21The write succeeded and the page renders as an empty box.
  • 22The run died halfway. How do I restart it safely?
  • 23It slows to a crawl or starts returning 429.

01What do I need before I start?

Node 18 or newer, a Confluence Cloud API token, and — the part everyone skips — space-admin rights on every space you intend to edit. Site admin is not enough.

Every tool in this repository is a plain Node.js CLI. There is nothing to install into the tenant, no app to approve, no Forge deploy. Each one talks to the public Confluence REST API over Node's built-in https using Basic auth (email:api_token), and writes its output to local files you can read.

Requirements

WhatVersionWhy
Node.js18 or newerThe tools use modern syntax and the built-in https client. No transpile step.
npmships with NodeInstalls dotenv, and fast-xml-parser where storage XHTML has to be parsed.
A Confluence Cloud API tokenanyCreated at id.atlassian.com → Security → API tokens. Used as Basic auth with your account email.
Space admin on the target spaces—The blocker nobody expects. See the next section.
A Data Center accountonly for DC→Cloud toolshtml-macro, visibility-macro and page-restrictions read ground truth from DC.
CarefulSite admin does not imply space admin. A site admin can see every space in the admin console and still receive 403 on PUT /wiki/api/v2/pages/{id}. This is the single most common reason a first run produces a long list of failures that look like a credentials problem and are not.

02How do I make my token allowed to edit every page?

Run grant-space-admin once. It adds your token's own account as an administrator on every space, additively and idempotently.

The precondition, in four commands

git clone https://github.com/leanzero-srl/leanzero-confluence-migration-toolkit.git
cd leanzero-confluence-migration-toolkit/grant-space-admin
npm install
cp .env.example .env        # CLOUD_BASE_URL (include /wiki), CLOUD_EMAIL, CLOUD_API_TOKEN

node main/grant_space_admin.js --dry-run       # see what would be granted
node main/grant_space_admin.js --skip-personal # grant, excluding ~personal spaces

What it actually calls

GET /wiki/rest/api/user/current
Resolves the accountId behind your token, so you never have to supply it.
GET /wiki/api/v2/spaces
Enumerates every space, paginated.
GET /wiki/api/v2/spaces/{id}/permissions
Reads what you already hold, so an already-granted space is skipped.
POST /wiki/rest/api/space/{spaceKey}/permission
Grants one permission. The v1 endpoint is the only documented way to do this from a user API token — Forge and Connect apps cannot call it.
NoteIt is additive only and idempotent: nothing is ever removed, and spaces where you already hold the target permissions are skipped. The undo is manual — remove the permission from Space settings, or DELETE /wiki/rest/api/space/{key}/permission/{id} using the ids in logs/grant_<epoch>.log.

03What is the safe order of operations?

Discovery dump, then one page, then one space, then the site — opening a browser at every step. A storage-format rewrite that is syntactically valid and semantically wrong reports success and renders an empty box.

The order that keeps you out of trouble

  1. 1Dump the raw storage XML first. Most tools have a --discovery-dump (or equivalent) that prints the untouched storage format of a handful of matches and exits. Storage format varies by app version — confirm the exact ac:name and parameter shape on your tenant before assuming the tool's defaults fit.
  2. 2Run the plan phase. Read-only. It writes a JSON plan under logs/. Open it. The plan is the contract: if it lists a page you did not expect, stop.
  3. 3Dry-run one page. --dry-run --space TEST --limit 1. This produces the backup, the unified diff and the metadata JSON without sending the PUT. Read the diff.
  4. 4Apply to one page. Then open it in a browser. Not the API — the browser. This is the only step that can tell you the macro renders.
  5. 5Apply to one space, re-check a sample, then go site-wide.
TipEvery write is backed up first. Before each PUT, the modified page's original storage XHTML, a unified diff and a metadata JSON are written under backups/. That directory is your undo, and it is per page — you can restore one page without touching the rest.

04My HTML macros came across empty. How do I get the content back?

html-macro reads the original HTML out of the Data Center storage format and splices it into the Cloud page in place of the broken macro block.

When pages move from Data Center to Cloud, HTML macros frequently break: the app may have no Cloud build, may behave differently, or may simply fail to render. What is left is an "unknown macro" placeholder or mangled markup where rich content used to be. The content itself is not lost — it is still sitting in the DC page's storage format.

How it runs

  1. 1Scans the DC instance for every page containing an HTML macro and extracts the raw HTML from the storage format.
  2. 2Finds the corresponding Cloud page and matches each macro block to its DC source.
  3. 3Writes a plan file. This is resumable and retryable — an interrupted run picks up where it stopped rather than starting over.
  4. 4Replaces the broken macro blocks on Cloud with the original content, in the chosen replacement mode.
CarefulRaw HTML on Cloud is not the same thing as raw HTML on DC. Confluence Cloud sanitises what it will render. Inline <script>, some inline styles and some embedded iframes will not survive, whichever mode you pick. Check one converted page in a browser before you accept a site-wide run — the API will return 200 for content that renders as nothing.

05My tabbed pages stopped rendering entirely. What happened?

Deck-of-Cards and Card macros can land on Cloud under names that collide with Cloud natives. composition-tabs rewrites them to the Cloud-compatible legacy equivalents, byte-for-byte everywhere else.

After migration, a tabbed-content app's Deck of Cards and Card macros sometimes arrive in Cloud storage as ac:name="deck" and ac:name="card". Those names collide with Confluence Cloud natives, and the page stops rendering. The Cloud-compatible legacy equivalents are tab-group and tab, so the fix is a storage-XHTML rewrite.

The exact rewrite

BeforeAfter
<ac:structured-macro ac:name="deck"><ac:structured-macro ac:name="tab-group">
<ac:structured-macro ac:name="card"><ac:structured-macro ac:name="tab">
top-level card param ac:name="label"ac:name="title"
top-level deck param id (optional, default on)deckId
everything elsepreserved byte-for-byte — macro ids, schema versions, <ac:rich-text-body> content, other params
NoteDefault-deny verification. Every deck is treated as the app's macro, because Cloud natives never use ac:name="deck" in storage. A card, which is ambiguous, is rewritten only if it has a deck/tab-group/tab ancestor, or a label parameter of the app's shape. Stand-alone cards with no signal are skipped with a recorded reason rather than guessed at.
LimitThis is phase one only. Converting the legacy Tab Group / Tab macros onward to the vendor's modern Tabs macro is deliberately out of scope — the app's own in-product "Switch to Tabs" converter is the supported path for that, and re-implementing it would be guessing at a private format.

06An app has no Cloud version at all. Is that content gone?

No. The original macro XML survives in page storage. responsibility-to-aura shows the pattern: discover it by CQL, resolve the identities, and PUT an equivalent Cloud macro in its place.

Server-and-DC-only apps have no Cloud build, so after migration their macros show as "Unknown macro" placeholders. What is easy to miss is that the original <ac:structured-macro …> XML is still there, intact, in the page's storage format — unrendered but fully readable. That makes a conversion possible, to whichever Cloud app you have chosen as the replacement.

The three-run pattern — worth copying for any macro conversion

  1. 1`--discovery-dump` — dump the raw storage XML of N matching pages and exit. Nothing is planned, nothing is written. This is how you confirm the exact ac:name and parameter shape on your tenant instead of trusting a default.
  2. 2`--dry-run` — build the plan, resolve every user identity, write backups and unified diffs, and skip the PUT. Read backups/*.diff.patch. This is the real review step.
  3. 3apply — same as the dry run, but the PUT is sent. Start with --space TEST --limit 1.

All three, in order

node main/sync_responsibility_to_aura.js --discovery-dump --space TEST --limit 5
node main/sync_responsibility_to_aura.js --dry-run --space TEST --limit 1
node main/sync_responsibility_to_aura.js --space TEST --limit 1
CarefulThe identity step is where these conversions actually fail. The source macro names users the way DC named them; the Cloud macro needs accountId. The resolver matches on email and reports every name it could not resolve, rather than dropping the user silently. Read that report — an unresolved responsible person is a converted macro that names nobody.

07The editor refuses to open a page. Why?

Nested bodied macros. Legal on Data Center, unsupported by Cloud's Fabric editor — it errors with confluenceADFMigrationUnsupportedContentInternalExtension. nested-macro rewrites the nesting away.

Confluence Cloud's editor cannot represent a bodied macro inside another bodied macro. Pages that have them render incorrectly and refuse to open for editing. The fix is to make the inner macro a sibling of the outer one instead of a child, without losing any content.

Split-around-child

Before:   A[ prefix   B   suffix ]

After:    A[prefix]  +  B  +  A'[suffix]

A' is a clone of A's wrapper — same ac:name, same parameters — with a fresh ac:macro-id. Empty halves are dropped. Intermediate tags between A's body and B (<p>, <td>, layout cells) are split too. The whole thing runs iteratively until the tree is stable, so arbitrary nesting depth and multiple sibling inner macros both work.

LimitSome containers cannot be split. column, section, layout, details, tabs-group and tabs carry structural semantics that splitting would destroy. For those, --fallback-strategy decides what happens instead — and the honest answer for some pages is that a human should look at them.

Discovery

Plan phase
CQL search for pages containing a candidate bodied macro (info, expand, panel, note, warning, tip, details, excerpt, column, section, layout, status), then fetch and parse each one's storage format via the v2 API and record every macro whose ancestor chain contains another bodied macro. Result: logs/plan_<ts>.json.
Execute phase
For every pending page, re-fetch the storage format (to get the current version number), un-nest, serialise, and PUT back via the v2 API.

08My Show If / Hide If macros stopped hiding anything. Why?

Their users and user-groups parameters still hold Data Center usernames and group names. Cloud's build of the app expects accountId and groupId, so the condition matches nobody.

This is the general shape of every identity-bearing macro parameter after a migration, and it is dangerous in the direction nobody checks: a visibility condition that resolves to nobody usually fails open, so content intended for one group becomes visible to everyone, with no error anywhere.

How the rewrite works

  1. 1Scan Cloud by CQL for pages containing show-if / hide-if macros.
  2. 2Look up the matching DC page (by space + title) to read the ground-truth parameter values — the Cloud copy has already lost fidelity, so DC is the source of truth.
  3. 3Resolve every DC username to a Cloud accountId and every DC group name to a Cloud groupId.
  4. 4Rewrite the macro parameters in the Cloud page body and PUT the update.
Tip--default-groups "staff:<groupId>,wiki-external:<groupId>" pins a mapping for groups whose names differ across instances, so the resolver does not have to guess. Supply it for any group that was renamed during the move.
CarefulVerify by absence. The correct check is not "did the writes succeed" — it is logging in as someone who should not see the protected content and confirming they cannot. A count of successful PUTs says nothing about who can now read what.

09grant-space-admin — how do I run it?

Once, before anything else. It adds your token's own account as an administrator on every space, additively and idempotently.

Setup and run

cd grant-space-admin
npm install
cp .env.example .env     # CLOUD_BASE_URL (must include /wiki), CLOUD_EMAIL, CLOUD_API_TOKEN

node main/grant_space_admin.js --dry-run                 # report only
node main/grant_space_admin.js --space DOCS,TEAM         # two spaces first
node main/grant_space_admin.js --skip-personal           # whole site, no ~personal spaces

Flags

FlagDefaultWhat it does
--dry-runoffEnumerate and report; send no POST.
--space <KEY>allRestrict to space key(s). Repeatable, or comma-separated.
--skip-personaloffExclude personal spaces — keys starting with ~.
--full-grantoffGrant the full admin-equivalent operation set instead of just administer:space + read:space.
--concurrency <N>3Parallel workers.

Output goes to logs/grant_<epoch>.log, one line per space with the permissions granted or the reason it was skipped. That log is the undo list — there is no automatic revert, so keep it.

10html-macro — how do I run it?

Two phases against two instances: read the original HTML from Data Center, replace the broken macro block on Cloud. The replacement mode is the decision that matters.

The run, in order

cd html-macro && npm install && cp .env.example .env

# 1. Plan one space. Read-only; writes logs/plan_<ts>.json.
node main/sync_html_macros.js --space DOCS --plan-only

# 2. Dry-run a single page and read the diff under backups/.
node main/sync_html_macros.js --space DOCS --limit 1 --dry-run

# 3. Apply to that space.
node main/sync_html_macros.js --space DOCS

# 4. Resume an interrupted run, retrying failures.
node main/sync_html_macros.js --resume --retry-failed

Flags

FlagDefaultWhat it does
--dry-runoffFull path with the PUT suppressed. Backups and diffs are still written.
--space <KEY>allSpace key(s); repeatable or comma-separated.
--limit <n>noneCap total pages processed.
--plan-onlyoffBuild the plan and stop.
--execute-only / --resumeoffRun an existing plan.
--plan-file <path>latestPlan JSON to load.
--concurrency <n>3Parallel Cloud PUTs.
--retry-failedoffAlso reprocess pages whose status is failed.
--replacement-mode <mode>rawraw inlines the HTML, macro preserves the macro wrapper, code wraps the HTML in a code block.
--macro-type <types>html,cssWhich macro types to process — html, css, or both.
--space-type <kind>sitesWhen --space is not used: sites (global spaces, matches Cloud), personal (DC ~user spaces), or all.
TipChoose the replacement mode deliberately. raw gives the closest visual result and is the most exposed to Cloud's sanitiser. code never renders but never loses a character — it is the right choice when the HTML carries scripts you intend to rewrite by hand later. macro is for when the Cloud app exists and only the body needs restoring.

11nested-macro — how do I run it?

Cloud-only. Discovers nested bodied macros by CQL, un-nests with split-around-child, and PUTs via the v2 API. The container exclusion list is the safety valve.

The run

cd nested-macro && npm install && cp .env.example .env

node main/sync_nested_macros.js --space DOCS --plan-only     # plan -> logs/plan_<ts>.json
node main/sync_nested_macros.js --space DOCS --limit 1 --dry-run
node main/sync_nested_macros.js --space DOCS
node main/sync_nested_macros.js --all                        # whole site

Flags

FlagDefaultWhat it does
--space <KEY> / --all—One space, or the whole site. One of the two is required.
--dry-runoffNo PUT; backups and diffs still written.
--limit <n>noneCap pages processed.
--plan-only / --execute-only / --resumeoffThe usual two-phase controls.
--plan-file <path>latestPlan JSON to resume from.
--concurrency <n>3Parallel page updates.
--retry-failedoffReprocess failed rows.
--candidate-macros <list>see belowWhich bodied macros CQL searches for.
--excluded-containers <list>see belowOuter macros that must never be split.
--fallback-strategy <s>—What to do when the outer macro is on the excluded list.

The two lists that decide behaviour

Default candidates
expand, info, warning, note, tip, panel, details, excerpt, column, section, layout, status — a page matching any of these is a candidate, and the body is then parsed to confirm real nesting.
Default excluded containers
column, section, layout, details, tabs-group, tabs, table, tr, td, div, show-if, hide-if. Splitting these destroys structural or access-control semantics, so they are never split.
Careful`show-if` and `hide-if` are on the excluded list for a reason. Splitting a visibility macro would move protected content outside the condition and publish it to everyone. If your pages nest inside those, fix them by hand.

12visibility-macro — how do I run it?

Reads ground truth from the DC page, resolves DC usernames and group names to Cloud accountId and groupId, rewrites the parameters. Pin the mappings you already know.

The run

cd visibility-macro && npm install && cp .env.example .env

node main/sync_visibility_macros.js --space DOCS --plan-only
node main/sync_visibility_macros.js --space DOCS --limit 1 --dry-run
node main/sync_visibility_macros.js --space DOCS \
  --default-groups "staff:<groupId>,wiki-external:<groupId>"

Flags

FlagWhat it does
--space <KEY> / --allScope. One is required.
--dry-run, --limit, --plan-only, --execute-only, --resume, --plan-file, --concurrency, --retry-failedThe standard two-phase controls, as above.
--default-groups "name:groupId,…"Pin a group name to a Cloud groupId, for groups the resolver cannot match by name (usually because they were renamed during the move).
--group-mapping <path>A file of DC group name → Cloud groupId, for when there are too many to pass on the command line.
--user-mapping <path>The same for DC username → Cloud accountId.
--macro-name <name>Override the macro name searched for, if your app version differs.
--cloud-groups-param-name <name>Override the parameter the Cloud macro stores groupIds in.
--strict-dcRefuse to rewrite a macro when the DC page cannot be matched, instead of falling back to the Cloud values.
--template-page <id>A page carrying a known-good Cloud macro, used as the shape template for the rewrite.
CarefulUse `--strict-dc` on anything that matters. Without it, a page whose DC counterpart cannot be found is rewritten from the Cloud values, which are the broken ones. Strict mode turns that into a skip with a recorded reason, which is what you want.

13composition-tabs — how do I run it, and how do I undo it?

The only tool in the repository with a dedicated restore script. Rewrite deck/card to tab-group/tab, and roll it back page by page if you need to.

Convert

cd composition-tabs && npm install && cp .env.example .env

node main/sync_composition_tabs.js --space DOCS --plan-only
node main/sync_composition_tabs.js --space DOCS --limit 1 --dry-run
node main/sync_composition_tabs.js --space DOCS

Undo

# Version-restore each page touched by that plan.
node main/restore_composition_tabs.js --plan-file logs/plan_<ts>.json --dry-run
node main/restore_composition_tabs.js --plan-file logs/plan_<ts>.json

# Or PUT the saved storage XHTML back instead of using version-restore.
node main/restore_composition_tabs.js --plan-file logs/plan_<ts>.json --from-backup

Flags worth knowing

FlagWhat it does
--old-deck-key / --old-card-keyOverride the storage macro names searched for, when your app version emits different ones.
--card-label-param / --card-title-paramOverride the parameter rename — by default label becomes title on cards.
--no-rename-deck-idLeave the top-level deck id parameter alone instead of renaming it to deckId.
--no-backupSkip writing the per-page backup. Do not use this.
--no-verify-afterSkip the post-write re-read that confirms the new storage parsed.
--version-message <msg>The audit message attached to the new page version.
--backup-dir <path>Where backups are written. Default backups/.
restore: --from-backupPUT the saved XHTML instead of calling version-restore. Use this when the page has been edited since.
restore: --message <msg>Audit message attached to the restore.
TipVersion-restore is cleaner than `--from-backup` when nothing else has touched the page. It uses Confluence's own history, so the page's version chain stays honest. Reach for --from-backup only when someone has edited the page since the conversion and you want the pre-conversion body specifically.

14responsibility-to-aura — how do I run it?

The most configurable tool here, because it converts between two different apps' macros. Discovery dump first, always — the parameter names on your tenant decide every flag below.

The three runs

cd responsibility-to-aura && npm install && cp .env.example .env

# 1. DISCOVERY — dump raw storage XML of N matches and exit.
node main/sync_responsibility_to_aura.js --discovery-dump --space TEST --limit 5

# 2. DRY-RUN — plan, resolve identities, write backups + diffs, no PUT.
node main/sync_responsibility_to_aura.js --dry-run --space TEST --limit 1

# 3. APPLY.
node main/sync_responsibility_to_aura.js --space TEST --limit 1

The flags that shape the conversion

FlagWhat it does
--discovery-dumpDump raw storage XML of matching pages and exit. Run this first, every time.
--source-macro-name / --source-users-paramThe macro you are converting FROM, and the parameter holding its users. Read both off the discovery dump.
--target-app / --target-macro-name / --target-users-paramThe macro you are converting TO.
--target-users-ri-userEmit users as <ri:user> elements rather than a plain parameter value, when the target app expects that shape.
--aura-output-mode <mode>Which rendering variant of the target macro to emit.
--max-users-per-aura <n>Split into multiple macros when a page names more responsible users than one macro renders well.
--default-info-label <text>Label used when the source macro carried none.
--user-mapping <path> / --page-users-csv <path>Pin identities the resolver cannot match, or supply them per page from a CSV.
--cloud-page-id <id>Convert exactly one page, by id. The fastest way to iterate.

The companion scripts

export_active_users_dc.js
Export active DC users so the identity resolver has a name-to-email source that does not depend on Cloud search.
verify_conversions.js
Re-read the converted pages and confirm the new macro is present, at a valid level in the document, and names the users it should.
fix_editor_paragraph_wrap.js
Repairs the case where the new macro landed inside a paragraph node the Cloud editor rejects.
restore_responsibility_to_aura.js
Roll a plan back.
CarefulNever skip the discovery dump. Every flag in the table above defaults to a shape observed on one tenant. Storage format varies by app version, and a conversion driven by the wrong parameter name writes successfully and produces a macro that names nobody.

15page-restrictions — how do I run it?

Three plain scripts, in a fixed order, with a backup file that is the only way back. Read the order twice before running step two.

The sequence

cd page-restrictions && npm install && cp .env.example .env

node check_pages.js 123456789,123456790          # optional: inspect known ids
node remove_restrictions_dc.js --dry-run         # preview, writes nothing
node remove_restrictions_dc.js                   # backup + remove. KEEP THE PATH.

#  ... run the blocked app migration from the Cloud Migration Assistant ...

node restore_restrictions_cloud.js --backup backups/restrictions-backup-<ts>.json

Flags

FlagWhat it does
--dry-runPreview. Also settable as DRY_RUN=true in .env, which covers the restore step too.
--space-keys <list>Restrict the DC sweep to specific spaces.
--space-type <kind>Global spaces, personal spaces, or both.
--content-type <kind>Pages, blogposts, or both.
--concurrency <n>Parallel workers.
--backup <path>Restore only: the backup JSON to re-apply.
CarefulCopy the backup JSON off the machine running the migration before you proceed. Step two removes restrictions across every space, and that file is the entire record of what they were. Losing it means reconstructing your access model by hand.

16An app migration keeps failing on restricted pages. What now?

page-restrictions backs up and strips DC page restrictions so the migration can run, then restores them on Cloud afterwards. The backup file is the only way back — keep it.

Several app migrations fail on restricted content with a message telling you to "run the migration script provided". The cause is that the migration runs as an app user which page restrictions exclude. Restrictions are the blocker, and the only reliable fix is to remove them for the duration and put them back after.

The three scripts

ScriptSideWhat it does
check_pages.jsDCDiagnostic. Given content ids, reports whether each exists and what restrictions it carries. Read-only.
remove_restrictions_dc.jsDCScans every space for restricted pages and blogposts, writes a full backup JSON of every restriction, then removes them.
restore_restrictions_cloud.jsCloudReads that backup and re-applies the restrictions on Cloud, resolving DC usernames to accountId first.

The order — do not reorder it

  1. 1node check_pages.js 163545710,163545711 — optional, inspect a few known ids first.
  2. 2node remove_restrictions_dc.js --dry-run — preview the sweep. Nothing is written.
  3. 3node remove_restrictions_dc.js — back up and remove. Keep the printed backup path.
  4. 4Re-run the app migration from the Cloud Migration Assistant. Wait for it to finish.
  5. 5node restore_restrictions_cloud.js --backup backups/restrictions-backup-<ts>.json — restore.
CarefulStep three is not reversible without the backup file. It contains one entry per restricted content id with the type, space key, title and the full read/update restriction sets — users and groups — exactly as DC reported them. Copy it somewhere that is not the machine running the migration.
NoteThe restore script builds a username → accountId map from the Cloud user-search API before applying anything, and reports every username it could not resolve instead of silently dropping the grant. Resolve those by hand before you call the restore complete — an unresolved grant is a restriction that no longer restricts.

17Something went wrong. How do I roll a page back?

From backups/. Every write is preceded by the original storage XHTML, a unified diff and a metadata JSON, per page — so recovery is per page, not all-or-nothing.

What is written before every PUT

backups/<pageId>.xhtml
The original storage format, exactly as Cloud returned it. PUT this back to restore the page.
backups/<pageId>.diff.patch
Unified diff of the change. This is what you read during a dry run — it is far faster than diffing two XML blobs by eye.
backups/<pageId>.json
Metadata: page id, space, title, version number at the time of read, and the reason the page was selected.
TipConfluence's own page history is the second line of defence and it is often faster for a single page: every tool's PUT creates a new version, so "Restore this version" in the UI works normally. Use backups/ when you need to restore many pages, or restore programmatically.

Because every tool is idempotent, re-running after a partial failure is safe: already-converted pages are detected and skipped, and an interrupted run resumes from its plan file rather than starting over.

18How do I actually prove a run worked?

Open the pages. A storage-format rewrite that is syntactically valid and semantically wrong writes successfully, reports success, and renders as an empty box — the API cannot tell you, only the browser can.

The verification that counts

  1. 1Open three converted pages in a browser, in the theme and permissions a real reader has. Not the API response, not the storage format — the rendered page.
  2. 2Enumerate what a correct result contains, then check each item. For a converted macro: does it render, does it name the right people, does it show the right content, and is the surrounding page unchanged? One element working while three are blank is a failure, not a partial win.
  3. 3Check a skipped page too. The tools deliberately skip ambiguous content. Confirm those skips were correct rather than assuming the recorded reason was.
  4. 4For anything visibility-shaped, verify by absence — log in as an account that should not see the content and confirm it cannot.
  5. 5Re-run the plan phase. A clean second plan (nothing pending) is good evidence the first run finished. A plan that still lists pages is telling you something.
CarefulA green count is not a passing test. "412 pages updated" is a proxy metric. It is compatible with 412 pages that now render an empty box. Assume the run is broken until a rendered page proves otherwise.

19Everything fails with 403. What is it?

Almost always space permissions, not credentials. A site admin is not automatically a space admin, and the two failures look identical from the API.

Telling the two apart

SymptomCauseFix
401 on the very first callThe token is wrong, or CLOUD_BASE_URL is missing /wiki.Check the URL includes /wiki, and that you are sending email:api_token — not the raw token.
200 on reads, 403 on every PUTYou can see the space, you cannot edit it.Run grant-space-admin. This is the single most common first-run failure.
403 on some spaces onlyPartial space-admin coverage.Re-run grant-space-admin without --space, and read the log for the spaces it skipped.
404 on a page you can open in a browserThe page is restricted and your account is excluded.Page restrictions, not space permissions. See page-restrictions.

20The plan phase finds nothing. Is that good news?

Not until you prove your CQL can see the space at all. An empty result and a space you cannot read produce the same output.

The positive control

  1. 1Pick a page you know carries the macro, and note its space and id.
  2. 2Run the tool scoped to that space with --limit 1 and --plan-only. If it appears in the plan, discovery works.
  3. 3If it does not, run the tool's --discovery-dump (or fetch the page's storage format directly) and compare the ac:name in the output with the name the tool searches for.
  4. 4Mismatch means your app version emits a different macro name — override it with the relevant --*-macro-name or --candidate-macros flag.
CarefulCQL is index-backed, so it lags. A page edited seconds ago may not be findable yet. If a page you just changed is missing from the plan, wait and re-plan before concluding the tool is broken.

21The write succeeded and the page renders as an empty box.

Syntactically valid, semantically wrong storage. The API cannot detect this — only a browser can, which is why every run order in this manual ends at a browser.

The three usual causes

Wrong macro name for your app version
The storage parses, Confluence finds no macro registered under that name, and renders nothing. Compare against a page where the macro was created by hand in the Cloud UI.
Right macro, wrong parameter name
The macro renders but with no content or no users. Same fix: create one by hand, read it back over REST, and match the parameter names exactly.
Macro at an invalid document level
Some macros cannot sit inside a paragraph node. responsibility-to-aura ships fix_editor_paragraph_wrap.js for exactly this; other tools report it in the verify step.

Recovering the page

  1. 1Restore it — Confluence page history has your pre-write version, or use backups/<pageId>.xhtml.
  2. 2Create the macro by hand on a scratch page in the Cloud UI.
  3. 3Fetch that page's storage format and diff it against what the tool produced.
  4. 4Set the overriding flag the diff points at, then re-run against one page.

22The run died halfway. How do I restart it safely?

Re-run with --resume. Every tool is idempotent — already-converted pages are detected and skipped — and --retry-failed re-runs only the rows that failed.

Restarting

# Continue the most recent plan.
node main/<tool>.js --resume

# Continue a specific plan, and retry the rows that errored.
node main/<tool>.js --resume --plan-file logs/plan_<ts>.json --retry-failed
TipFix the cause before `--retry-failed`. Retrying against an unchanged cause reproduces the same failures and makes the first run's log harder to read. Read the failure reasons in the plan first — they are recorded per page.

If the plan itself is stale — the pages have changed since it was built — delete it and re-plan. Every tool re-fetches the page and its current version number immediately before writing, so a stale plan cannot corrupt a page; it will simply skip rows that no longer match.

23It slows to a crawl or starts returning 429.

Lower the concurrency. Confluence Cloud rate-limits per tenant, and the limit is lower than most people assume for write traffic.

What to change

SymptomChange
Sporadic 429Drop --concurrency to 1 or 2. Retries use exponential backoff, but sustained over-concurrency outruns it.
Steady but slowThat is usually correct. Storage rewrites are read-modify-write per page and cannot be batched.
5xx in burstsBack off and re-run with --resume. The plan makes this free.
One page hangsVery large storage bodies are slow to parse. --limit 1 on that page id will tell you whether it is the page or the tool.

What it does, and what it will not do

Stated up front, because discovering a limit mid-cutover is the expensive way to find it.

What it does

  • Macro ids, schema versions and rich-text bodies preserved byte-for-byte — only the targeted attribute changes
  • DC content recovered from the source instance where the Cloud copy lost it
  • DC usernames and group names resolved to Cloud accountId and groupId
  • Page restrictions backed up in full and restored after the migration completes
  • A per-page backup, unified diff and metadata JSON before every single write
  • Confluence page history, untouched — the UI's "restore this version" works normally

What it will not do

  • It will not render what Cloud sanitises — inline scripts, some inline styles and some iframes do not survive whichever mode you choose
  • It will not convert legacy Tab Group / Tab macros onward to the vendor's modern Tabs macro; the app's own in-product converter is the supported path
  • It will not split column, section, layout, details or tabs containers, whose structural semantics splitting would destroy
  • It will not guess an ambiguous macro's intent — it skips and records the reason instead
  • It will not invent an identity: unresolvable users and groups are reported, never silently dropped
  • It will not tell you the page renders. Only a browser can do that, and you have to open one
Apache-2.0 licensed. Free to use, fork and ship inside your own migration.

Running a Confluence migration?

The toolkit is free and Apache-2.0. If you would rather have someone who has already hit every one of these edge cases run it with you, that is what we do.

View on GitHubAtlassian migrationsAsk on Discord