`).join('');
diff --git a/scripts/migrate/migrate_apply.sh b/scripts/migrate/migrate_apply.sh
index 0064955..f9c30c4 100644
--- a/scripts/migrate/migrate_apply.sh
+++ b/scripts/migrate/migrate_apply.sh
@@ -81,6 +81,9 @@ migrateApplyApp()
migrateEmit phase=pre-backup status=skipped reason=user-opt-out app="$app"
fi
+ # Per-app pre-migrate hook (optional) — declared in the app's tools.sh.
+ migrateRunHook "$app" pre "$source_host" "restic"
+
# ---- 3. The actual restore (reuses existing restoreAppStart) --------------
# restoreAppStart already wipes the app folder, restores the snapshot,
# re-runs the install-time tag pipeline, and starts the container. The
@@ -109,6 +112,9 @@ migrateApplyApp()
fi
fi
+ # Per-app post-migrate hook (optional) — last thing before we declare done.
+ migrateRunHook "$app" post "$source_host" "restic"
+
local finished_at
finished_at=$(date +%s)
local duration=$((finished_at - started_at))
diff --git a/scripts/migrate/migrate_hooks.sh b/scripts/migrate/migrate_hooks.sh
new file mode 100644
index 0000000..e338ea5
--- /dev/null
+++ b/scripts/migrate/migrate_hooks.sh
@@ -0,0 +1,37 @@
+#!/bin/bash
+
+# Per-app migrate hooks. After a migrate (restic-mediated apply OR direct-SSH
+# pull) places the source's data on this host, some apps need app-specific
+# fix-ups beyond the standard URL rewrite — rotating a federation key,
+# regenerating an OIDC client secret, dropping a SaaS lock, etc.
+#
+# Convention: an app's tools.sh (auto-sourced by the modular per-app tools
+# loader — see [[libreportal-modular-app-tools]]) may declare:
+#
+# _migrate_pre() # called before stop+wipe
+# _migrate_post() # called after restart, before user sees it
+#
+# Both receive: $1 = source_hostname (peer hostname or backup tag),
+# $2 = transport ("restic" | "direct-ssh")
+# Hooks are optional — apps without them just inherit the standard flow.
+
+# Run a single named hook if it exists. Quiet if not defined.
+migrateRunHook()
+{
+ local app="$1"
+ local stage="$2" # "pre" or "post"
+ local source="$3"
+ local transport="$4"
+
+ local hook_name="${app}_migrate_${stage}"
+ if declare -f "$hook_name" >/dev/null 2>&1; then
+ isNotice "Running ${stage}-migrate hook for ${app}"
+ migrateEmit phase="hook-${stage}" status=running app="$app" hook="$hook_name"
+ if "$hook_name" "$source" "$transport"; then
+ migrateEmit phase="hook-${stage}" status=complete app="$app"
+ else
+ isNotice "Hook ${hook_name} returned non-zero — continuing migrate"
+ migrateEmit phase="hook-${stage}" status=failed app="$app"
+ fi
+ fi
+}
diff --git a/scripts/peer/peer_pull.sh b/scripts/peer/peer_pull.sh
index 0299609..7e007cb 100644
--- a/scripts/peer/peer_pull.sh
+++ b/scripts/peer/peer_pull.sh
@@ -64,6 +64,9 @@ peerPullApp()
migrateEmit phase=pre-backup status=skipped reason=user-opt-out app="$app"
fi
+ # Per-app pre-migrate hook (optional) — declared in the app's tools.sh.
+ migrateRunHook "$app" pre "$peer_name" "direct-ssh"
+
# ---- 3. Stop + wipe ----------------------------------------------------
migrateEmit phase=stop status=running app="$app"
if declare -f dockerComposeDown >/dev/null 2>&1 && [[ -d "$containers_dir$app" ]]; then
@@ -117,6 +120,9 @@ peerPullApp()
fi
migrateEmit phase=start-app status=complete app="$app"
+ # Per-app post-migrate hook (optional) — last thing before declaring done.
+ migrateRunHook "$app" post "$peer_name" "direct-ssh"
+
local finished_at; finished_at=$(date +%s)
local duration=$((finished_at - started_at))
isSuccessful "Pulled $app from $peer_name in ${duration}s"
diff --git a/scripts/source/files/arrays/files_migrate.sh b/scripts/source/files/arrays/files_migrate.sh
index 8376026..517c41e 100755
--- a/scripts/source/files/arrays/files_migrate.sh
+++ b/scripts/source/files/arrays/files_migrate.sh
@@ -6,6 +6,7 @@
migrate_scripts=(
"migrate/migrate_apply.sh"
"migrate/migrate_discover.sh"
+ "migrate/migrate_hooks.sh"
"migrate/migrate_pre_backup.sh"
"migrate/migrate_preflight.sh"
"migrate/migrate_progress.sh"