Initial commit.

This commit is contained in:
Scott Duensing 2026-10-19 01:25:16 -05:00
commit 8ca82b555a
59 changed files with 12366 additions and 0 deletions

7
.gitignore vendored Normal file
View file

@ -0,0 +1,7 @@
node_modules/
*.log
# Editor / OS junk.
*~
*.swp
.DS_Store

275
README.md Normal file
View file

@ -0,0 +1,275 @@
# site-sync
A Saltcorn plugin for **one-way, desired-state deployment**: make a *target* site's
current design state converge to a *source* site's current state. Unlike its sibling
[`dev-deploy`](../dev-deploy) (which records an append-only ops journal and replays
it), site-sync keeps **no edit log** -- it snapshots the two sites' *current* state,
diffs them, previews a plan, and applies the difference. "Just make prod match dev."
## How it works
site-sync leans on Saltcorn's own machinery and adds only what's missing:
1. **Capture** -- a site's desired state is Saltcorn's own
`backup.create_pack_json(false, true)`: a name-keyed JSON pack of the metadata
(tables, fields, constraints, views, pages, page groups, triggers, workflow
steps, roles, tags, libraries, models, code pages) plus an allow-listed config
map. site-sync
*sanitizes* it (see Security). Reference-table **row data** travels separately in
an opt-in manifest.
2. **Diff / plan** -- the target pulls the source's desired state and diffs it against
its own current state, entity-by-entity (by **name**, except roles which are keyed
by their numeric id). Present-in-source-only => create, differing => update,
present-in-target-only => delete (mirror mode). Tables get a nested field- and
constraint-level diff. Probable TABLE renames are detected by structural signature
(a view/page rename is planned as create + delete). Code pages (server-side JS) are
a first-class kind, flagged with a warning on the preview.
3. **Preview** -- the plan is rendered (counts, per-entity changes, and a highlighted
list of **destructive** operations) and persisted under a `plan_id` with a
*precondition hash* of the target's current state.
4. **Apply** -- takes the site's apply lock (see "One apply at a time" below),
atomically claims the plan (a double-submit is refused), re-checks the
precondition (refuses if the target drifted since preview), snapshots for rollback,
applies the **renames** the admin confirmed, upserts via `install_pack`, prunes via
the surgical **delete engine** (constraint removals, model deletes and view
re-bindings included), then syncs reference rows. Every non-fatal problem is listed
on the result page. Re-applying an unchanged source is a no-op ("Already in sync").
Identity is **name-based** -- exactly "current state, no log". A table rename with no
journal would look like drop+create; site-sync detects a probable rename structurally,
shows it on the preview, and -- only if the admin ticks it there -- applies a
data-preserving `Table.rename` (on Postgres) instead, carrying the table's data mode
along. An unticked rename is applied as create + (gated) drop.
## Install & pair
Install the plugin, then open **Settings -> Plugins -> site-sync** (the gear bounces
straight to `/admin/site-sync/`).
- **Peers** tab: copy this instance's `env_id` into the other instance's peer form and
vice-versa; a shared secret is generated once (sealed at rest -- see Security).
**Rotate** generates a new secret and shows it once; paste it on the other instance
under **Replace a peer's secret** (until then the pairing cannot authenticate).
- **Dashboard**: set the environment label and policies (below).
Topology is **pull-primary**: the admin on the *target* pulls a *source* peer's state
and previews it locally, so every safety gate runs on the mutating side and never
trusts the sender. A push path (`POST /site-sync/api/plan` + `/apply`) also exists but
remote-triggered apply is gated behind `on_apply=auto`.
## Policies (Dashboard)
| Policy | Default | Meaning |
| --- | --- | --- |
| `sync_mode` | `mirror` | `mirror` prunes entities the source dropped; `additive` never prunes. |
| `on_delete` | `confirm` | Governs **data-bearing** drops (a table with rows, or a field on a populated table): `confirm` drops only the tables whose names the admin **types** into the apply form (never pre-filled) **and** ticks the confirmation; `refuse` never drops them; `auto` drops the typed tables without the tick. Metadata-only drops (0 rows) prune freely under mirror. Table renames need their own tick on the apply form. |
| `on_apply` | `confirm` | Remote apply policy. `confirm` = only the target's admin UI applies (preview, then apply by `plan_id`); `auto` = paired peers may ALSO push-apply remotely (`POST /site-sync/api/apply`, only plans they pushed themselves). A remote apply never drops data-bearing tables/fields and never renames tables. The pull path always previews. |
| `require_tls` | off | Reject inbound peer traffic that didn't arrive over HTTPS (as seen by Express: a TLS socket, or `X-Forwarded-Proto` from a proxy Saltcorn's `trust proxy` trusts). |
## Reference-data (Tables tab)
Row data is opt-in per table. Everything defaults to **user** mode (rows never
touched -- this is the only safe choice for end-user-entered data). Opt a
**reference/lookup** table into:
- **starter** -- rows are seeded once into a fresh table, then the target owns them.
- **managed** -- the source is canonical; rows are mirrored (add / update / remove),
matched by a stable per-row `_ss_row_uuid` (a registered field, so it survives
backup/restore; duplicates are re-keyed). Foreign keys are carried as the referenced
row's uuid and resolved back to a local id, with an insert fixpoint that handles
required cross-table FKs. Rows are written only when their values changed. A managed
row the source dropped but that user data on the target still references is KEPT
(with a warning), never cascaded; prune errors are reported, not fatal.
A starter table is marked shipped only once all of its rows are present.
**File fields** on managed/starter rows sync too, **content-addressed**: the manifest
carries each file's sha256 hash + name + mime, and the target fetches the bytes from
`GET /api/file/:hash` (a signed response), verifies the hash, and writes them into its
own store with `File.from_contents` (a required File field is materialized at insert).
Unchanged files are not re-fetched (dedup by hash); a failed fetch keeps the row's
existing file reference.
The `users` table is **hard-locked** to user mode and can never be flipped.
## Layout & logo files (always-on)
Files referenced *inside* page layouts, view configurations, and the `site_logo_id`
config are synced too -- **always on**, independent of the row data modes. On capture,
each such reference is replaced with a content-hash placeholder (so the diff is
content-based: an unchanged image doesn't churn, a changed one shows the page/logo as
an update). On apply, the target fetches the bytes over the same `/api/file/:hash`
transport, **verifies the hash**, enforces an **image-mime allow-list** (layout refs
are images; this stops a hostile peer planting a file served as HTML), and rewrites the
reference to its local copy -- deduping against files it already has. A materialized
layout image gets the most permissive `min_role` of the pages/views that use it (the
logo is public). This covers the
common cases (`fileid`, `bgFileId`, `image_id`, the site logo). Files embedded by
*other* means -- custom HTML/CSS bodies, theme assets, unrecognized keys -- are not
discovered; deploy those via a shared media store.
## Rollback
Before every apply, site-sync captures a **restore point**: the pre-apply metadata pack
plus exactly the rows the apply can change or destroy -- full rows of tables the delete
gate will drop, the primary key + doomed columns of tables losing fields, and all
managed/starter rows. The apply result page (and the failure page, when an apply broke
part-way) offers **"Rollback this apply"**, which re-syncs the metadata and config to
the pre-apply pack in mirror mode (whatever `sync_mode` says), applies the inverse of
any confirmed renames, removes rows the apply inserted, restores the captured rows by
primary key (no duplication), and un-marks starters the apply seeded. File references
revert for free: apply never deletes old file bytes, so restoring a row's old field
value points it back at the still-present original file. Each apply can be rolled back
at most once, and never while it is still in flight.
## One apply at a time
Only one apply or rollback runs on a site at a time -- across every worker process and
every server sharing the database, and including a peer's remote push-apply. Another
started meanwhile is refused (not queued), with what is running and since when. Each
tenant has its own lock.
The lock is held under a 60-second **lease** that the running apply renews every 15
seconds, timed by the database's clock (so servers whose clocks disagree still agree on
it). If the process running an apply or rollback dies -- crash, kill, restart -- its
lease lapses within a minute and the plan it left **applying** / **rolling back** is
reported as **interrupted**: on the dashboard (with a **Rollback this apply** button
when it has a restore point), in `sitesync_status` and `sitesync_get_plan`. The next
apply or rollback on the site takes the lapsed lock over and returns every interrupted
plan to the status it had before (an interrupted apply is pending again, so it can be
rolled back from its restore point or applied again; an interrupted rollback can be
retried), naming each in its warnings. An apply that stalls longer than its lease (the
lock taken over meanwhile) stops at its next phase and leaves the plan to the new
holder.
## Security model
Because a desired-state apply *overwrites and prunes* (a strictly larger blast radius
than an append-only journal), the trust boundary is enforced on the **target** side,
which never trusts a sender:
- **Admin gate**: every admin route requires an admin session created on **this**
tenant (upstream `isAdmin` semantics). Saltcorn's Postgres session store is shared by
all tenants, so another tenant's admin replaying their cookie here is refused.
- **Transport**: HMAC-SHA256 signed requests (timestamp + nonce + method + path +
target-host + body-hash), with a +/-5-minute skew window, a nonce-replay store,
host-binding (a request signed for tenant *t1* can't be replayed at *t2*; the host is
Express's `req.host`, never a raw client-set `X-Forwarded-Host`), a request-body cap
enforced *before* the signature check, and an optional TLS requirement. **Every
response is signed too** (`X-SS-Response-Signature` over the request's nonce, the
status and the body bytes) and the client verifies it -- and that the responder is
the paired `env_id` -- before using anything it pulled, so an on-path attacker on an
`http://` link cannot substitute a pack or a file. Outbound calls are bounded by a
timeout and a response-size cap.
- **Sealed secrets**: peer secrets are sealed AES-256-GCM (full 16-byte tag) under a key
derived from Saltcorn's `session_secret`, which must be set explicitly
(`SALTCORN_SESSION_SECRET` or `session_secret` in the Saltcorn config file);
Saltcorn's generated fallback secret is refused. Never stored in the DB.
- **Config allow-list**: only `menu_items`, `site_name`, `site_logo_id` can be
converged. `install_pack`'s `setConfig` clobbers unconditionally, so this list is
the sole guard against a compromised source pushing security config
(`disable_csrf_routes`, headers, SMTP, ...).
- **Infra & locked tables**: tables AND fields whose name begins with `_` (plugin/system
bookkeeping such as `_dd_*`, `_idp_*`, `_ss_*`, `_ss_row_uuid`) and the `users` table
are excluded from the pack entirely (never synced, diffed, or offered a data mode),
so a hostile peer can neither create/mutate them nor (under mirror) prune another
plugin's tables.
- **Plugins**: presence + version **parity check only** -- plugin configuration and the
secret `deploy_private_key` are stripped; a mismatch is a pre-flight warning, never
a mid-apply action.
- **Data-loss gates**: row counts are re-counted **live at apply time** (not trusted
from the preview), and remote push-apply can never satisfy the human data-drop
confirmation, nor apply a plan another peer authored.
## HTTP endpoints
Admin (admin session of this tenant; POSTs CSRF-protected): `GET /admin/site-sync/`,
`POST .../settings`, `GET .../peers`, `POST .../peers/add`, `POST .../peers/rotate`
(generate, or install a pasted secret), `POST .../peers/delete`, `GET .../plan` (choose a
peer; read-only), `POST .../plan` (pull + preview; persists a plan), `POST .../apply`,
`POST .../rollback`, `GET .../tables`, `POST .../tables/set`. The same operations are
offered to MCP clients as tools (see MCP tools).
Machine (HMAC peer auth, CSRF-exempt, signed responses): `GET /site-sync/api/health`,
`GET /site-sync/api/state`, `GET /site-sync/api/file/:hash`, `POST /site-sync/api/plan`,
`POST /site-sync/api/apply`.
## MCP tools
With the project's [saltcorn-mcp](../mcp) plugin installed on the same site, an MCP
client (an AI assistant holding a scoped MCP token) can drive site-sync the way an
admin drives the UI: the plugin exports `mcp_tools` (contract:
[`mcp/docs/pluginTools.md`](../mcp/docs/pluginTools.md)). The tools are thin adapters
over the same operations the admin routes use (`lib/operations.js`), so every rule
(settings validation, data-drop gates, the drift check, the users-table lock) applies
identically. Peers calling peers (the machine API) are never tools, and there is no
push: pulling is the path.
| Tool | Scope | What it does |
| --- | --- | --- |
| `sitesync_status` | read | env_id, label, policies, version, peer count, managed / starter table counts, whether the session secret is configured. |
| `sitesync_list_peers` | read | Paired peers (never their secrets) and this site's env_id. |
| `sitesync_peer_health` | read | Signed health call to a peer: reachable / authenticated, version, env_id, plugin-parity warnings. Talks to the peer. |
| `sitesync_list_tables` | read | Every application table: rows, data mode, locked, starter_shipped_at. |
| `sitesync_list_plans` | read | Recent plans (filter by status): peer, direction, has_restore_point, can_apply, can_rollback, stale. |
| `sitesync_get_plan` | read | A plan, trimmed: summary, entity names per kind and change, destructive items, rename candidates, the tables an apply must name to drop data and whether the acknowledgement is needed, config changes, row counts, warnings, standing. With `entity: {kind, name}`: one entity's source and current definition, secret-looking keys masked. |
| `sitesync_local_state` | read | This site's desired-state hash, entity counts per kind, syncable config present, shipped row counts (read-only). |
| `sitesync_plan_pull` | write | Pull a peer's state and record a plan (changes nothing on either site); returns the `get_plan` view. Talks to the peer. |
| `sitesync_apply_plan` | admin | Apply a plan (`confirm_renames`, `prunable`, `confirm_data_drops` are the UI's gates; never forced). Installs code pages, i.e. server-side JavaScript. Destructive. |
| `sitesync_rollback` | admin | Revert an applied plan to its restore point (once). Destructive. |
| `sitesync_update_settings` | admin | Partial, validated change of `env_label`, `sync_mode`, `on_delete`, `on_apply` (auto lets peers apply remotely), `require_tls`. |
| `sitesync_add_peer` | admin | Pair with a peer; a generated shared secret is returned ONCE (as the UI shows it once), a given one never. |
| `sitesync_rotate_peer_secret` | admin | Generate (returned once) or install the peer's new secret. |
| `sitesync_delete_peer` | admin | Unpair; the peer's plans go, except applied plans that can still be rolled back. Destructive. |
| `sitesync_set_table_mode` | admin | user / starter / managed for a table (validated; users and `_` tables refused). |
"peer" arguments take a peer's id, env_id or label. A stored plan holds the source's
full configurations and managed rows, so plan views never include rows and show
definitions only one entity at a time, through the server's redaction. Only one apply
or rollback runs on a site at a time (across every server process; see "One apply at a
time"); a pull or apply can take minutes.
## Testing
```
./test/run.sh # full in-process suite against a throwaway SQLite DB
./test/run.sh coreUnit.js # core (capture/diff/apply/rows/rollback) regression checks
./test/runPg.sh # the same suite against a throwaway Postgres DB
./test/runPg.sh pgGate.js # multi-tenant isolation gate (two real tenants)
./test/run.sh mcpTools.js # the MCP tools against a signed mock peer (also runPg.sh)
./test/run.sh applyLock.js # the apply lock: worker processes racing, one SIGKILLed mid-apply (also runPg.sh: + per-tenant locks)
node test/compatVectors.js # recorded vectors: peer secrets + signatures from earlier versions still work
node test/registeredTableTest.js # lib/common/registeredTable.js on a throwaway DB (RT_TEST_PG=1: Postgres)
```
`coreUnit.js` ends by running `test/compatVectors.js` (the vectors in
`test/compatVectors.json` were recorded from earlier code with a fake session
secret; never regenerate them).
```
./test/runLivePull.sh # live pull over real HTTP between two throwaway instances
```
The in-process runners spin up an **isolated** throwaway database (never touching
other instances), register the plugin by location so its real `onLoad` runs, and
keep every byte of runner state -- database, file store, sessions, and the
plugins folder Saltcorn copies a by-location plugin into -- in a `mktemp` directory
outside the plugin tree (`test/testEnv.sh`), so no test state can leak into an
installed copy of the plugin. They drive the real modules + route handlers. HMAC auth
is exercised both with async-iterable mock requests and over **real HTTP** (an Express app mounting the
plugin's own API routes, plus a deliberately tampering server for the client-side
response checks); convergence is tested by "capture desired state -> perturb ->
converge back". `runLivePull.sh` runs the **source as a real `saltcorn serve`** process
and pulls it in-process over the wire. Runners exit non-zero on any failure;
`runPg.sh` exits 77 with `SKIPPED (NOT green)` when Postgres is unreachable.
## Limitations / follow-ups
- **Renames on SQLite** fall back to drop+create (SQLite can't `ALTER ... RENAME`); the
data-bearing old table is *retained* by the delete gate, so no data is lost. On
Postgres the rename is applied in place.
- **Rollback of a managed-row prune** cannot bring back a file whose bytes were deleted
with it (a File field configured `also_delete_file`); the row comes back, the file
does not.
See [`docs/architecture.md`](./docs/architecture.md) for the module map and internals.

124
docs/architecture.md Normal file
View file

@ -0,0 +1,124 @@
# site-sync architecture
site-sync is a one-way desired-state deployer. It delegates SERIALIZATION to
Saltcorn's `backup.create_pack_json` and the bulk of APPLICATION to
`pack.install_pack`, and adds three things of its own: a diff/plan/preview engine, a
surgical policy-gated delete engine, and an opt-in reference-row layer. The peer
transport, sealed secrets, config workflow, and per-tenant patterns are transplanted
from the sibling `dev-deploy` plugin.
## Module map (`lib/`)
Transport & identity. The peer protocol, peer store, at-rest sealing, admin
helpers, registered-table bootstrap and sync-policy constants live in
`lib/common/` as generic infrastructure (they began as code shared with
dev-deploy; site-sync now owns its copy). The site-sync modules below bind them:
| Module | Responsibility |
| --- | --- |
| `constants.js` | Plugin name/version, `SYNC_MODES`, `DELETE_POLICY`, `APPLY_POLICY`, `PLAN_STATUS`, signed-request header/content-type names, the peer KEK salt/info (`PEER_KEK_INFO`) and table names; re-exports `SYNCABLE_CONFIG_KEYS`, `ROW_SYNC_LOCKED_TABLES`, `DATA_MODES` and the infra / lock / synced-mode predicates from `common/syncConstants.js`. |
| `transport.js` | API paths and size/time limits, and the outbound client on `common/peerClient.js` -- one `signedRequest` (signed headers, `application/vnd.site-sync+json`, timeout, byte-capped read, response-signature verification before use) behind `peerJson` (2xx JSON + responder `env_id` check) and `peerBytes` (verified file bytes). |
| `peerAuth.js` | Binding of `common/peerServer.js`: inbound `requirePeerAuth` (header check -> declared/streamed body cap -> skew -> peer lookup -> signature, host = Express `req.host` -> nonce replay -> JSON body -> signed TLS gate on `req.protocol`) and `sendSigned`/`sendSignedJson`, which sign every reply against the request's nonce. |
| `peers.js` | `_ss_peers` binding of `common/peerStore.js`: CRUD; secrets sealed at rest (`common/aesGcm.js`, KEK from `common/sessionKey.js` -- only an explicitly configured `session_secret`); rotate (generate or install the other side's new secret); `peer_id` allocation retries on collision; deleting a peer deletes its plans (`operations.deletePeer` first detaches the ones that can still be rolled back). |
| `env.js` | Env identity in `_sc_config` key `site_sync_env` via `common/configStore.js` (survives backup, restored before `onLoad`). |
| `ids.js` | `randomUuid` (env_id, plan ids, row identities). |
| `schema.js` | Idempotent table bootstrap: registered `_ss_peers` (fields from `common/peerStore.js`)/`_ss_table_modes`; raw regenerable `_ss_seen_nonces` (`common/peerServer.js`)/`_ss_plans`/`_ss_rollback`/`_ss_apply_lock`; race-tolerant column migrations (`_ss_plans.claimed_from`, `_ss_rollback.restore_files`) for workers booting together (and drops 0.0.1's unused `_ss_peer_state`). |
| `render.js` | Admin-UI helpers: the admin URL table, theme-aware `sendWrap`, nav; re-exports the shared admin gate, escape, csrf field and flash-and-redirect. |
New desired-state code:
| Module | Responsibility |
| --- | --- |
| `capture.js` | `buildDesiredState` = `create_pack_json` -> `sanitizeDesired` (parity-only plugins, config allow-list, exclusion of locked tables and of `_`-prefixed infra tables AND fields, plain-JSON normalization) -> order-insensitive hash. Also sanitizes packs received from a peer. |
| `diff.js` | Pure `computePlan(desired, current, opts)`: per-kind create/update/delete (roles by id, isEqual with per-kind normalization), nested table field/constraint diff, structural TABLE-rename detection (views/pages: create + delete), code pages as a first-class kind, config overwrite set, destructive annotation. |
| `plan.js` | Live row-count gathering, `can_install_pack` warnings, reference-row preview, plan persistence (`_ss_plans`; retention never prunes a plan whose restore point is kept) + precondition hash, plan listing, the data-drop tables an apply must name, and preview/apply-form HTML. |
| `siteLock.js` | The site-wide apply lock: one `_ss_apply_lock` row per tenant schema under a renewed lease timed by the database clock; atomic acquire (insert, or take over a lapsed lease) that recovers plans a stopped holder left in flight to their `claimed_from` status; lease checkpoints (`assertHeld`) and `finishClaimed` (no status write after the lock was lost); read-only `lockState` for the dashboard / MCP. |
| `apply.js` | Orchestrator: site lock -> atomic plan claim -> precondition re-check -> metadata snapshot + restore point -> confirmed-rename pre-pass -> deep-clone+prune -> `install_pack` -> delete pass -> reference-row sync -> non-silent `getState().refresh()`, checking the lease before each mutating phase; collects every non-fatal problem into `result.warnings`. |
| `deletes.js` | `applyRenames` (confirmed renames only; data-preserving `Table.rename`, SQLite fallback) + `applyDeletes` (reverse-dependency prune incl. code pages, models, constraint removals; FK fields stripped before any table drop; **live** row re-count and the data-loss gates). |
| `rowIdentity.js` | Registered `_ss_row_uuid` field, `ensureManagedSchema`, uuid<->pk resolution (via `pk_name`), locked-table gates. |
| `rowPayload.js` | Portable row <-> local row (FK ids <-> referenced-row uuid; File fields <-> a content-hash placeholder; user-mode FKs nulled with a warning). |
| `rows.js` | Data-mode storage (`_ss_table_modes`), manifest build (pre-pass schema), the apply engine (insert fixpoint -> converge/FK-fix, writing only changed rows -> managed prune that keeps rows user data still references), file materialization (fetch-by-hash + dedup; a failed fetch keeps the existing reference), and the source-side content-hash file index. |
| `layoutFiles.js` | Always-on content-addressed transfer of files referenced inside page layouts / view configs / the site logo: placeholderize (capture), resolvePlaceholders (apply; image-mime allow-list + hash-verify + dedup; min_role = most permissive of the referencing pages/views), indexLocalFiles (extends the source file index). |
| `rollback.js` | Pre-apply restore point (metadata pack + exactly the rows the gate can drop/change, in `_ss_rollback`) and `rollbackApply` (under the site lock, once per apply: mirror-mode re-sync of metadata/config -- an apply run under the same lease --, inverse renames, removal of inserted rows, restore of captured rows by pk, starter un-marking; file refs revert via retained old bytes). |
| `operations.js` | The admin operations, transport-free -- the routes AND the MCP tools are thin adapters over them, so the rules live here: settings options + validation, peer resolution (id / env_id / label) and pairing, the pull orchestration (`diffPluginsWithPeer` parity check -> signed `fetchPeerState` -> `buildPlan`), plan listing and standing (can apply / stale / can roll back / interrupted), apply and rollback (the site lock is taken below, in `apply.js` / `rollback.js`, so push-apply shares it; a lock refusal is an `OpError` with no rollback offer), the lock's state for the dashboard and `sitesync_status` (idle / running / interrupted), table listing and data modes, this site's read-only state summary. Actionable failures throw `OpError`. |
| `routes.js` | Route table + admin handlers (rendering over `operations.js`) + machine handlers (wrapped by `peerRoute`: auth, then a signed reply even on error). |
| `mcpTools.js` | `mcp_tools` for the saltcorn-mcp server: 15 `sitesync_*` tools (read / write / admin) as adapters over `operations.js`; plan views trimmed (names, counts, gates) or one entity at a time, through `ctx.redact`; a generated peer secret returned once. |
## Apply order (why it is what it is)
1. **Lock + claim + precondition** -- take the site lock (`siteLock.js`, below: a
second apply or a rollback started meanwhile, in any process, is refused), atomically
claim the plan (a double-submit is refused; the status it left is kept in
`claimed_from`), re-hash the target's current state, and refuse if it changed since
preview (optimistic concurrency -- airtight against other applies, which the lock
serializes). Row inserts don't change the metadata hash, so the delete engine
additionally re-counts rows live.
2. **Snapshot + restore point** -- Saltcorn's `Snapshot.take_if_changed` (metadata) plus
site-sync's own restore point (pre-apply metadata pack + raw rows of touched/dropped
tables) so the apply is one-click revertible.
3. **Renames** (before upsert, only those the admin ticked on the apply form; never on a
remote push-apply) -- `Table.rename` preserves data and makes `install_pack` *update*
the renamed table rather than create a fresh empty one.
4. **Upsert** -- a deep-cloned, pruned pack fed to `install_pack` (`name=undefined` to
skip the `installed_packs` append; plugins/event_logs/model_instances emptied;
config re-intersected with the allow-list; existing desired-named tags deleted so
the create path runs instead of the append-without-dedup path).
5. **Deletes** (after upsert) -- surgical per-entity prune in reverse-dependency order
(page_groups -> pages -> views -> triggers -> library -> tags -> models -> code pages
-> constraint removals + field drops on surviving tables, so no surviving FK column
points at a doomed table -> FK fields + triggers of every doomed table -> the tables),
each data-bearing drop gated by `on_delete` + the table name TYPED into the apply
form's allow-list (never pre-filled; empty on a remote push-apply) + the confirm tick,
with `users`/locked tables never dropped and no name-sweep.
6. **Reference rows + files** -- insert fixpoint (handles required cross-table FKs),
converge (managed) / seed-once (starter), managed prune by uuid; File fields are
materialized by fetching bytes from the source's `GET /api/file/:hash` (verified
against the placeholder hash, deduped so unchanged files are not re-fetched; the
response itself is signature-verified first).
7. **Refresh** -- one non-silent `getState().refresh()` so cluster workers converge.
Steps 3-6 each start by renewing the lease (`assertHeld`); see below.
## One apply at a time: the site lock
Saltcorn forks several workers even on SQLite, and several servers may share one
database, so the guard cannot live in process memory. `_ss_apply_lock` holds at most
one row per tenant schema: the holder (a random token), the operation and plan, and
`expires_ms`. Every time in it is the DATABASE's clock (`julianday('now')` on SQLite,
`clock_timestamp()` on Postgres), never a server's.
- **Acquire** is one atomic statement each way: `INSERT ... ON CONFLICT DO NOTHING
RETURNING` while the lock is free, else `UPDATE ... WHERE expires_ms < now RETURNING`
(take over a lapsed lease). Of any number of racing callers exactly one wins; the
rest get `LockBusyError` naming what runs. Nothing queues.
- **Lease**: 60 s, renewed every 15 s by a background timer while the operation runs,
so a holder that dies (crash, kill, restart) loses the lock within a minute.
- **Recovery**: every apply and rollback runs under the lock, so its holder knows any
plan still `applying` / `rolling_back` was abandoned. Acquiring therefore returns
each to `claimed_from` (recorded atomically by the claim: `SET status = $1,
claimed_from = status`), exactly what a failed apply or rollback restores -- an
interrupted apply is `pending` again (its restore point rolls it back), an
interrupted rollback can be retried. Plans claimed by 0.0.3 (no `claimed_from`)
fall back to `pending` / `applied`. The operation's warnings name each recovered
plan.
- **Reporting** is read-only: `lockState` + the in-flight plans give the dashboard and
`sitesync_status` their idle / running / interrupted state, and a plan's standing
says `interrupted` (judged by the status it will be recovered to) when it is in
flight with no live lease.
- **A stalled holder** (event loop blocked or database unreachable past the lease) can
lose the lock. `assertHeld` before each mutating phase and before the final status
write throws `LockLostError`; `finishClaimed` then writes no plan status (the new
holder recovered the plan and may be working on it).
- **Nesting**: a rollback's metadata step is an apply run under the rollback's lease
(`withSiteLock({ lease })`), not a second acquire.
## Storage lifecycle (backup-safe by construction)
- **Identity** -> `_sc_config` (restored before `onLoad`; no duplicate).
- **Must-survive runtime state** (peer secrets, table modes) -> **registered** Saltcorn
tables (`_ss_peers`, `_ss_table_modes`) that ride backup.
- **Regenerable state** (nonce store, persisted plans,
rollback restore points, the apply lock) -> **raw** portable DDL (`TEXT`/`INTEGER`/`BIGINT`,
schema-prefixed), invisible to backup but safe to lose.
- A managed table's `_ss_row_uuid` is a **registered field** (never a raw `ALTER`) so
it survives restore instead of rolling back the whole table.

69
index.js Normal file
View file

@ -0,0 +1,69 @@
// site-sync: Saltcorn plugin for one-way DESIRED-STATE synchronization.
//
// Snapshot a source site's current design state (via Saltcorn's own
// backup.create_pack_json), diff it against a target, preview a plan, and apply
// the difference so the target converges to the source. No edit log. User data is
// protected by per-table data modes; the users table is hard-locked.
const { PLUGIN_NAME, PLUGIN_VERSION } = require("./lib/constants");
const { createAllTables } = require("./lib/schema");
const { initEnvIfMissing } = require("./lib/env");
const { createDashboardWorkflow } = require("./lib/common/dashboardWorkflow");
const { ADMIN_URLS } = require("./lib/render");
const { routes } = require("./lib/routes");
const { API_PREFIX } = require("./lib/transport");
const mcpTools = require("./lib/mcpTools");
const log = (msg) => {
// eslint-disable-next-line no-console
console.log(`[${PLUGIN_NAME}] ${msg}`);
};
// Register the machine-API prefix in Saltcorn's disable_csrf_routes so peers can
// POST to it with the custom Content-Type. Belt-and-suspenders: the api routes
// ALSO carry noCsrf:true on the route object.
const ensureCsrfBypass = async () => {
try {
const { getState } = require("@saltcorn/data/db/state");
const current = getState().getConfig("disable_csrf_routes", "");
const entries = current.split(",").map((s) => s.trim()).filter(Boolean);
if (!entries.includes(API_PREFIX)) {
entries.push(API_PREFIX);
await getState().setConfig("disable_csrf_routes", entries.join(","));
}
} catch (e) {
// eslint-disable-next-line no-console
console.error(`[${PLUGIN_NAME}] failed to register csrf bypass:`, e);
}
};
const onLoad = async (cfg) => {
try {
await createAllTables();
const env = await initEnvIfMissing();
await ensureCsrfBypass();
log(`v${PLUGIN_VERSION} loaded env_id=${env.env_id}`);
} catch (err) {
// eslint-disable-next-line no-console
console.error(`[${PLUGIN_NAME}] onLoad failed:`, err);
throw err;
}
};
module.exports = {
sc_plugin_api_version: 1,
plugin_name: PLUGIN_NAME,
// The plugin gear (Settings -> Plugins) bounces straight to the dashboard.
configuration_workflow: () => createDashboardWorkflow({ name: PLUGIN_NAME, url: ADMIN_URLS.DASHBOARD }),
onLoad: onLoad,
// With configuration_workflow present, Saltcorn invokes capability keys as
// (cfg)=>value (state.ts withCfg), so routes must be a function, not the array.
routes: () => routes,
// Tools for the saltcorn-mcp server (its docs/pluginTools.md): an MCP client
// drives site-sync as an admin drives the UI (lib/mcpTools.js).
mcp_tools: () => mcpTools.tools()
};

276
lib/apply.js Normal file
View file

@ -0,0 +1,276 @@
// Apply a persisted plan so this instance converges to the source's desired state.
//
// The upsert (create/update) half is delegated to Saltcorn's own install_pack:
// its two-pass FK-aware ordering (bare tables -> refresh -> fields incl. FKs ->
// constraints -> views -> triggers -> pages -> page_groups) is exactly what we
// want. install_pack is ADDITIVE-only; the surgical delete half lives in
// deletes.js.
//
// Mandatory pre-processing before handing a pack to install_pack (install_pack
// mutates its argument and has non-idempotent appenders):
// * structuredClone the pack (install_pack sorts pack.tables, rewrites min_role,
// reassigns role.id in place -- must not corrupt the persisted plan).
// * name = undefined (skip the non-idempotent installed_packs append).
// * plugins = [] (site-sync never installs plugin config; parity is a pre-flight
// warning only, and plugin_pack carries the secret deploy_private_key).
// * event_logs / model_instances = [] (EventLog.create is append-only).
// * config re-intersected with SYNCABLE_CONFIG_KEYS (never trust the sender;
// install_pack's setConfig clobbers unconditionally).
// * tags are installed in a SECOND, tags-only install_pack call, and only the
// plan's created/updated tags: an updated tag is deleted just before it is
// re-created (install_pack's existing-tag path appends entries without
// dedup), and only after the main install succeeded, so a failure there can
// never leave the target without its tags; unchanged tags are not touched.
//
// Safety flow: take the site-wide apply lock (lib/siteLock.js: one apply or
// rollback per site at a time, across workers and servers), claim the plan
// atomically, re-check the precondition hash (refuse if the target drifted since
// preview -- optimistic concurrency), take a metadata snapshot + restore point,
// apply (checking before every mutating phase that the lock is still held), then
// one non-silent getState().refresh() so sibling cluster workers converge. On
// failure the plan's status is restored and the error carries `planId`, so the
// caller can offer the rollback of a partially applied plan.
const Table = require("@saltcorn/data/models/table");
const capture = require("./capture");
const planLib = require("./plan");
const siteLock = require("./siteLock");
const deletes = require("./deletes");
const rowsLib = require("./rows");
const layoutFiles = require("./layoutFiles");
const { getEnv } = require("./env");
const { PLAN_STATUS } = require("./constants");
// The statuses a plan can be applied from (an applied plan only with opts.force).
const APPLY_FROM = [PLAN_STATUS.PENDING, PLAN_STATUS.ROLLED_BACK];
// Build the install_pack input: a deep clone reduced to the upsert-safe surface.
// Tags are left out (see installTags).
const buildUpsertPack = (desired) => {
const pack = structuredClone(desired);
pack.plugins = [];
pack.event_logs = [];
pack.model_instances = [];
pack.tags = [];
pack.config = capture.pickSyncableConfig(pack.config);
return pack;
};
// Install the plan's created/updated tags with a tags-only install_pack call.
const installTags = async (plan) => {
const tags = plan.entities.tags.create.concat(plan.entities.tags.update.map((u) => u.to));
if (tags.length === 0) {
return;
}
const Tag = require("@saltcorn/data/models/tag");
for (const u of plan.entities.tags.update) {
const existing = await Tag.findOne({ name: u.key });
if (existing) {
await existing.delete();
}
}
const tagPack = { plugins: [], event_logs: [], model_instances: [] };
for (const k of capture.PACK_KINDS.filter((kind) => kind !== "code_pages")) {
tagPack[k] = [];
}
tagPack.tags = structuredClone(tags);
await require("@saltcorn/admin-models/models/pack").install_pack(tagPack, undefined, async () => {}, false);
};
// install_pack's View.update never changes a view's table binding, so re-point
// every updated view whose desired table differs from its current one.
const fixViewTables = async (plan) => {
const View = require("@saltcorn/data/models/view");
for (const u of plan.entities.views.update) {
const cur = View.findOne({ name: u.key });
const tbl = u.to.table ? Table.findOne({ name: u.to.table }) : null;
if (!cur || (u.to.table && !tbl)) {
continue;
}
const wantId = tbl ? tbl.id : null;
if ((cur.table_id || null) !== wantId) {
await View.update({ table_id: wantId }, cur.id);
}
}
};
// Build a content-hash file fetcher for the row/layout file layers: a signed,
// response-verified GET of the file by hash from the plan's source peer.
// `opts.fileFetcher` (a test override, e.g. an in-process resolver) wins. Returns
// null when there is no peer to fetch from (files are then skipped with a warning).
const buildFileFetcher = async (loaded, env, opts) => {
if (typeof opts.fileFetcher === "function") {
return opts.fileFetcher;
}
if (loaded.peer_id === null || loaded.peer_id === undefined) {
return null;
}
const peerRow = await require("./peers").findPeer(loaded.peer_id);
if (!peerRow) {
return null;
}
const { peerBytes, API_PATHS } = require("./transport");
return async (hash) => peerBytes(peerRow, env.env_id, API_PATHS.file(hash));
};
// Flatten every non-fatal problem of an apply into human-readable warnings.
const collectApplyWarnings = (parts) => {
const out = parts.notes.slice();
for (const f of parts.renames.failed) {
out.push(`rename "${f.from}" -> "${f.to}" not applied (${f.error}); handled as create + gated drop`);
}
for (const s of parts.deletes.skipped || []) {
out.push(`${s.kind} "${s.name}" not deleted: ${s.reason}`);
}
return out.concat(parts.fileWarnings, parts.rows.warnings || []);
};
// The apply proper, run while this caller holds the site lock (lease) and the
// plan's APPLYING claim.
const runApply = async (planId, loaded, opts, lease) => {
const env = await getEnv();
const notes = [];
// Optimistic concurrency: refuse if the target changed since the preview.
const before = await capture.buildDesiredState();
if (!opts.force && before.hash !== loaded.precondition_hash) {
throw new Error("target state changed since this plan was previewed; re-preview the plan before applying");
}
// Defense in depth: re-sanitize the desired pack on the mutating side.
const desired = capture.sanitizeDesired(loaded.desired);
const gateOpts = {
onDelete: env.on_delete,
prunable: opts.prunable || [],
confirmDataDrops: !!opts.confirmDataDrops
};
// Saltcorn's own metadata snapshot (best-effort; reported when unavailable).
let snapshotTaken = false;
try {
await require("@saltcorn/admin-models/models/snapshot").take_if_changed(`site-sync pre-apply ${planId}`);
snapshotTaken = true;
} catch (e) {
notes.push(`no Saltcorn snapshot taken: ${e.message}`);
}
// site-sync restore point: pre-apply metadata pack + the rows this apply can
// change or destroy, so it can be one-click reverted. Skipped when we are
// ourselves executing a rollback. A failure only disables rollback (reported).
if (!opts.skipRestorePoint) {
try {
await require("./rollback").saveRestorePoint(planId, before.pack, loaded.plan, gateOpts);
} catch (e) {
notes.push(`no restore point saved (rollback unavailable for this apply): ${e.message}`);
}
}
const doStructural = opts.applyDeletes !== false;
// ---- rename pre-pass (BEFORE install_pack) ----
await lease.assertHeld();
// Data-preserving Table.rename (confirmed renames only) so install_pack then
// UPDATES the renamed table instead of creating a fresh empty one.
let renameResult = { renamed: [], failed: [] };
if (doStructural) {
renameResult = await deletes.applyRenames(loaded.plan, { confirmRenames: opts.confirmRenames });
}
// A content-hash file fetcher for the source peer, reused by both the layout-file
// resolution below and the reference-row sync later.
const fileFetcher = await buildFileFetcher(loaded, env, opts);
const fileWarnings = [];
// ---- upsert half (create + update) ----
const upsertPack = buildUpsertPack(desired);
// Resolve files referenced inside page layouts / view configs / the site logo to
// LOCAL file values BEFORE install_pack writes those layouts (otherwise the
// placeholders would be persisted verbatim). resolvePlaceholders dedups against
// the target's own layout files, and fileIndexSeed (rollback) lets pre-apply
// files (on disk but no longer referenced) resolve without a fetch.
try {
await layoutFiles.resolvePlaceholders(upsertPack, {
fileFetcher,
fileIndexSeed: opts.fileIndexSeed,
warnings: fileWarnings
});
} catch (e) {
fileWarnings.push(`layout-file resolution failed: ${e.message}`);
}
await lease.assertHeld();
await require("@saltcorn/admin-models/models/pack").install_pack(upsertPack, undefined, async () => {}, false);
await fixViewTables(loaded.plan);
await installTags(loaded.plan);
// ---- delete half (mirror pruning) ----
let deleteResult = { applied: false, deleted: 0, skipped: [] };
if (doStructural) {
await lease.assertHeld();
deleteResult = await deletes.applyDeletes(loaded.plan, Object.assign({ renamed: renameResult.renamed }, gateOpts));
}
// ---- reference-row sync (AFTER install_pack so managed schema exists) ----
let rowResult = { applied: false, perTable: {}, warnings: [] };
const manifest = loaded.plan.sourceRows;
if (manifest && Object.keys(manifest).length > 0 && opts.applyRows !== false) {
await lease.assertHeld();
rowResult = await rowsLib.applyRowSync(manifest, { fileFetcher });
rowResult.applied = true;
}
// Cluster convergence: one non-silent refresh so sibling workers pick up the
// new catalog (install_pack refreshes with noSignal=true internally).
try {
const { getState } = require("@saltcorn/data/db/state");
await getState().refresh();
} catch (e) {
notes.push(`cluster refresh failed (other workers may serve stale metadata until restart): ${e.message}`);
}
return {
applied: true,
plan_id: planId,
snapshotTaken,
summary: loaded.plan.summary,
renames: renameResult,
deletes: deleteResult,
rows: rowResult,
warnings: collectApplyWarnings({ notes, renames: renameResult, deletes: deleteResult, fileWarnings, rows: rowResult })
};
};
// Apply a persisted plan under the site lock (siteLock.LockBusyError while another
// apply or rollback runs; opts.lease = run under a lock the caller holds already).
// The plan is claimed atomically first (pending or rolled_back -> applying; also
// applied with opts.force). On success the plan is `applied` and the warnings
// first name any abandoned plan acquiring the lock recovered; on failure its prior
// status is restored and the error is rethrown with `planId` set.
const applyPlan = async (planId, opts = {}) => {
return await siteLock.withSiteLock({ operation: "apply", planId, lease: opts.lease }, async (lease) => {
const loaded = await planLib.loadPlan(planId);
if (!loaded) {
throw new Error(`unknown plan ${planId}`);
}
const startable = APPLY_FROM.concat(opts.force ? [PLAN_STATUS.APPLIED] : []);
if (!(await planLib.claimPlanStatus(planId, startable, PLAN_STATUS.APPLYING))) {
throw new Error(`plan ${planId} cannot be applied: it is ${loaded.status} (re-preview to apply the source again)`);
}
const result = await siteLock.finishClaimed(lease, planId, { prior: loaded.status, done: PLAN_STATUS.APPLIED }, () => runApply(planId, loaded, opts, lease));
result.warnings = siteLock.recoveryWarnings(lease).concat(result.warnings);
return result;
});
};
module.exports = {
APPLY_FROM,
applyPlan
};

184
lib/capture.js Normal file
View file

@ -0,0 +1,184 @@
// Desired-state capture.
//
// A site's DESIRED STATE is Saltcorn's own snapshot-mode pack: metadata (tables
// schema, views, pages, page_groups, triggers, roles, library, tags, models,
// code_pages) plus a config map. We delegate the whole serialization to
// backup.create_pack_json(false, true) -- the exact object Saltcorn's Snapshot
// feature hashes -- then apply site-sync's security sanitization so the captured
// state is safe to hand to a peer AND safe to feed into install_pack on apply:
//
// * PLUGINS are reduced to PARITY info only: {name, source, location, version}.
// plugin_pack embeds `configuration` and the secret `deploy_private_key`;
// shipping those would leak secrets and could reconfigure a sibling plugin
// (e.g. idp redirect_uris). site-sync never pushes plugin config.
// * CONFIG is intersected with SYNCABLE_CONFIG_KEYS. install_pack's setConfig
// clobbers unconditionally, so this allow-list is the only guard against a
// compromised source overwriting security config (disable_csrf_routes, ...).
// * EVENT_LOGS and MODEL_INSTANCES are forced to [] (snapshot mode already drops
// them, and install_pack's EventLog.create is append-only / non-idempotent).
// * CODE_PAGES (server-side JavaScript) stay in the pack as a first-class kind:
// diff.js previews/prunes them like any other entity, so install_pack's
// code-page write is never invisible.
//
// The capture is hashed (sha256 of the sanitized-pack JSON) so a sync of an
// unchanged source short-circuits to an empty plan: both sides sanitize
// identically and create_pack_json emits a fixed key order, so equal states hash
// equal.
const { sha256Hex } = require("./common/hash");
const { SYNCABLE_CONFIG_KEYS, isInfraName, isLockedTable } = require("./constants");
// site-sync syncs APPLICATION design state, not other plugins' private
// bookkeeping (see isInfraName): a desired-state MIRROR must never propagate or
// (worse) prune infra tables or the per-row identity columns, or a source that
// lacks dev-deploy would drop the target's _dd_* tables and their peer secrets. The
// filter is applied symmetrically on the source's captured state and the target's
// current state, so infra entities never enter the diff at all.
//
// The users table (and any ROW_SYNC_LOCKED_TABLES) must NEVER be synced as a TABLE
// spec: install_pack would apply the pack's min_role_read/min_role_write/
// ownership_formula and create arbitrary fields on it, so a hostile peer could open
// the auth table's write ACL or add fields. We drop the table entry entirely so it
// never reaches install_pack. Views/pages/triggers that merely REFERENCE users are
// kept (login/signup views are legitimate design; the users table always exists on
// the target for them to bind to).
const isExcludedTable = (name) => isInfraName(name) || isLockedTable(name);
// Drop plugin/system-infra + locked tables, infra fields of the remaining tables,
// and views/triggers/models (and tag entries) bound to infra.
const excludeInfra = (pack) => {
pack.tables = pack.tables
.filter((t) => !isExcludedTable(t.name))
.map((t) => Object.assign({}, t, { fields: (t.fields || []).filter((f) => !isInfraName(f.name)) }));
pack.views = pack.views.filter((v) => !isInfraName(v.name) && !isInfraName(v.table));
const infraTriggers = new Set(pack.triggers.filter((t) => isInfraName(t.table_name)).map((t) => t.name));
pack.triggers = pack.triggers.filter((t) => !infraTriggers.has(t.name));
pack.models = pack.models.filter((m) => !isInfraName(m.table_name));
pack.tags = pack.tags.map((tag) => {
const entries = (tag.entries || []).filter((e) =>
!isInfraName(e.table_name) && !isInfraName(e.view_name) && !isInfraName(e.page_name) && !infraTriggers.has(e.trigger_name));
return Object.assign({}, tag, { entries });
});
return pack;
};
// Keep only parity-relevant, non-secret plugin fields.
const sanitizePluginEntry = (pl) => {
return {
name: pl.name,
source: pl.source,
location: pl.location,
version: pl.version
};
};
// Restrict a config map to the syncable allow-list. Missing keys are simply
// absent (so the diff treats them as "not managed", never as a delete).
const pickSyncableConfig = (config) => {
const out = {};
if (!config || typeof config !== "object") {
return out;
}
for (const key of SYNCABLE_CONFIG_KEYS) {
if (Object.prototype.hasOwnProperty.call(config, key)) {
out[key] = config[key];
}
}
return out;
};
// Every array-valued entity kind of a sanitized pack (always present, maybe empty).
const PACK_KINDS = ["tables", "views", "pages", "page_groups", "triggers", "roles", "library", "tags", "models", "code_pages"];
// Canonicalize a pack's array order before hashing so the hash is order-INSENSITIVE.
// create_pack_json builds several kinds via find() with no ORDER BY (roles,
// library, tags, models) and getFields() (DB order); an unrelated apply can reorder
// rows on Postgres, which would otherwise flip the hash and cause a spurious
// precondition-drift refusal. Sorting by the stable key (roles by id, everything
// else by name; fields/constraints within a table) removes that flakiness. Returns
// a deep clone -- the original pack is untouched.
const canonicalizeForHash = (pack) => {
const clone = JSON.parse(JSON.stringify(pack));
const byName = (a, b) => String(a && a.name).localeCompare(String(b && b.name));
if (Array.isArray(clone.roles)) {
clone.roles.sort((a, b) => (a.id || 0) - (b.id || 0));
}
for (const k of PACK_KINDS.filter((kind) => kind !== "roles")) {
if (Array.isArray(clone[k])) {
clone[k].sort(byName);
}
}
for (const t of (clone.tables || [])) {
if (Array.isArray(t.fields)) {
t.fields.sort(byName);
}
if (Array.isArray(t.constraints)) {
t.constraints.sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b)));
}
}
return clone;
};
// Stable content hash of a sanitized pack (order-insensitive; see above).
const hashPack = (pack) => {
return sha256Hex(Buffer.from(JSON.stringify(canonicalizeForHash(pack)), "utf8"));
};
// Apply site-sync's security sanitization to a raw pack. Used BOTH to build this
// instance's desired state AND -- critically -- to sanitize a pack RECEIVED from a
// peer before diffing/applying it: the target must never trust the sender's
// sanitization, or a hostile peer could ship an infra table (_dd_peers) or a
// non-allow-listed config key and have it created/clobbered on apply.
const sanitizeDesired = (raw) => {
const pack = Object.assign({}, raw || {});
pack.plugins = (pack.plugins || []).map(sanitizePluginEntry);
pack.config = pickSyncableConfig(pack.config);
pack.event_logs = [];
pack.model_instances = [];
for (const k of PACK_KINDS) {
if (!Array.isArray(pack[k])) {
pack[k] = [];
}
}
excludeInfra(pack);
return pack;
};
// Produce this instance's sanitized desired-state pack. Returns { pack, hash }.
//
// The pack is normalized to PLAIN JSON (JSON round-trip): create_pack_json returns
// class instances for some kinds (e.g. Role objects for `roles`), but a peer's
// desired pack always arrives as plain objects over the JSON transport. lodash
// isEqual treats an instance and a plain object with identical properties as
// UNEQUAL, which would make every such entity falsely diff as "changed" on every
// real sync. Round-tripping the current pack the same way the transport does makes
// both sides structurally identical. (Hash is unaffected -- it is already computed
// over JSON.stringify.)
const buildDesiredState = async () => {
const backup = require("@saltcorn/admin-models/models/backup");
const raw = await backup.create_pack_json(false, true);
const pack = JSON.parse(JSON.stringify(sanitizeDesired(raw)));
// Replace files referenced inside page layouts / view configs / the site logo
// with content-addressed placeholders (symmetric on both sides, so the hash and
// diff are content-based). Their bytes are transferred on apply via /api/file.
await require("./layoutFiles").placeholderize(pack);
return { pack, hash: hashPack(pack) };
};
module.exports = {
buildDesiredState,
sanitizeDesired,
hashPack,
pickSyncableConfig,
PACK_KINDS
};

44
lib/common/adminGate.js Normal file
View file

@ -0,0 +1,44 @@
// Who the request's Saltcorn session user is, for plugin routes.
//
// Mirrors upstream isAdmin / loggedIn: on Postgres every tenant shares one
// session table and deserializeUser returns the stored user verbatim, so a
// session minted by ANOTHER tenant must count as logged out here (otherwise
// user #N or the admin of tenant A acts as user #N / admin of tenant B).
const { HTTP_STATUS } = require("./httpStatus");
// Saltcorn's built-in admin role.
const ADMIN_ROLE_ID = 1;
// req.user when it belongs to the current tenant, else null.
const tenantUser = (req) => {
const db = require("@saltcorn/data/db");
const u = req && req.user;
return u && u.tenant === db.getTenantSchema() ? u : null;
};
const isAdmin = (req) => {
const u = tenantUser(req);
return !!u && u.role_id === ADMIN_ROLE_ID;
};
// Gate for a server-rendered admin page or form POST: true for this tenant's
// admin, else a plain 403 is sent and false returned.
const requireAdmin = (req, res) => {
if (isAdmin(req)) {
return true;
}
res.status(HTTP_STATUS.FORBIDDEN).type("text/plain").send("admin only");
return false;
};
module.exports = {
ADMIN_ROLE_ID,
tenantUser,
isAdmin,
requireAdmin
};

46
lib/common/adminHtml.js Normal file
View file

@ -0,0 +1,46 @@
// Small HTML pieces of the plugin's server-rendered admin pages.
const { escapeHtml } = require("./htmlEscape");
// The hidden CSRF input for a form POST (Saltcorn's csurf token).
const csrfField = (req) => {
const token = req.csrfToken ? req.csrfToken() : "";
return `<input type="hidden" name="_csrf" value="${escapeHtml(token)}">`;
};
// Bootstrap nav-pills (links = [[href, label], ...]) with the current page marked
// active. The active link is the one whose href is the LONGEST boundary-safe
// prefix of the request path, so a sub-page still highlights its parent tab.
const navPills = (req, links) => {
const cur = String((req && (req.originalUrl || req.path)) || "").split("?")[0].replace(/\/+$/, "");
let activeHref = "";
for (const [href] of links) {
const h = href.replace(/\/+$/, "");
if ((cur === h || cur.startsWith(h + "/")) && h.length > activeHref.length) {
activeHref = h;
}
}
const items = links.map(([href, label]) => {
const on = href.replace(/\/+$/, "") === activeHref;
return ` <li class="nav-item"><a class="nav-link${on ? " active" : ""}"${on ? ' aria-current="page"' : ""} href="${escapeHtml(href)}">${escapeHtml(label)}</a></li>`;
}).join("\n");
return `<ul class="nav nav-pills mb-3">\n${items}\n</ul>`;
};
// Flash a message and redirect (Post/Redirect/Get). Saltcorn renders flash text
// as raw HTML and messages can echo admin input, so it is escaped here; nothing
// is reflected from the query string.
const flashRedirect = (req, res, url, type, message) => {
req.flash(type, escapeHtml(message));
res.redirect(url);
};
module.exports = {
csrfField,
navPills,
flashRedirect
};

124
lib/common/aesGcm.js Normal file
View file

@ -0,0 +1,124 @@
// AES-256-GCM at-rest sealing, keyed by sessionKey.
//
// createSealer(keySpec) binds one session-secret-derived key (see
// sessionKey.deriveKey) and offers two storage formats:
// struct -- { ciphertext, iv, tag } as Buffers (seal/open), or as hex strings
// for DB columns (sealHex/openHex: Saltcorn's sqlite layer
// JSON-stringifies values, so raw Buffers must not be stored)
// string -- "scs1:" + base64(iv | tag | ciphertext): one opaque value for a
// plugin configuration field (sealString/openString)
// Both pin the GCM tag to the full 16 bytes (authTagLength): without it Node
// accepts a truncated tag (down to 4 bytes), which weakens forgery resistance.
//
// Threat model: protects DB-only exposure (dumps, backups, peer sync, an admin
// reading config). It does NOT protect against an attacker who also holds the
// host env / config file -- they hold the session secret.
const crypto = require("crypto");
const { deriveKey } = require("./sessionKey");
const GCM_ALGORITHM = "aes-256-gcm";
const IV_BYTES = 12;
const TAG_BYTES = 16;
// saltcorn-secret, string format v1
const STRING_PREFIX = "scs1:";
const toBuffer = (plaintext) => {
return Buffer.isBuffer(plaintext) ? plaintext : Buffer.from(String(plaintext), "utf8");
};
const seal = (key, plaintext) => {
const iv = crypto.randomBytes(IV_BYTES);
const cipher = crypto.createCipheriv(GCM_ALGORITHM, key, iv, { authTagLength: TAG_BYTES });
const ciphertext = Buffer.concat([cipher.update(toBuffer(plaintext)), cipher.final()]);
return { ciphertext: ciphertext, iv: iv, tag: cipher.getAuthTag() };
};
const open = (key, sealed) => {
const decipher = crypto.createDecipheriv(GCM_ALGORITHM, key, sealed.iv, { authTagLength: TAG_BYTES });
decipher.setAuthTag(sealed.tag);
return Buffer.concat([decipher.update(sealed.ciphertext), decipher.final()]);
};
const sealHex = (key, plaintext) => {
const sealed = seal(key, plaintext);
return {
ciphertext: sealed.ciphertext.toString("hex"),
iv: sealed.iv.toString("hex"),
tag: sealed.tag.toString("hex")
};
};
const openHex = (key, sealedHex) => {
return open(key, {
ciphertext: Buffer.from(sealedHex.ciphertext, "hex"),
iv: Buffer.from(sealedHex.iv, "hex"),
tag: Buffer.from(sealedHex.tag, "hex")
});
};
const isSealedString = (value) => {
return typeof value === "string" && value.startsWith(STRING_PREFIX);
};
const sealString = (key, plaintext) => {
const sealed = seal(key, plaintext);
return STRING_PREFIX + Buffer.concat([sealed.iv, sealed.tag, sealed.ciphertext]).toString("base64");
};
// The plaintext (utf8), or null when value is not a sealed string or cannot be
// opened (wrong/rotated key, tampering, truncation, corruption).
const openString = (key, value) => {
if (!isSealedString(value)) {
return null;
}
try {
const raw = Buffer.from(value.slice(STRING_PREFIX.length), "base64");
return open(key, {
iv: raw.subarray(0, IV_BYTES),
tag: raw.subarray(IV_BYTES, IV_BYTES + TAG_BYTES),
ciphertext: raw.subarray(IV_BYTES + TAG_BYTES)
}).toString("utf8");
} catch (e) {
return null;
}
};
// keySpec = { owner, salt, info } (see sessionKey.deriveKey). The key is derived
// on each use (cached there), so a missing session secret surfaces at use time.
const createSealer = (keySpec) => {
const key = () => deriveKey(keySpec);
return {
seal: (plaintext) => seal(key(), plaintext),
open: (sealed) => open(key(), sealed),
sealHex: (plaintext) => sealHex(key(), plaintext),
openHex: (sealedHex) => openHex(key(), sealedHex),
sealString: (plaintext) => sealString(key(), plaintext),
// null also when no acceptable session secret is configured: the caller
// treats every null as "re-enter the secret".
openString: (value) => {
try {
return openString(key(), value);
} catch (e) {
return null;
}
}
};
};
module.exports = {
STRING_PREFIX,
isSealedString,
createSealer
};

50
lib/common/cappedBody.js Normal file
View file

@ -0,0 +1,50 @@
// Byte-capped body readers: never buffer more than a caller-set limit from a
// peer or remote API.
// A fetch Response body as a Buffer, refusing more than maxBytes: by declared
// Content-Length first, then by running count, so a lying or chunked response is
// cut off (leaving the for-await cancels the stream) rather than buffered.
const readCappedResponse = async (res, maxBytes) => {
const declared = Number(res.headers.get("content-length"));
if (Number.isFinite(declared) && declared > maxBytes) {
throw new Error(`response too large (${declared} bytes exceeds ${maxBytes})`);
}
if (!res.body) {
return Buffer.alloc(0);
}
const chunks = [];
let total = 0;
for await (const chunk of res.body) {
total += chunk.length;
if (total > maxBytes) {
throw new Error(`response too large (exceeds ${maxBytes} bytes)`);
}
chunks.push(Buffer.from(chunk));
}
return Buffer.concat(chunks);
};
// An incoming request stream as a Buffer, or null once it exceeds maxBytes.
// Leaving the for-await early destroys the stream, so an oversized upload is cut
// off rather than buffered.
const readCappedRequest = async (req, maxBytes) => {
const chunks = [];
let total = 0;
for await (const chunk of req) {
const buf = typeof chunk === "string" ? Buffer.from(chunk) : chunk;
total += buf.length;
if (total > maxBytes) {
return null;
}
chunks.push(buf);
}
return Buffer.concat(chunks);
};
module.exports = {
readCappedResponse,
readCappedRequest
};

48
lib/common/configStore.js Normal file
View file

@ -0,0 +1,48 @@
// Durable per-tenant key/value storage in Saltcorn's _sc_config, for plugin state
// GENERATED during onLoad (an env identity, signing keys, a SAML cert).
//
// Why _sc_config and not a registered table or the plugin config blob:
// - It survives backup: backup_config dumps every non-fixed _sc_config key,
// and restore_config restores them BEFORE install_pack runs the plugin's
// onLoad -- so the restored value is already present when onLoad reads it
// (no onLoad-generates-then-restore-imports duplicate, which a registered
// table suffers because restore_tables runs AFTER onLoad).
// - It does NOT write the plugin's own _sc_plugins row (Plugin.upsert during
// onLoad cascades plugin re-loading and duplicates rows). setConfig writes
// _sc_config only.
//
// Reads go DIRECT to _sc_config: getState().getConfig only surfaces keys in
// Saltcorn's known configTypes schema, so a custom plugin key is invisible
// through it even though it is stored and backed up. Writes go through setConfig
// (correct jsonb/text encoding on both backends; value wrapped as {v: ...}).
const readKey = async (key) => {
const db = require("@saltcorn/data/db");
const row = await db.selectMaybeOne("_sc_config", { key: key });
if (!row || row.value === null || row.value === undefined) {
return undefined;
}
let v = row.value;
if (typeof v === "string") {
try {
v = JSON.parse(v);
} catch (e) {
return undefined;
}
}
// Saltcorn wraps config values as { v: <value> }.
return v && typeof v === "object" && "v" in v ? v.v : v;
};
const writeKey = async (key, value) => {
const { getState } = require("@saltcorn/data/db/state");
await getState().setConfig(key, value);
};
module.exports = {
readKey,
writeKey
};

View file

@ -0,0 +1,45 @@
// A configuration_workflow that bounces straight to the plugin's own dashboard.
//
// The plugin "gear" on Settings -> Plugins always opens /plugins/configure/<name>,
// which renders the plugin's configuration workflow form (the core configure
// route has no redirect hook -- server/routes/plugins.js). Plugins whose real
// settings live in their own dashboard use this single step to get there with NO
// extra click.
//
// NOTE: blurb MUST be an ARRAY -- the array path is rendered raw (form.ts join),
// whereas a string blurb is run through the text() XSS whitelist, which strips
// <script>/<meta>. CSP allows 'unsafe-inline', so the inline script redirect is
// the primary path; the <noscript> meta-refresh and the visible button are
// fallbacks (the link is what shows if script/meta are ever stripped).
const { escapeHtml } = require("./htmlEscape");
// name = the plugin name shown on the step and the button; url = its dashboard.
const createDashboardWorkflow = ({ name, url }) => {
const Workflow = require("@saltcorn/data/models/workflow");
const Form = require("@saltcorn/data/models/form");
const href = escapeHtml(url);
const label = escapeHtml(name);
return new Workflow({
steps: [
{
name: name,
form: async () =>
new Form({
blurb: [
`<script>window.location.replace(${JSON.stringify(url)});</script>`,
`<noscript><meta http-equiv="refresh" content="0; url=${href}"></noscript>`,
`<p>Opening the ${label} dashboard&hellip; <a class="btn btn-primary" role="button" href="${href}">Open the ${label} dashboard</a></p>`
],
fields: []
})
}
]
});
};
module.exports = {
createDashboardWorkflow
};

52
lib/common/fileStore.js Normal file
View file

@ -0,0 +1,52 @@
// File helpers: content hashing and tenant-store
// path containment.
const crypto = require("crypto");
const fs = require("fs");
const path = require("path");
const { sha256Hex } = require("./hash");
// SHA-256 of a file's contents as lowercase hex, streamed (constant memory).
const sha256File = (absPath) => {
return new Promise((resolve, reject) => {
const hash = crypto.createHash("sha256");
const stream = fs.createReadStream(absPath);
stream.on("data", (chunk) => hash.update(chunk));
stream.on("end", () => resolve(hash.digest("hex")));
stream.on("error", reject);
});
};
// Content hash of a Saltcorn File model instance, streamed off disk. S3-backed
// files have no local path, so fall back to a buffered download there.
const hashFileContents = async (file) => {
if (file.s3_store) {
return sha256Hex(await file.get_contents());
}
return await sha256File(file.location);
};
// Absolute path on this instance of a tenant-relative file path.
// SECURITY: the value can originate from a (HMAC-authenticated) peer payload, so
// it is attacker-influenceable. Resolve it and assert it stays strictly within
// this tenant's file_store root -- otherwise a "../../.." (or absolute) path
// would let a peer read/write arbitrary files (e.g. /etc/cron.d, app JS -> RCE)
// or another tenant's. The root itself is not a file.
const toAbsolutePath = (db, relPath) => {
const root = path.resolve(db.connectObj.file_store, db.getTenantSchema());
const abs = path.resolve(root, String(relPath || ""));
if (!abs.startsWith(root + path.sep)) {
throw new Error(`refusing file path outside tenant store: ${relPath}`);
}
return abs;
};
module.exports = {
hashFileContents,
toAbsolutePath
};

28
lib/common/hash.js Normal file
View file

@ -0,0 +1,28 @@
// Content hashing and constant-time comparison.
const crypto = require("crypto");
// SHA-256 of a Buffer or string (utf8) as lowercase hex; null/undefined hash as "".
const sha256Hex = (data) => {
const buf = Buffer.isBuffer(data) ? data : Buffer.from(data || "");
return crypto.createHash("sha256").update(buf).digest("hex");
};
// Timing-safe equality of two Buffers or strings (utf8): the comparison time
// does not depend on where they first differ (only on the length).
const constantTimeEqual = (a, b) => {
const ba = Buffer.isBuffer(a) ? a : Buffer.from(String(a));
const bb = Buffer.isBuffer(b) ? b : Buffer.from(String(b));
if (ba.length !== bb.length) {
return false;
}
return crypto.timingSafeEqual(ba, bb);
};
module.exports = {
sha256Hex,
constantTimeEqual
};

24
lib/common/htmlEscape.js Normal file
View file

@ -0,0 +1,24 @@
// HTML (and XML) text/attribute escaping.
const HTML_ESCAPES = {
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
"\"": "&quot;",
"'": "&#39;"
};
const HTML_SPECIALS = /[&<>"']/g;
// value as text safe inside an element or a quoted attribute; null/undefined -> "".
const escapeHtml = (value) => {
if (value === null || value === undefined) {
return "";
}
return String(value).replace(HTML_SPECIALS, (ch) => HTML_ESCAPES[ch]);
};
module.exports = {
escapeHtml
};

24
lib/common/httpStatus.js Normal file
View file

@ -0,0 +1,24 @@
// HTTP statuses the plugin's routes answer with.
const HTTP_STATUS = {
OK: 200,
ACCEPTED: 202,
NOT_MODIFIED: 304,
BAD_REQUEST: 400,
UNAUTHORIZED: 401,
FORBIDDEN: 403,
NOT_FOUND: 404,
METHOD_NOT_ALLOWED: 405,
CONFLICT: 409,
PAYLOAD_TOO_LARGE: 413,
UNSUPPORTED_MEDIA_TYPE: 415,
UNPROCESSABLE_ENTITY: 422,
TOO_MANY_REQUESTS: 429,
SERVER_ERROR: 500,
SERVICE_UNAVAILABLE: 503
};
module.exports = {
HTTP_STATUS
};

105
lib/common/peerClient.js Normal file
View file

@ -0,0 +1,105 @@
// Outbound signed peer requests.
//
// Every request is signed with the pairing's shared secret (peerProtocol
// buildCanonical; the body is JSON-serialized once and used for both the HTTP
// body and the signature). Every response must carry a signature over this
// request's nonce, the status and the exact body bytes; it is verified BEFORE the
// body is parsed or handed to a caller -- the receiving side mutates itself from
// what a pull returns, so an unauthenticated response (an on-path attacker on an
// http:// link) must never be acted on. Bodies are read through a byte cap and
// every exchange is bounded by a timeout.
const {
buildCanonical,
normalizeHost,
sign,
verifyResponseSignature,
randomNonce
} = require("./peerProtocol");
const { readCappedResponse } = require("./cappedBody");
// Unverified text from an unsigned response is quoted only this long.
const UNVERIFIED_DETAIL_BYTES = 200;
// The parsed JSON body of a verified exchange; a body that is empty or not JSON
// is a protocol error.
const parseJson = (result) => {
try {
return JSON.parse(result.bytes.toString("utf8"));
} catch (e) {
throw new Error(`peer ${result.url} returned status ${result.status} with a non-JSON body (${result.contentType}, ${result.bytes.length} bytes)`);
}
};
// opts = { headers: { ENV_ID, TIMESTAMP, NONCE, SIGNATURE, RESPONSE_SIGNATURE }
// names, contentType: the signed request body's Content-Type }
const createPeerClient = ({ headers, contentType }) => {
// The one signed round trip. expectedEnvId (optional) must equal the
// responder's ENV_ID header. Returns { status, ok, bytes, contentType,
// headers, url } only after the response verified; throws otherwise
// (including for an unsigned error reply such as a 401 from the peer's auth
// layer, whose text is reported as unverified).
const signedRequest = async ({ baseUrl, method, path, body, sourceEnvId, secret, requireTls, maxBytes, timeoutMs, expectedEnvId }) => {
const base = new URL(baseUrl);
if (requireTls && base.protocol !== "https:") {
throw new Error(`peer requires TLS but base_url is not https: ${baseUrl}`);
}
const bodyStr = body ? JSON.stringify(body) : "";
const timestamp = String(Date.now());
const nonce = randomNonce().toString("hex");
const canonical = buildCanonical({
timestamp: timestamp,
nonce: nonce,
method: method,
path: path,
targetHost: normalizeHost(base.host),
body: bodyStr
});
const requestHeaders = {
[headers.ENV_ID]: sourceEnvId,
[headers.TIMESTAMP]: timestamp,
[headers.NONCE]: nonce,
[headers.SIGNATURE]: sign(secret, canonical)
};
const init = { method: method, headers: requestHeaders, signal: AbortSignal.timeout(timeoutMs) };
if (bodyStr) {
requestHeaders["Content-Type"] = contentType;
init.body = bodyStr;
}
const url = baseUrl.replace(/\/+$/, "") + path;
const res = await fetch(url, init);
let bytes;
try {
bytes = await readCappedResponse(res, maxBytes);
} catch (e) {
throw new Error(`peer ${url}: ${e.message}`);
}
const responseType = res.headers.get("content-type") || "(none)";
const provided = res.headers.get(headers.RESPONSE_SIGNATURE);
if (!verifyResponseSignature(secret, { nonce: nonce, status: res.status, body: bytes }, provided)) {
const detail = bytes.toString("utf8", 0, UNVERIFIED_DETAIL_BYTES);
throw new Error(`peer ${url} returned status ${res.status} (${responseType}) without a valid response signature; unverified detail: ${detail || "(empty)"}`);
}
if (expectedEnvId) {
const responder = res.headers.get(headers.ENV_ID);
if (responder !== expectedEnvId) {
throw new Error(`peer ${url} answered as env_id ${responder || "(none)"}, expected ${expectedEnvId}`);
}
}
return { status: res.status, ok: res.ok, bytes: bytes, contentType: responseType, headers: res.headers, url: url };
};
return {
signedRequest
};
};
module.exports = {
createPeerClient,
parseJson
};

129
lib/common/peerProtocol.js Normal file
View file

@ -0,0 +1,129 @@
// The signed peer-to-peer wire protocol: HMAC-SHA256 over a canonical string,
// both ways.
//
// request canonical: timestamp, nonce, METHOD, path, normalized target host,
// sha256hex(raw body bytes) -- joined by "\n"
// response canonical: "resp", the REQUEST's nonce, status, sha256hex(body
// bytes) -- binds a response to exactly one request (no
// replay of an old response); the "resp" label means a
// response signature can never pass as a request one
//
// Header names and the request Content-Type are per plugin (peerServer /
// peerClient take them); the canonical contains no header names.
const crypto = require("crypto");
const { sha256Hex, constantTimeEqual } = require("./hash");
const HMAC_ALGORITHM = "sha256";
const SECRET_BYTES = 32;
const NONCE_BYTES = 16;
const SKEW_TOLERANCE_MS = 5 * 60 * 1000;
// The acceptance window is 2x SKEW wide (timestampWithinSkew accepts ts in
// [now-SKEW, now+SKEW]), and a nonce's seen time is the RECEIVE time, which can
// trail ts by up to SKEW when a peer's clock leads -- so a nonce must be retained
// for 2x SKEW past receipt to cover the whole window a captured request stays
// replayable (pruning at 1x SKEW reopened that gap).
const NONCE_RETENTION_MS = 2 * SKEW_TOLERANCE_MS;
const RESPONSE_LABEL = "resp";
const randomSecret = () => {
return crypto.randomBytes(SECRET_BYTES);
};
const randomNonce = () => {
return crypto.randomBytes(NONCE_BYTES);
};
const buildCanonical = ({ timestamp, nonce, method, path, targetHost, body }) => {
return [
String(timestamp),
String(nonce),
String(method).toUpperCase(),
String(path),
String(targetHost || ""),
sha256Hex(body || "")
].join("\n");
};
const buildResponseCanonical = ({ nonce, status, body }) => {
return [
RESPONSE_LABEL,
String(nonce),
String(status),
sha256Hex(body)
].join("\n");
};
// Normalize a host[:port] so the signing side (outbound, the peer base_url's
// host) and the verifying side (inbound, Express req.host -- the trust-proxy-aware
// host Saltcorn routes the tenant by) produce byte-identical canonicals:
// lowercase, trim, and drop the default port (clients omit :80/:443 from the Host
// header). Binding it into the canonical stops a request signed for one tenant
// being replayed against another tenant on the same multi-tenant server.
const normalizeHost = (h) => {
return String(h || "").trim().toLowerCase().replace(/:(?:80|443)$/, "");
};
const sign = (secret, canonical) => {
return crypto.createHmac(HMAC_ALGORITHM, secret).update(canonical, "utf8").digest("hex");
};
// Timing-safe comparison of providedHex against the expected signature.
const verifySignature = (secret, canonical, providedHex) => {
if (!providedHex || typeof providedHex !== "string") {
return false;
}
let expectedBuf;
let providedBuf;
try {
expectedBuf = Buffer.from(sign(secret, canonical), "hex");
providedBuf = Buffer.from(providedHex, "hex");
} catch (e) {
return false;
}
return constantTimeEqual(expectedBuf, providedBuf);
};
// response = { nonce (of the request), status, body (Buffer) }
const signResponse = (secret, response) => {
return sign(secret, buildResponseCanonical(response));
};
const verifyResponseSignature = (secret, response, providedHex) => {
return verifySignature(secret, buildResponseCanonical(response), providedHex);
};
const timestampWithinSkew = (tsString) => {
const ts = Number(tsString);
if (!Number.isFinite(ts)) {
return false;
}
return Math.abs(Date.now() - ts) <= SKEW_TOLERANCE_MS;
};
module.exports = {
SKEW_TOLERANCE_MS,
NONCE_RETENTION_MS,
randomSecret,
randomNonce,
buildCanonical,
buildResponseCanonical,
normalizeHost,
sign,
verifySignature,
signResponse,
verifyResponseSignature,
timestampWithinSkew
};

223
lib/common/peerServer.js Normal file
View file

@ -0,0 +1,223 @@
// Inbound HMAC-signed peer requests and signed responses.
//
// Required request headers (names per plugin, opts.headers):
// ENV_ID caller's env UUID (looked up in the plugin's peers table)
// TIMESTAMP ms-since-epoch; rejected outside the skew window
// NONCE random per-request bytes; rejected if reused within the retention
// window (replay protection, see recordNonce)
// SIGNATURE hex HMAC-SHA256 over the request canonical (peerProtocol)
//
// Peer requests use a plugin-specific Content-Type (application/vnd.<plugin>+json)
// so Saltcorn's express.json() middleware leaves the stream untouched: the exact
// raw bytes are read here -- capped BEFORE the signature check -- and used in the
// HMAC (no JSON-canonical fragility, no re-serialization assumptions).
//
// Host and protocol come from Express (req.host -- Express 5 keeps the port -- and
// req.protocol), which honour the app's "trust proxy" setting: the same host
// Saltcorn routes the tenant by. Raw X-Forwarded-* headers are never read: with
// trust proxy off (Saltcorn's default) they are client-controlled.
const {
NONCE_RETENTION_MS,
buildCanonical,
normalizeHost,
verifySignature,
signResponse,
timestampWithinSkew
} = require("./peerProtocol");
const { readCappedRequest } = require("./cappedBody");
const { HTTP_STATUS } = require("./httpStatus");
const JSON_CONTENT_TYPE = "application/json";
// Where requirePeerAuth leaves what sendSigned needs to sign the response.
const PEER_CONTEXT = Symbol("peerContext");
// Create the raw replay-protection store `table`: every accepted peer request's
// (env_id, nonce) is recorded so a captured signed request cannot be replayed
// within the window. Raw + regenerable (losing it only reopens the small replay
// window). Idempotent.
const createNonceStore = async (table) => {
const db = require("@saltcorn/data/db");
const schema = db.getTenantSchemaPrefix();
await db.query(`
CREATE TABLE IF NOT EXISTS ${schema}${table} (
env_id TEXT NOT NULL,
nonce TEXT NOT NULL,
seen_ms BIGINT NOT NULL,
PRIMARY KEY (env_id, nonce)
)
`);
await db.query(`CREATE INDEX IF NOT EXISTS ${table}_seen ON ${schema}${table} (seen_ms)`);
};
// opts = {
// headers { ENV_ID, TIMESTAMP, NONCE, SIGNATURE, RESPONSE_SIGNATURE } names
// nonceTable the createNonceStore table of seen request nonces
// maxBodyBytes largest request body accepted (413 above it)
// peers { findPeerByEnvId, peerSecret, touchPeerLastSeen } (peerStore)
// getEnv async () -> this instance's env ({ env_id, require_tls }) or null
// }
const createPeerAuth = ({ headers, nonceTable, maxBodyBytes, peers, getEnv }) => {
const requiredHeaders = [headers.ENV_ID, headers.TIMESTAMP, headers.NONCE, headers.SIGNATURE].map((h) => h.toLowerCase());
// Record (env_id, nonce); false if it was already seen (replay) or on a store
// error (fail closed -- better to reject than to allow a replay). Prunes
// entries past the retention window first.
const recordNonce = async (envId, nonce) => {
const db = require("@saltcorn/data/db");
const schema = db.getTenantSchemaPrefix();
const now = Date.now();
try {
await db.query(`DELETE FROM ${schema}${nonceTable} WHERE seen_ms < $1`, [now - NONCE_RETENTION_MS]);
await db.query(`INSERT INTO ${schema}${nonceTable} (env_id, nonce, seen_ms) VALUES ($1, $2, $3)`, [envId, nonce, now]);
return true;
} catch (e) {
return false;
}
};
// Unsigned rejection (the caller is not authenticated yet); always null.
const reject = (res, status, message) => {
res.status(status).json({ error: message });
return null;
};
const rejectTooLarge = (res) => {
res.set("Connection", "close");
return reject(res, HTTP_STATUS.PAYLOAD_TOO_LARGE, `request body exceeds ${maxBodyBytes} bytes`);
};
// Send an authenticated peer's response with the response signature over the
// exact bytes and this env's id. Only valid after requirePeerAuth succeeded.
const sendSigned = (req, res, status, bytes, contentType) => {
const ctx = req[PEER_CONTEXT];
res.status(status);
res.set({
"Content-Type": contentType,
[headers.ENV_ID]: ctx.myEnvId || "",
[headers.RESPONSE_SIGNATURE]: signResponse(ctx.secret, { nonce: ctx.nonce, status: status, body: bytes })
});
res.end(bytes);
};
const sendSignedJson = (req, res, status, obj) => {
sendSigned(req, res, status, Buffer.from(JSON.stringify(obj), "utf8"), JSON_CONTENT_TYPE);
};
// On success: req.body is the parsed signed JSON body ({} when empty) and the
// peer row is returned; the handler answers with sendSigned / sendSignedJson.
// On failure: a 4xx response is sent and null is returned.
const requirePeerAuth = async (req, res) => {
for (const h of requiredHeaders) {
if (!req.headers[h]) {
return reject(res, HTTP_STATUS.BAD_REQUEST, `missing header ${h}`);
}
}
const declaredLength = Number(req.headers["content-length"]);
if (Number.isFinite(declaredLength) && declaredLength > maxBodyBytes) {
return rejectTooLarge(res);
}
const [callerEnvId, timestamp, nonce, signature] = requiredHeaders.map((h) => req.headers[h]);
if (!timestampWithinSkew(timestamp)) {
return reject(res, HTTP_STATUS.UNAUTHORIZED, "timestamp out of skew window");
}
const peerRow = await peers.findPeerByEnvId(callerEnvId);
if (!peerRow) {
return reject(res, HTTP_STATUS.UNAUTHORIZED, "unknown peer env_id");
}
let secret;
try {
secret = await peers.peerSecret(peerRow.peer_id);
} catch (e) {
return reject(res, HTTP_STATUS.UNAUTHORIZED, "peer not provisioned");
}
// Raw bytes (empty for GET/HEAD), capped BEFORE the signature check: only a
// (non-secret) env_id is needed to get this far, so an unbounded read would
// let anyone who knows one exhaust memory. The HMAC covers exactly what
// arrived.
let bodyRaw = Buffer.alloc(0);
if (req.method !== "GET" && req.method !== "HEAD") {
try {
bodyRaw = await readCappedRequest(req, maxBodyBytes);
} catch (e) {
return reject(res, HTTP_STATUS.BAD_REQUEST, "failed to read request body");
}
if (bodyRaw === null) {
return rejectTooLarge(res);
}
}
// Bind the target host (which tenant Saltcorn routed this request to) into
// the signed canonical: a request signed for tenant t1 must NOT verify when
// replayed against tenant t2 on the same server. req.hostname would drop the
// port the sender signed. NOTE: do NOT compare against peerRow.base_url --
// inbound that is the SENDER's address (for pull-back), not this receiver's.
const canonical = buildCanonical({
timestamp: timestamp,
nonce: nonce,
method: req.method,
path: req.originalUrl || req.url,
targetHost: normalizeHost(req.host),
body: bodyRaw
});
if (!verifySignature(secret, canonical, signature)) {
return reject(res, HTTP_STATUS.UNAUTHORIZED, "bad signature");
}
// Replay guard: only after the signature is valid (so unauthenticated
// callers cannot flood the nonce store). A reused (env_id, nonce) within the
// window -- a captured request replayed verbatim -- is rejected here.
if (!(await recordNonce(callerEnvId, nonce))) {
return reject(res, HTTP_STATUS.UNAUTHORIZED, "nonce replay or store error");
}
// Always replace req.body: when the signed body is empty, whatever an
// upstream parser produced from an unsigned Content-Type must not be trusted.
req.body = {};
if (bodyRaw.length > 0) {
try {
req.body = JSON.parse(bodyRaw.toString("utf8"));
} catch (e) {
return reject(res, HTTP_STATUS.BAD_REQUEST, "body is not valid JSON");
}
}
// From here on the caller is authenticated: responses are signed.
const env = await getEnv();
req[PEER_CONTEXT] = { secret: secret, nonce: nonce, myEnvId: env ? env.env_id : null };
// If this env requires TLS for inbound peer traffic, reject any request that
// did not arrive over HTTPS. req.protocol is "https" for a direct TLS socket,
// or when a TRUSTED proxy (Express trust proxy) reports X-Forwarded-Proto.
if (env && env.require_tls && req.protocol !== "https") {
sendSignedJson(req, res, HTTP_STATUS.FORBIDDEN, { error: "TLS required" });
return null;
}
await peers.touchPeerLastSeen(peerRow.peer_id);
return peerRow;
};
return {
requirePeerAuth,
sendSigned,
sendSignedJson
};
};
module.exports = {
createNonceStore,
createPeerAuth
};

243
lib/common/peerStore.js Normal file
View file

@ -0,0 +1,243 @@
// Peer model: CRUD on the plugin's peers table.
//
// Peer secrets are stored AES-256-GCM sealed (hex columns) under the plugin's
// own session-secret-derived KEK; plaintext only crosses the process boundary at
// pairing time (when the operator copies the secret to the other instance's UI)
// and at HMAC sign/verify time. Rotating the session secret invalidates every
// pairing (the KEK changes, so existing ciphertexts no longer open).
const { createSealer } = require("./aesGcm");
const { randomSecret } = require("./peerProtocol");
// A shared secret is 32 bytes, pasted/displayed as 64 hex characters.
const SECRET_HEX_PATTERN = /^[0-9a-fA-F]{64}$/;
// peer_id is allocated as MAX+1 (not atomic); retry this many times on a collision.
const PEER_ID_INSERT_ATTEMPTS = 5;
// Field definitions (excluding the auto `id` PK) of the registered peers table
// this store reads and writes; the plugin's schema.js registers it with these.
const PEER_TABLE_FIELDS = [
{ name: "peer_id", type: "Integer", is_unique: true },
{ name: "env_id", type: "String", is_unique: true, required: true },
{ name: "label", type: "String" },
{ name: "base_url", type: "String", required: true },
{ name: "peer_secret_ciphertext", type: "String" },
{ name: "peer_secret_iv", type: "String" },
{ name: "peer_secret_tag", type: "String" },
{ name: "require_tls", type: "Integer" },
{ name: "created_at", type: "String", required: true },
{ name: "last_seen_at", type: "String" }
];
// An operator-pasted shared secret as a Buffer; blank -> null (caller generates).
const parseSecretHex = (raw) => {
const s = String(raw || "").trim();
if (!s) {
return null;
}
if (!SECRET_HEX_PATTERN.test(s)) {
throw new Error("shared secret must be 64 hex characters");
}
return Buffer.from(s, "hex");
};
// Validate an operator-entered peer base_url before we ever sign+send requests
// to it. Peers are deliberately allowed to be localhost / private addresses
// (Dev/Test/Prod often share a host or private network), so this is NOT IP
// filtering -- it just rejects unparseable URLs and non-http(s) schemes
// (file:, gopher:, data:, ...) that have no business being an HTTP peer.
const validatePeerUrl = (baseUrl) => {
let u;
try {
u = new URL(baseUrl);
} catch (e) {
throw new Error(`invalid base_url: ${baseUrl}`);
}
if (u.protocol !== "http:" && u.protocol !== "https:") {
throw new Error(`base_url must use http or https: ${baseUrl}`);
}
if (!u.host) {
throw new Error(`base_url has no host: ${baseUrl}`);
}
};
const rowToPeer = (row) => {
if (!row) {
return null;
}
// sealed components are kept out of plain accessors -- use peerSecret()
return {
peer_id: row.peer_id,
env_id: row.env_id,
label: row.label,
base_url: row.base_url,
require_tls: !!row.require_tls,
created_at: row.created_at,
last_seen_at: row.last_seen_at
};
};
// opts = {
// owner plugin name (error messages, key derivation)
// table the registered peers table (_dd_peers / _ss_peers)
// kek { salt, info } of the plugin's peer-secret KEK (byte-identical
// forever, or existing pairings stop opening)
// dependentTables tables whose rows reference peer_id and go with the peer
// }
const createPeerStore = ({ owner, table, kek, dependentTables }) => {
const sealer = createSealer({ owner: owner, salt: kek.salt, info: kek.info });
// The sealed-secret columns of a peers row for secret (a Buffer).
const sealedColumns = (secret) => {
const sealed = sealer.sealHex(secret);
return {
peer_secret_ciphertext: sealed.ciphertext,
peer_secret_iv: sealed.iv,
peer_secret_tag: sealed.tag
};
};
// The plaintext secret of a raw peers row (throws if it does not open).
const openSecret = (row) => {
return sealer.openHex({ ciphertext: row.peer_secret_ciphertext, iv: row.peer_secret_iv, tag: row.peer_secret_tag });
};
const listPeers = async () => {
const db = require("@saltcorn/data/db");
const rows = await db.select(table, {}, { orderBy: "peer_id" });
return rows.map(rowToPeer);
};
const findPeer = async (peerId) => {
const db = require("@saltcorn/data/db");
return rowToPeer(await db.selectMaybeOne(table, { peer_id: peerId }));
};
// The raw row (with the sealed secret columns), or null.
const findPeerByEnvId = async (envId) => {
const db = require("@saltcorn/data/db");
return await db.selectMaybeOne(table, { env_id: envId });
};
// The plaintext 32-byte secret of an existing peer; throws if the peer is
// missing or has no sealed secret.
const peerSecret = async (peerId) => {
const db = require("@saltcorn/data/db");
const row = await db.selectMaybeOne(table, { peer_id: peerId });
if (!row) {
throw new Error(`peer ${peerId} not found`);
}
if (!row.peer_secret_ciphertext || !row.peer_secret_iv || !row.peer_secret_tag) {
throw new Error(`peer ${peerId} has no sealed secret`);
}
return openSecret(row);
};
// Create a peer with existingSecret, or a freshly generated one. Returns
// { peer, secret } where secret is the plaintext Buffer -- only available at
// this single moment.
const addPeer = async ({ envId, label, baseUrl, requireTls, existingSecret }) => {
const db = require("@saltcorn/data/db");
if (!envId || !baseUrl) {
throw new Error("addPeer requires envId and baseUrl");
}
validatePeerUrl(baseUrl);
if (await findPeerByEnvId(envId)) {
throw new Error(`peer with env_id ${envId} already exists`);
}
const secret = existingSecret || randomSecret();
// The peers table is a registered Saltcorn table with an auto `id` PK;
// peer_id is a regular unique column, assigned here (serial-style MAX+1) to
// keep it stable across restore (other rows reference it by value). The
// MAX+1 is not atomic, so on a concurrent-pairing collision (peer_id unique
// violation) recompute and retry rather than losing the one-time secret.
const row = {
peer_id: null,
env_id: envId,
label: label || null,
base_url: baseUrl,
...sealedColumns(secret),
require_tls: requireTls ? 1 : 0,
created_at: new Date().toISOString(),
last_seen_at: null
};
for (let attempt = 1; ; attempt++) {
const nextRs = await db.query(`SELECT COALESCE(MAX(peer_id),0)+1 AS next FROM ${db.getTenantSchemaPrefix()}${table}`);
row.peer_id = nextRs.rows[0].next;
try {
// noid: let Saltcorn assign the `id` PK; peer_id is supplied explicitly.
await db.insert(table, row, { noid: true });
break;
} catch (e) {
if (attempt >= PEER_ID_INSERT_ATTEMPTS || (await findPeerByEnvId(envId))) {
throw e;
}
}
}
return { peer: rowToPeer(await findPeerByEnvId(envId)), secret: secret };
};
// Replace a peer's shared secret: with existingSecret (the value the OTHER
// side just rotated to and displayed) or, when absent, a freshly generated one
// that this side displays for pasting over there.
const rotatePeerSecret = async (peerId, existingSecret) => {
const db = require("@saltcorn/data/db");
const peer = await findPeer(peerId);
if (!peer) {
throw new Error(`peer ${peerId} not found`);
}
const secret = existingSecret || randomSecret();
await db.updateWhere(table, sealedColumns(secret), { peer_id: peerId });
return { peer: peer, secret: secret };
};
// Rows referencing the peer go too: a deleted max peer_id is handed out again
// (MAX+1) to the next pairing, which must never inherit them.
const deletePeer = async (peerId) => {
const db = require("@saltcorn/data/db");
await db.deleteWhere(table, { peer_id: peerId });
for (const dependent of dependentTables) {
await db.deleteWhere(dependent, { peer_id: peerId });
}
};
const touchPeerLastSeen = async (peerId) => {
const db = require("@saltcorn/data/db");
await db.updateWhere(table, { last_seen_at: new Date().toISOString() }, { peer_id: peerId });
};
return {
sealedColumns,
openSecret,
listPeers,
findPeer,
findPeerByEnvId,
peerSecret,
addPeer,
rotatePeerSecret,
deletePeer,
touchPeerLastSeen
};
};
module.exports = {
PEER_TABLE_FIELDS,
parseSecretHex,
createPeerStore
};

View file

@ -0,0 +1,41 @@
// Plugin parity between paired peers: presence + version of every installed
// plugin. Applying state that references a plugin the
// peer lacks (or runs at another version) can fail, so the admin is warned before
// a sync. Plugin CONFIG is never compared or sent here, only names and versions.
// This tenant's installed plugins ({ name, source, version }, by name). Schema-
// qualified so a sub-tenant never reads the root tenant's plugin list.
const localPluginList = async () => {
const db = require("@saltcorn/data/db");
const schema = db.getTenantSchemaPrefix();
return (await db.query(`SELECT name, source, version FROM ${schema}_sc_plugins ORDER BY name`)).rows;
};
// Human-readable warnings (empty when both lists match).
const comparePluginLists = (localPlugins, peerPlugins) => {
const localByName = new Map(localPlugins.map((pl) => [pl.name, pl]));
const peerByName = new Map(peerPlugins.map((pl) => [pl.name, pl]));
const warnings = [];
for (const [name, mine] of localByName) {
const theirs = peerByName.get(name);
if (!theirs) {
warnings.push(`peer missing plugin "${name}"`);
} else if ((mine.version || "") !== (theirs.version || "")) {
warnings.push(`plugin version mismatch on "${name}": local ${mine.version || "?"}, peer ${theirs.version || "?"}`);
}
}
for (const name of peerByName.keys()) {
if (!localByName.has(name)) {
warnings.push(`peer has plugin not installed here: "${name}"`);
}
}
return warnings;
};
module.exports = {
localPluginList,
comparePluginLists
};

View file

@ -0,0 +1,318 @@
// Registered Saltcorn tables for plugin state.
//
// A plugin keeps backed-up state in REGISTERED tables (_sc_tables + _sc_fields,
// auto `id` PK) so it rides Saltcorn backup/restore. Making one is harder than
// Table.create:
// - Registration is decided from _sc_tables in the DB, never this process's
// table cache: right after a live install (or another worker's first boot)
// the cache is stale, and Table.create on an existing table throws.
// - A registered table is never dropped.
// - A STRAY physical table of the same name that is not registered (a legacy
// raw table from an older plugin version, or an orphan left by a failed or
// racing install) is adopted: its rows are read, the table dropped, the
// registered table created and the rows copied back by column name (ids
// preserved), all in ONE transaction -- an empty orphan is simply replaced,
// and a failure rolls back to the untouched stray table.
// - Concurrently booting workers are serialized: Postgres takes a
// transaction-scoped advisory lock per plugin + tenant (CREATE ... IF NOT
// EXISTS is not safe against a concurrent create); SQLite takes the write
// lock up front (BEGIN IMMEDIATE, retried while another process holds it).
// Everything is tenant-schema-qualified on Postgres.
const { ADMIN_ROLE_ID } = require("./adminGate");
// SQLite: how often / how long to wait for another process's write lock (on top
// of the driver's own busy timeout per attempt).
const SQLITE_BUSY_ATTEMPTS = 20;
const SQLITE_BUSY_DELAY_MS = 250;
const getDb = () => {
return require("@saltcorn/data/db");
};
const sleep = (ms) => {
return new Promise((resolve) => setTimeout(resolve, ms));
};
// The schema-qualified, quoted SQL reference of a table in the current tenant.
const tableRef = (name) => {
return `${getDb().getTenantSchemaPrefix()}"${name}"`;
};
// Whether a table named `name` physically exists in this tenant's schema
// (registered or not).
const physicalTableExists = async (name) => {
const db = getDb();
const rs = db.isSQLite
? await db.query("SELECT name FROM sqlite_master WHERE type = 'table' AND name = $1", [name])
: await db.query("SELECT 1 FROM information_schema.tables WHERE table_schema = $1 AND table_name = $2", [db.getTenantSchema(), name]);
return rs.rows.length > 0;
};
// The column names a physical table has in this tenant's schema.
const physicalColumns = async (name) => {
const db = getDb();
if (db.isSQLite) {
return new Set((await db.query(`PRAGMA table_info("${name}")`)).rows.map((r) => r.name));
}
const rs = await db.query(
"SELECT column_name FROM information_schema.columns WHERE table_schema = $1 AND table_name = $2",
[db.getTenantSchema(), name]
);
return new Set(rs.rows.map((r) => r.column_name));
};
const isSqliteBusy = (e) => {
return !!e && (e.code === "SQLITE_BUSY" || /SQLITE_BUSY/.test(String(e.message)));
};
// Run a statement, retrying while another SQLite process holds the lock.
const sqliteWithRetry = async (sql) => {
const db = getDb();
for (let attempt = 1; ; attempt++) {
try {
await db.query(sql);
return;
} catch (e) {
if (!isSqliteBusy(e) || attempt >= SQLITE_BUSY_ATTEMPTS) {
throw e;
}
await sleep(SQLITE_BUSY_DELAY_MS);
}
}
};
// Run fn in ONE transaction covering every db.* call it makes, serialized
// against every other process doing schema work for `owner` in this tenant.
// Saltcorn's SQLite withTransaction is a no-op, so SQLite gets an explicit
// BEGIN IMMEDIATE; on Postgres withTransaction only routes queries through its
// client inside a request context, so one is established when absent (e.g.
// during onLoad). Never nest it.
const inSchemaTransaction = async (owner, fn) => {
const db = getDb();
if (db.isSQLite) {
await sqliteWithRetry("BEGIN IMMEDIATE");
try {
const result = await fn();
await sqliteWithRetry("COMMIT");
return result;
} catch (e) {
await db.query("ROLLBACK");
throw e;
}
}
const run = () => db.withTransaction(async () => {
await db.query("SELECT pg_advisory_xact_lock(hashtext($1))", [`${owner}:schema:${db.getTenantSchema()}`]);
return await fn();
});
return db.getRequestContext() ? await run() : await db.runWithTenant(db.getTenantSchema(), run);
};
const registeredRow = async (name) => {
return await getDb().selectMaybeOne("_sc_tables", { name: name });
};
// What a registered table lacks: field defs without an _sc_fields row, and
// registered fields whose physical column went missing (a damaged table).
const missingParts = async (tableRow, spec) => {
const db = getDb();
const registered = new Set((await db.select("_sc_fields", { table_id: tableRow.id })).map((f) => f.name));
const columns = await physicalColumns(spec.name);
return {
fields: spec.fields.filter((f) => !registered.has(f.name)),
columns: spec.fields.filter((f) => registered.has(f.name) && !columns.has(f.name))
};
};
// Stray physical tables whose rows still have to be adopted.
const presentLegacyTables = async (spec) => {
const present = [];
for (const name of spec.legacyTables || []) {
if (await physicalTableExists(name)) {
present.push(name);
}
}
return present;
};
const needsWork = async (spec) => {
const tableRow = await registeredRow(spec.name);
if (!tableRow) {
return true;
}
const missing = await missingParts(tableRow, spec);
return missing.fields.length > 0 || missing.columns.length > 0 || (await presentLegacyTables(spec)).length > 0;
};
const createField = async (table, f) => {
const Field = require("@saltcorn/data/models/field");
await Field.create({
table: table,
name: f.name,
label: f.label || f.name,
type: f.type,
is_unique: !!f.is_unique,
required: !!f.required,
attributes: f.attributes || {}
});
};
// Read a stray table's rows, then drop it (inside the caller's transaction).
const takeRows = async (name) => {
const db = getDb();
const rows = (await db.query(`SELECT * FROM ${tableRef(name)} ORDER BY 1`)).rows;
await db.query(`DROP TABLE ${tableRef(name)}`);
return rows;
};
// Postgres: after inserting explicit ids, move the serial past them.
const bumpIdSequence = async (name) => {
const db = getDb();
if (db.isSQLite) {
return;
}
const qualified = tableRef(name);
await db.query(`SELECT setval(pg_get_serial_sequence('${qualified.replace(/'/g, "''")}', 'id'), (SELECT COALESCE(MAX(id), 1) FROM ${qualified}))`);
};
// Copy adopted rows into the registered table by column name. A row is first
// passed through rowTransform (null drops it); a row whose keyField value is
// already present is skipped; a legacy id is kept unless already taken.
const insertRows = async (spec, rows) => {
const db = getDb();
let keptIds = false;
for (const r of rows) {
const row = spec.rowTransform ? await spec.rowTransform(r) : r;
if (!row) {
continue;
}
const clean = {};
for (const f of spec.fields) {
if (row[f.name] !== undefined && row[f.name] !== null) {
clean[f.name] = row[f.name];
}
}
if (spec.keyField && clean[spec.keyField] !== undefined && (await db.selectMaybeOne(spec.name, { [spec.keyField]: clean[spec.keyField] }))) {
continue;
}
if (r.id !== undefined && r.id !== null && !(await db.selectMaybeOne(spec.name, { id: r.id }))) {
clean.id = r.id;
keptIds = true;
}
await db.insert(spec.name, clean, { noid: true });
}
if (keptIds) {
await bumpIdSequence(spec.name);
}
};
// The transaction body: re-check everything (another worker may have done it
// while this one waited for the lock), then register / heal / adopt.
const registerLocked = async (spec) => {
const db = getDb();
const Table = require("@saltcorn/data/models/table");
const Field = require("@saltcorn/data/models/field");
const { getState } = require("@saltcorn/data/db/state");
const scope = spec.createScope || ((fn) => fn());
const tableRow = await registeredRow(spec.name);
let adopted = [];
if (!tableRow) {
if (await physicalTableExists(spec.name)) {
adopted = await takeRows(spec.name);
}
await scope(async () => {
const table = await Table.create(spec.name, {
min_role_read: spec.minRoleRead || ADMIN_ROLE_ID,
min_role_write: spec.minRoleWrite || ADMIN_ROLE_ID
});
for (const f of spec.fields) {
await createField(table, f);
}
});
} else {
const missing = await missingParts(tableRow, spec);
if (missing.fields.length > 0) {
// Table.findOne reads the cache, which may predate this table.
await getState().refresh_tables(true);
const table = Table.findOne({ name: spec.name });
await scope(async () => {
for (const f of missing.fields) {
await createField(table, f);
}
});
}
for (const f of missing.columns) {
const sqlType = new Field({ name: f.name, type: f.type, attributes: f.attributes || {} }).sql_type;
await db.query(`ALTER TABLE ${tableRef(spec.name)} ADD COLUMN "${f.name}" ${sqlType}`);
}
}
for (const legacy of await presentLegacyTables(spec)) {
adopted = adopted.concat(await takeRows(legacy));
}
await insertRows(spec, adopted);
return !tableRow;
};
// Make spec.name a registered table with an auto `id` PK plus spec.fields.
// Idempotent; returns true when this call registered it.
// spec = {
// owner plugin name (lock namespace, error messages)
// name the table
// fields [{ name, type, label?, is_unique?, required?, attributes? }]
// minRoleRead / minRoleWrite default: admin
// keyField optional logical key: an adopted row whose value is already
// present is skipped (makes repeated adoption idempotent)
// rowTransform optional async (strayRow) => row | null (null drops it)
// legacyTables optional further stray tables (e.g. a renamed-aside copy
// from an interrupted older migration) whose rows are adopted
// into the registered table and which are then dropped
// createScope optional (fn) => runs fn around Table.create / Field.create
// (e.g. to keep the plugin's own CRUD wraps from journaling)
// }
const ensureRegisteredTable = async (spec) => {
if (!(await needsWork(spec))) {
return false;
}
const { getState } = require("@saltcorn/data/db/state");
try {
return await inSchemaTransaction(spec.owner, () => registerLocked(spec));
} catch (e) {
// Done by another worker meanwhile (e.g. the SQLite lock wait ran out)?
if (!(await needsWork(spec))) {
return false;
}
throw new Error(`${spec.owner}: could not register table ${spec.name}: ${e.message}`);
} finally {
// Table.create / Field.create skip their cache refresh inside a Postgres
// transaction, and on SQLite a rolled-back create already refreshed it
// (a phantom table): reload here and tell the other workers.
await getState().refresh_tables();
}
};
module.exports = {
tableRef,
physicalTableExists,
physicalColumns,
inSchemaTransaction,
ensureRegisteredTable
};

69
lib/common/sessionKey.js Normal file
View file

@ -0,0 +1,69 @@
// Keys derived from the installation's session secret.
//
// The root of every at-rest key is the session secret the installation is
// CONFIGURED with: the SALTCORN_SESSION_SECRET env var (what Saltcorn checks
// first) or session_secret in the Saltcorn config file (~/.config/.saltcorn).
// Saltcorn's own fallback (db.connectObj.session_secret when neither is set) is
// refused on purpose: it is a fresh random value per process (sealed data would
// not open after a restart or in another cluster worker) or the public
// "deadbeef" default (sealed data readable by anyone).
//
// A key is HKDF-SHA256(ikm = session secret, salt, info). Each plugin passes its
// own salt/info strings, so plugins on one instance derive independent keys;
// those strings must never change or existing ciphertexts stop opening.
// Rotating the session secret invalidates every derived key (documented).
const crypto = require("crypto");
const HKDF_HASH = "sha256";
const KEY_BYTES = 32;
// Derived keys by (salt, info, bytes), valid only for cachedSecret: the session
// secret is global, so one cache serves every tenant, and a changed secret
// clears it so a rotated secret never keeps serving stale keys.
const keyCache = new Map();
let cachedSecret = null;
// owner names the plugin in the error raised when no acceptable secret is set.
const getSessionSecret = (owner) => {
const fromEnv = process.env.SALTCORN_SESSION_SECRET;
if (fromEnv) {
return fromEnv;
}
let cfg = null;
try {
const { getConfigFile } = require("@saltcorn/data/db/connect");
cfg = getConfigFile();
} catch (e) {
// not running inside Saltcorn (connect module unavailable): no config file
}
if (cfg && typeof cfg.session_secret === "string" && cfg.session_secret.length > 0) {
return cfg.session_secret;
}
throw new Error(`${owner}: no session_secret configured (set SALTCORN_SESSION_SECRET or session_secret in the Saltcorn config file); refusing to derive a key from Saltcorn's generated fallback secret`);
};
// keySpec = { owner, salt, info, bytes? } -> Buffer of `bytes` (default 32).
const deriveKey = ({ owner, salt, info, bytes = KEY_BYTES }) => {
const secret = getSessionSecret(owner);
if (secret !== cachedSecret) {
keyCache.clear();
cachedSecret = secret;
}
const cacheKey = JSON.stringify([salt, info, bytes]);
let key = keyCache.get(cacheKey);
if (!key) {
const ikm = Buffer.from(secret, "utf8");
key = Buffer.from(crypto.hkdfSync(HKDF_HASH, ikm, Buffer.from(salt, "utf8"), Buffer.from(info, "utf8"), bytes));
keyCache.set(cacheKey, key);
}
return key;
};
module.exports = {
getSessionSecret,
deriveKey
};

View file

@ -0,0 +1,62 @@
// Sync-policy constants: one source of truth for every enforcement point on both
// sides of a sync.
// The ONLY global _sc_config keys a deployer syncs. The capture side records /
// ships only these and the apply side refuses any other key, so a compromised
// peer cannot write security-relevant config it was never meant to touch
// (disable_csrf_routes, custom HTML headers, SMTP creds). Per-environment
// settings (base_url: links, OAuth callbacks, the idp issuer) are deliberately
// absent.
const SYNCABLE_CONFIG_KEYS = [
"menu_items",
"site_name",
"site_logo_id"
];
// User tables that must NEVER receive peer-synced rows and must never be
// flipped into managed/starter mode, no matter what a peer sends. `users` is the
// crown jewel: a synced row there is an injected account (role_id=1 => admin
// takeover). Enforced on the apply (and revert) side, not just the UI, since a
// hostile authenticated peer never goes through the UI.
const ROW_SYNC_LOCKED_TABLES = ["users"];
// Per-table row-propagation modes.
// user (default) -- rows belong to the target; sync never touches them.
// starter -- rows ship once (initial seed), then the target owns them.
// managed -- rows always converge from source; source is canonical.
const DATA_MODES = {
USER: "user",
STARTER: "starter",
MANAGED: "managed"
};
// Plugin/system infrastructure: a table OR field whose name
// begins with "_" is reserved by Saltcorn (_sc_*) and plugins (_dd_*, _ss_*,
// _idp_*, the per-row identity columns _dd_row_uuid / _ss_row_uuid). Infra is
// never journaled, synced, diffed, pruned, or offered a data mode. (Documented
// limitation: an application table or field named with a leading underscore is
// not synced.)
const isInfraName = (name) => {
return typeof name === "string" && name.startsWith("_");
};
const isLockedTable = (name) => {
return ROW_SYNC_LOCKED_TABLES.includes(name);
};
const isSyncedMode = (mode) => {
return mode === DATA_MODES.MANAGED || mode === DATA_MODES.STARTER;
};
module.exports = {
SYNCABLE_CONFIG_KEYS,
ROW_SYNC_LOCKED_TABLES,
DATA_MODES,
isInfraName,
isLockedTable,
isSyncedMode
};

120
lib/constants.js Normal file
View file

@ -0,0 +1,120 @@
// Compile-time constants for the site-sync plugin.
//
// site-sync is a one-way DESIRED-STATE deployer: it snapshots a source site's
// current design state, diffs it against a target, and applies the difference so
// the target converges to the source. Unlike dev-deploy (an ops journal) there is
// no edit log -- identity is derived from the current state itself (entities keyed
// by NAME, the same key Saltcorn's *_pack builders make canonical; roles by id).
// SYNCABLE_CONFIG_KEYS, ROW_SYNC_LOCKED_TABLES, DATA_MODES (stored per table in
// _ss_table_modes) and the isInfraName / isLockedTable / isSyncedMode predicates
// are shared with dev-deploy: one source of truth in lib/common/syncConstants.js,
// re-exported here. capture.js intersects the produced config to
// SYNCABLE_CONFIG_KEYS and apply refuses any other key -- install_pack's
// setConfig clobbers unconditionally, so a compromised source must not reach
// security config. (install_pack also writes function_code_pages from
// pack.code_pages; site-sync handles code pages as a first-class previewed +
// prunable kind in diff.js, never as raw config.)
const syncConstants = require("./common/syncConstants");
const { ADMIN_ROLE_ID } = require("./common/adminGate");
const PLUGIN_NAME = "site-sync";
const PLUGIN_VERSION = require("../package.json").version;
// Saltcorn role ids used by the file layer: ADMIN (lib/common/adminGate.js) is the
// most privileged role, PUBLIC the least (min_role_read = PUBLIC means anyone may
// read). Synced files are owned by the admin user (file ownership is not portable
// between user tables).
const PUBLIC_ROLE_ID = 100;
const ADMIN_USER_ID = 1;
// How a sync treats entities present in the TARGET but absent from the SOURCE.
// mirror (default) -- prune them so the target exactly matches the source.
// additive -- never remove; the target stays a superset of the source.
const SYNC_MODES = {
MIRROR: "mirror",
ADDITIVE: "additive"
};
// Policy for DATA-BEARING drops (dropping a table that holds rows, or a field that
// holds column data). Metadata-only deletes (views, pages, triggers, empty fields)
// prune freely under mirror mode; a data-bearing drop is gated by this policy.
// confirm (default) -- allowed only when the table name was TYPED into the
// prunable allow-list (never pre-filled) AND the data-loss
// acknowledgement was given in the apply request.
// refuse -- never drop a data-bearing table/field (leave it, warn).
// auto -- drop without the acknowledgement (still requires the
// typed allow-list).
const DELETE_POLICY = {
AUTO: "auto",
CONFIRM: "confirm",
REFUSE: "refuse"
};
// Whether a paired peer may trigger an apply remotely.
// confirm (default) -- only an admin applies a previewed plan by plan_id.
// auto -- a paired peer may push-apply remotely
// (POST /site-sync/api/apply); the pull path always previews.
const APPLY_POLICY = {
AUTO: "auto",
CONFIRM: "confirm"
};
// Lifecycle of a persisted plan (_ss_plans.status). Transitions are claimed
// atomically (UPDATE ... WHERE status IN (...)) so a double-submit cannot run two
// applies or two rollbacks of the same plan concurrently.
const PLAN_STATUS = {
PENDING: "pending",
APPLYING: "applying",
APPLIED: "applied",
ROLLING_BACK: "rolling_back",
ROLLED_BACK: "rolled_back"
};
// Custom request Content-Type for signed peer bodies. Chosen so Saltcorn's
// express.json() middleware leaves the stream untouched and peerAuth can read the
// exact raw bytes for the HMAC. Must match on both the sign and verify sides.
const PEER_CONTENT_TYPE = "application/vnd.site-sync+json";
// Signed-request header names. The HMAC canonical contains NO header names, so
// these are free to rename -- but the sign side (transport.js) and verify side
// (peerAuth.js) must agree byte-for-byte.
const HEADERS = {
ENV_ID: "X-SS-Env-Id",
TIMESTAMP: "X-SS-Timestamp",
NONCE: "X-SS-Nonce",
SIGNATURE: "X-SS-Signature",
RESPONSE_SIGNATURE: "X-SS-Response-Signature"
};
// HKDF salt AND info of the KEK sealing peer secrets at rest (lib/peers.js).
// site-sync-specific, so site-sync and dev-deploy on one instance derive
// INDEPENDENT KEKs from the same session secret. Must stay byte-identical
// forever: changing it orphans every existing pairing.
const PEER_KEK_INFO = "site-sync:peer-secrets:aes-gcm-key:v1";
// Registered peers table and the raw nonce store of the inbound replay guard
// (both created in lib/schema.js).
const PEERS_TABLE = "_ss_peers";
const SEEN_NONCES_TABLE = "_ss_seen_nonces";
// The raw site-wide apply lock (lib/siteLock.js; created in lib/schema.js).
const APPLY_LOCK_TABLE = "_ss_apply_lock";
module.exports = {
...syncConstants,
PLUGIN_NAME,
PLUGIN_VERSION,
ADMIN_ROLE_ID,
PUBLIC_ROLE_ID,
ADMIN_USER_ID,
SYNC_MODES,
DELETE_POLICY,
APPLY_POLICY,
PLAN_STATUS,
PEER_CONTENT_TYPE,
HEADERS,
PEER_KEK_INFO,
PEERS_TABLE,
SEEN_NONCES_TABLE,
APPLY_LOCK_TABLE
};

291
lib/deletes.js Normal file
View file

@ -0,0 +1,291 @@
// The surgical structural-convergence engine: RENAMES and DELETES.
//
// This is the sole reason site-sync differs from install_pack (which is
// additive-only) and why it does NOT use uninstall_pack (too coarse: it drops
// whole tables by name, taking their row data, and name-collides). Everything here
// operates ONLY on entities the plan explicitly identified (current-not-in-source),
// never a name-sweep, and every table/field drop passes a policy gate.
//
// Order matters (reverse dependency, like uninstall_pack):
// RENAMES run first, in apply.js, BEFORE install_pack -- a data-preserving
// Table.rename means install_pack then UPDATES the renamed table instead of
// creating a fresh empty one. Only renames the admin CONFIRMED are applied (a
// structural match can be a false positive). (SQLite can't ALTER RENAME, so it
// falls back to drop+create, where the data-bearing old table is RETAINED by
// the gate below.)
// DELETES run last, after install_pack: page_groups -> pages -> views ->
// triggers -> library -> tags -> models -> code pages -> constraint removals ->
// field drops on surviving tables (so no surviving FK column still points at a
// doomed table) -> FK fields + triggers of EVERY doomed table -> the tables.
//
// Delete gates (the highest data-loss surface in the whole plugin):
// * users / ROW_SYNC_LOCKED_TABLES are NEVER dropped.
// * a metadata-only drop (0 rows) proceeds under mirror mode.
// * a DATA-BEARING drop (table with rows, or a field on a table with rows) is
// allowed only when on_delete != refuse AND the table name was typed into the
// caller's `prunable` allow-list AND (on_delete == auto OR confirmDataDrops).
// Otherwise it is SKIPPED and reported, never silently dropped.
const { isEqual } = require("lodash");
const Table = require("@saltcorn/data/models/table");
const rowsLib = require("./rows");
const { DELETE_POLICY, isInfraName, isLockedTable } = require("./constants");
// Decide whether a data-bearing table/field drop is permitted. Metadata-only drops
// (rowCount 0) are always allowed under mirror.
const gateDrop = ({ name, rowCount, onDelete, prunable, confirmDataDrops }) => {
if ((rowCount || 0) === 0) {
return { allowed: true };
}
if (onDelete === DELETE_POLICY.REFUSE) {
return { allowed: false, reason: `refuse policy: "${name}" holds ${rowCount} row(s)` };
}
if (!prunable.includes(name)) {
return { allowed: false, reason: `"${name}" holds ${rowCount} row(s); type its name into the prunable allow-list to drop it` };
}
if (onDelete === DELETE_POLICY.CONFIRM && !confirmDataDrops) {
return { allowed: false, reason: `"${name}" holds data; the data-loss confirmation is required` };
}
return { allowed: true };
};
// --- rename pre-pass (called from apply.js BEFORE install_pack) ---
// Apply the CONFIRMED data-preserving TABLE renames (opts.confirmRenames = the old
// table names the admin ticked; absent = none). The table's data mode moves with
// it. Returns { renamed:[{from,to}], failed:[{from,to,error}] }. A `from` that is
// not confirmed, no longer exists, or fails to rename is left for the delete pass
// (gated) to handle, so no data is ever lost by a declined or failed rename.
const applyRenames = async (plan, opts = {}) => {
const result = { renamed: [], failed: [] };
const confirmed = new Set(opts.confirmRenames || []);
for (const r of plan.entities.tables.rename) {
if (!confirmed.has(r.from) || isLockedTable(r.from) || isLockedTable(r.to) || isInfraName(r.to)) {
continue;
}
const tbl = Table.findOne({ name: r.from });
if (!tbl) {
continue;
}
try {
await tbl.rename(r.to);
} catch (e) {
// e.g. SQLite (no ALTER RENAME) -- fall back to drop+create; the old
// data-bearing table is retained by the delete gate.
result.failed.push({ from: r.from, to: r.to, error: e.message });
continue;
}
result.renamed.push({ from: r.from, to: r.to });
await rowsLib.renameModeRow(r.from, r.to);
}
return result;
};
// --- delete pass (called from apply.js AFTER install_pack) ---
// Metadata-only kinds, in dependency order (groups reference pages, pages embed
// views, triggers/tags reference views and tables, ...).
const METADATA_DELETE_ORDER = ["page_groups", "pages", "views", "triggers", "library", "tags", "models"];
const deleteEntity = async (kind, entry) => {
const name = entry.name;
let found;
if (kind === "page_groups") {
found = require("@saltcorn/data/models/page_group").findOne({ name });
} else if (kind === "pages") {
found = require("@saltcorn/data/models/page").findOne({ name });
} else if (kind === "views") {
found = require("@saltcorn/data/models/view").findOne({ name });
} else if (kind === "triggers") {
found = require("@saltcorn/data/models/trigger").findOne({ name });
} else if (kind === "library") {
found = await require("@saltcorn/data/models/library").findOne({ name });
} else if (kind === "tags") {
found = await require("@saltcorn/data/models/tag").findOne({ name });
} else if (kind === "models") {
const tbl = Table.findOne({ name: entry.table_name });
found = tbl ? await require("@saltcorn/data/models/model").findOne({ name, table_id: tbl.id }) : null;
}
if (found) {
await found.delete();
}
};
// Remove code pages (server-side JavaScript kept in the function_code_pages /
// function_code_pages_tags config maps), then rebuild the code-page context.
const deleteCodePages = async (names) => {
const { getState } = require("@saltcorn/data/db/state");
const state = getState();
const pages = state.getConfigCopy("function_code_pages", {});
const tags = state.getConfigCopy("function_code_pages_tags", {});
for (const n of names) {
delete pages[n];
delete tags[n];
}
await state.setConfig("function_code_pages", pages);
await state.setConfig("function_code_pages_tags", tags);
await state.refresh_codepages();
};
// Remove table constraints the source no longer has (install_pack only adds).
const removeConstraints = async (tbl, specs, result) => {
if (specs.length === 0) {
return;
}
const TableConstraint = require("@saltcorn/data/models/table_constraints");
const existing = await TableConstraint.find({ table_id: tbl.id });
for (const spec of specs) {
const c = existing.find((x) => x.type === spec.type && isEqual(x.configuration, spec.configuration));
if (!c) {
continue;
}
try {
await c.delete();
result.removedConstraints += 1;
} catch (err) {
result.skipped.push({ kind: "constraint", name: `${tbl.name} ${spec.type}`, reason: err.message });
}
}
};
// Drop fields from a surviving table, gated once by the table's LIVE row count.
const dropFields = async (tbl, fields, gateOpts, result) => {
if (fields.length === 0) {
return;
}
let gate;
try {
gate = gateDrop(Object.assign({ name: tbl.name, rowCount: await tbl.countRows() }, gateOpts));
} catch (err) {
gate = { allowed: false, reason: `could not count rows: ${err.message}` };
}
for (const f of fields) {
const label = `${tbl.name}.${f.name}`;
if (!gate.allowed || isInfraName(f.name)) {
result.skipped.push({ kind: "field", name: label, reason: gate.reason || "infrastructure field" });
continue;
}
try {
const field = Table.findOne({ name: tbl.name }).getField(f.name);
if (field) {
await field.delete();
result.droppedFields += 1;
}
} catch (err) {
result.skipped.push({ kind: "field", name: label, reason: err.message });
}
}
};
// Remove the edges that would block dropping `name`: its own FK fields and its
// triggers. Done for EVERY doomed table before any drop, so doomed tables that
// reference each other never block one another.
const stripTableEdges = async (name) => {
const Trigger = require("@saltcorn/data/models/trigger");
const tbl = Table.findOne({ name });
for (const f of tbl.getFields().filter((x) => x.is_fkey)) {
await f.delete();
}
for (const trg of Trigger.find({ table_id: tbl.id })) {
await trg.delete();
}
};
const applyDeletes = async (plan, opts = {}) => {
const gateOpts = {
onDelete: opts.onDelete || DELETE_POLICY.CONFIRM,
prunable: opts.prunable || [],
confirmDataDrops: !!opts.confirmDataDrops
};
const renamed = new Set((opts.renamed || []).map((r) => r.from));
const result = { applied: true, deleted: 0, droppedFields: 0, removedConstraints: 0, skipped: [] };
for (const kind of METADATA_DELETE_ORDER) {
for (const e of plan.entities[kind].delete) {
try {
await deleteEntity(kind, e);
} catch (err) {
result.skipped.push({ kind, name: e.name, reason: err.message });
}
}
}
const codePages = plan.entities.code_pages.delete.map((c) => c.name);
if (codePages.length > 0) {
await deleteCodePages(codePages);
}
// Tables the source dropped, plus every rename `from` that was not renamed
// (declined, or failed on SQLite). Each is gated on a LIVE row count: the
// precondition hash is metadata-only, so the preview-time count is not
// trustworthy (rows added since would slip through the zero-row fast path).
const dropNames = plan.entities.tables.delete.map((d) => d.name)
.concat(plan.entities.tables.rename.filter((r) => !renamed.has(r.from)).map((r) => r.from));
const doomed = [];
for (const name of dropNames) {
const tbl = Table.findOne({ name });
if (isLockedTable(name)) {
result.skipped.push({ kind: "table", name, reason: "system/locked table -- never dropped" });
} else if (tbl) {
try {
const gate = gateDrop(Object.assign({ name, rowCount: await tbl.countRows() }, gateOpts));
if (gate.allowed) {
doomed.push(name);
} else {
result.skipped.push({ kind: "table", name, reason: gate.reason });
}
} catch (err) {
result.skipped.push({ kind: "table", name, reason: err.message });
}
}
}
// Surviving tables first: constraint removals, then field drops (so no surviving
// FK column still references a doomed table when it is dropped).
for (const u of plan.entities.tables.update) {
const tbl = Table.findOne({ name: u.name });
if (!tbl || isLockedTable(u.name)) {
continue;
}
await removeConstraints(tbl, u.constraints.remove, result);
await dropFields(tbl, u.fields.delete, gateOpts, result);
}
const droppable = [];
for (const name of doomed) {
try {
await stripTableEdges(name);
droppable.push(name);
} catch (err) {
result.skipped.push({ kind: "table", name, reason: err.message });
}
}
for (const name of droppable) {
try {
await Table.findOne({ name }).delete();
result.deleted += 1;
} catch (err) {
result.skipped.push({ kind: "table", name, reason: err.message });
}
}
return result;
};
module.exports = {
applyRenames,
applyDeletes,
gateDrop
};

366
lib/diff.js Normal file
View file

@ -0,0 +1,366 @@
// The diff engine: compute a desired-vs-current PLAN.
//
// Both inputs are sanitized packs produced by capture.buildDesiredState (source's
// desired state and the target's own current state), so they are already
// infra-filtered, config-allow-listed, and name-keyed. This module is PURE: it
// takes the two packs plus a row-count map and options, and returns a plain plan
// object. All live-DB work (gathering row counts, can_install_pack warnings,
// persistence) lives in plan.js; all mutation lives in apply.js / deletes.js.
//
// Identity: every kind is keyed by NAME except ROLES (keyed by numeric id -- a
// privilege level, the key install_pack itself upserts on) and MODELS (name +
// table). Classification per kind: in-source-only = CREATE, in-both-but-differing
// (lodash isEqual after normalization) = UPDATE, in-target-only = DELETE (only
// under mirror mode). Tables additionally get a nested field-level and
// constraint-level diff. Probable TABLE renames (a delete whose structural
// signature uniquely matches a create's) are lifted out of create/delete into
// `rename`, so the admin can confirm a data-preserving Table.rename instead of
// drop+recreate. Other kinds carry no row data, so they are planned exactly as apply
// executes them: create + delete.
const { isEqual } = require("lodash");
const { SYNC_MODES, DELETE_POLICY } = require("./constants");
// --- normalization (avoid false diffs) ---
// A workflow step embedded in a trigger pack keeps the source DB's numeric
// trigger_id (a name-vs-id leak that is meaningless on the target). Strip it
// before comparing so a matching trigger doesn't look changed.
const normalizeTrigger = (trig) => {
if (!trig || !Array.isArray(trig.steps)) {
return trig;
}
const steps = trig.steps.map((s) => {
const { trigger_id, ...rest } = s;
return rest;
});
return Object.assign({}, trig, { steps });
};
// A page pack carries root_page_for_roles (which roles use this page as their home).
// That is per-instance home-page CONFIG (like base_url), not design, and install_pack
// only sets a role home when it is currently empty (non-clobbering) -- so a differing
// home would make the page diff forever without converging. Strip it from the compare.
const normalizePage = (page) => {
if (!page || page.root_page_for_roles === undefined) {
return page;
}
const { root_page_for_roles, ...rest } = page;
return rest;
};
const normalizeByKind = (kind, entry) => {
if (kind === "triggers") {
return normalizeTrigger(entry);
}
if (kind === "pages") {
return normalizePage(entry);
}
return entry;
};
// --- structural signatures for rename detection ---
const sortByName = (arr) => (arr || []).slice().sort((a, b) => String(a.name).localeCompare(String(b.name)));
const tableSignature = (t) => {
const fields = sortByName(t.fields).map((f) => ({
name: f.name, type: f.type, reftable_name: f.reftable_name,
required: !!f.required, is_unique: !!f.is_unique, primary_key: !!f.primary_key,
attributes: f.attributes
}));
const constraints = (t.constraints || []).map((c) => ({ type: c.type, configuration: c.configuration }))
.sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b)));
return JSON.stringify({ fields, constraints });
};
// --- generic classification ---
const indexBy = (arr, keyOf) => {
const m = new Map();
for (const e of (arr || [])) {
m.set(keyOf(e), e);
}
return m;
};
// Classify a kind's entries into create / update / delete buckets. `deletable`
// and `mirror` gate whether target-only entries become deletes. (Locked tables
// such as users never reach here: capture excludes them from both packs.)
const classify = (desiredArr, currentArr, keyOf, kind, { deletable, mirror }) => {
const desired = indexBy(desiredArr, keyOf);
const current = indexBy(currentArr, keyOf);
const bucket = { create: [], update: [], delete: [], rename: [] };
for (const [key, dEntry] of desired) {
if (!current.has(key)) {
bucket.create.push(dEntry);
} else if (!isEqual(normalizeByKind(kind, dEntry), normalizeByKind(kind, current.get(key)))) {
bucket.update.push({ key, from: current.get(key), to: dEntry });
}
}
if (deletable && mirror) {
for (const [key, cEntry] of current) {
if (!desired.has(key)) {
bucket.delete.push(cEntry);
}
}
}
return bucket;
};
// Lift probable renames out of a bucket: a delete whose signature uniquely matches
// exactly one create's signature (and vice versa) is treated as a rename. Mutates
// and returns the bucket. keyOf identifies the human key for the plan.
const detectRenames = (bucket, signatureOf, keyOf) => {
if (bucket.create.length === 0 || bucket.delete.length === 0) {
return bucket;
}
const sigCount = (arr) => {
const m = new Map();
for (const e of arr) {
const s = signatureOf(e);
m.set(s, (m.get(s) || 0) + 1);
}
return m;
};
const createSig = sigCount(bucket.create);
const deleteSig = sigCount(bucket.delete);
const usedCreate = new Set();
const usedDelete = new Set();
for (let di = 0; di < bucket.delete.length; di++) {
const dEntry = bucket.delete[di];
const s = signatureOf(dEntry);
// Only an unambiguous 1:1 signature match is a rename.
if (createSig.get(s) !== 1 || deleteSig.get(s) !== 1) {
continue;
}
const ci = bucket.create.findIndex((c, idx) => !usedCreate.has(idx) && signatureOf(c) === s);
if (ci >= 0) {
usedCreate.add(ci);
usedDelete.add(di);
bucket.rename.push({ from: keyOf(dEntry), to: keyOf(bucket.create[ci]), fromEntry: dEntry, toEntry: bucket.create[ci] });
}
}
bucket.create = bucket.create.filter((_, idx) => !usedCreate.has(idx));
bucket.delete = bucket.delete.filter((_, idx) => !usedDelete.has(idx));
return bucket;
};
// --- nested table diff (fields + constraints + table-level props) ---
const TABLE_PROP_KEYS = [
"description", "is_user_group", "min_role_read", "min_role_write", "versioned",
"has_sync_info", "provider_name", "provider_cfg", "ownership_formula", "ownership_field_name"
];
const tablePropsDiffer = (from, to) => {
for (const k of TABLE_PROP_KEYS) {
if (!isEqual(from[k], to[k])) {
return true;
}
}
return false;
};
const diffConstraints = (fromArr, toArr) => {
const key = (c) => JSON.stringify({ type: c.type, configuration: c.configuration });
const fromSet = new Set((fromArr || []).map(key));
const toSet = new Set((toArr || []).map(key));
const add = (toArr || []).filter((c) => !fromSet.has(key(c)));
const remove = (fromArr || []).filter((c) => !toSet.has(key(c)));
return { add, remove };
};
// Diff one table (present on both sides). Returns null when nothing changed.
const diffTable = (fromT, toT, rowCount) => {
const fromF = indexBy(fromT.fields, (f) => f.name);
const toF = indexBy(toT.fields, (f) => f.name);
const fields = { create: [], update: [], delete: [] };
for (const [name, f] of toF) {
if (!fromF.has(name)) {
fields.create.push(f);
} else if (!isEqual(fromF.get(name), f)) {
fields.update.push({ name, from: fromF.get(name), to: f });
}
}
for (const [name, f] of fromF) {
if (!toF.has(name)) {
fields.delete.push(f);
}
}
const constraints = diffConstraints(fromT.constraints, toT.constraints);
const propsChanged = tablePropsDiffer(fromT, toT);
const nFieldChanges = fields.create.length + fields.update.length + fields.delete.length;
const nConstr = constraints.add.length + constraints.remove.length;
if (!propsChanged && nFieldChanges === 0 && nConstr === 0) {
return null;
}
return {
name: toT.name,
propsChanged,
fields,
constraints,
rowCount,
dataBearing: rowCount > 0 && fields.delete.length > 0
};
};
// --- top-level plan ---
// A model is identified by (name, table); JSON keeps the pair unambiguous.
const modelKey = (m) => JSON.stringify([m.name, m.table_name]);
const KIND_SPECS = [
{ key: "views", keyOf: (v) => v.name, deletable: true },
{ key: "pages", keyOf: (p) => p.name, deletable: true },
{ key: "page_groups", keyOf: (g) => g.name, deletable: true },
{ key: "triggers", keyOf: (t) => t.name, deletable: true },
// Roles are ADDITIVE ONLY: a role id is a privilege level referenced by every
// entity's min_role, so pruning one would strand those references. Create/update
// by id; never delete.
{ key: "roles", keyOf: (r) => String(r.id), deletable: false },
{ key: "library", keyOf: (l) => l.name, deletable: true },
{ key: "tags", keyOf: (t) => t.name, deletable: true },
{ key: "models", keyOf: modelKey, deletable: true },
// Code pages are server-side JavaScript (install_pack writes them into
// function_code_pages); previewed + prunable like any other kind.
{ key: "code_pages", keyOf: (c) => c.name, deletable: true }
];
// Every kind a plan carries, tables first (the preview + summary order).
const PLAN_KINDS = ["tables"].concat(KIND_SPECS.map((s) => s.key));
// The identity key of an entry of a plan kind (the key its plan update carries).
const keyOfKind = (kind, entry) => {
return kind === "tables" ? entry.name : KIND_SPECS.find((s) => s.key === kind).keyOf(entry);
};
const computePlan = (desired, current, opts = {}) => {
const syncMode = opts.syncMode || SYNC_MODES.MIRROR;
const onDelete = opts.onDelete || DELETE_POLICY.CONFIRM;
const rowCounts = opts.rowCounts || {};
const mirror = syncMode === SYNC_MODES.MIRROR;
const rowsOf = (name) => rowCounts[name] || 0;
const entities = {};
// Tables: classify by name, detect renames, then compute nested diffs for the
// tables present on both sides.
const tblBucket = classify(desired.tables, current.tables, (t) => t.name, "tables", { deletable: true, mirror });
detectRenames(tblBucket, tableSignature, (t) => t.name);
const tableUpdates = [];
for (const u of tblBucket.update) {
const nested = diffTable(u.from, u.to, rowsOf(u.key));
if (nested) {
tableUpdates.push(nested);
}
}
entities.tables = {
create: tblBucket.create,
update: tableUpdates,
delete: tblBucket.delete.map((t) => ({ name: t.name, rowCount: rowsOf(t.name), dataBearing: rowsOf(t.name) > 0 })),
rename: tblBucket.rename.map((r) => ({ from: r.from, to: r.to, rowCount: rowsOf(r.from) }))
};
// Other kinds: generic classification (no rename detection -- they hold no
// row data, and apply executes them as create + delete).
for (const spec of KIND_SPECS) {
const bucket = classify(desired[spec.key], current[spec.key], spec.keyOf, spec.key, { deletable: spec.deletable, mirror });
entities[spec.key] = {
create: bucket.create,
update: bucket.update.map((u) => ({ key: u.key, to: u.to })),
delete: bucket.delete
};
}
// Config: converge-by-overwrite; never delete a target config key.
const configSets = {};
for (const [k, v] of Object.entries(desired.config || {})) {
if (!isEqual((current.config || {})[k], v)) {
configSets[k] = v;
}
}
const plan = {
sync_mode: syncMode,
on_delete: onDelete,
entities,
config: { sets: configSets },
warnings: [],
destructive: []
};
annotate(plan);
return plan;
};
// Fill plan.destructive (human-readable), code-page warnings, and plan.summary
// (counts). Destructive = drops (whole tables, fields, entities) and any
// data-bearing operation. A detected rename is listed too: unless the admin
// confirms it, apply treats its old table as a drop. Config overwrites are not
// destructive.
const annotate = (plan) => {
const d = [];
const tableDrop = (name, rowCount, text) => ({ text, dataBearing: rowCount > 0, table: name, rowCount });
const rowsText = (rowCount) => (rowCount > 0 ? `${rowCount} row(s) of data` : "no rows");
for (const t of plan.entities.tables.delete) {
d.push(tableDrop(t.name, t.rowCount, `drop table "${t.name}" (${rowsText(t.rowCount)})`));
}
for (const r of plan.entities.tables.rename) {
d.push(tableDrop(r.from, r.rowCount, `drop table "${r.from}" (${rowsText(r.rowCount)}) unless the rename "${r.from}" -> "${r.to}" is confirmed`));
}
for (const u of plan.entities.tables.update) {
for (const f of u.fields.delete) {
d.push({ text: `drop field "${u.name}.${f.name}"${u.rowCount > 0 ? ` (table has ${u.rowCount} row(s))` : ""}`,
dataBearing: u.rowCount > 0, table: u.name, field: f.name, rowCount: u.rowCount });
}
}
for (const spec of KIND_SPECS.filter((s) => s.deletable)) {
for (const e of plan.entities[spec.key].delete) {
d.push({ text: `delete ${spec.key.replace(/s$/, "")} "${e.name}"`, dataBearing: false });
}
}
plan.destructive = d;
const codeChanges = plan.entities.code_pages.create.map((c) => c.name).concat(plan.entities.code_pages.update.map((u) => u.key));
if (codeChanges.length > 0) {
plan.warnings.push(`installs/changes ${codeChanges.length} code page(s) -- server-side JavaScript that runs on this instance: ${codeChanges.join(", ")}`);
}
const count = (b) => b.create.length + b.update.length + b.delete.length + (b.rename ? b.rename.length : 0);
plan.summary = {};
for (const kind of PLAN_KINDS) {
plan.summary[kind] = count(plan.entities[kind]);
}
plan.summary.config = Object.keys(plan.config.sets).length;
plan.summary.total = Object.values(plan.summary).reduce((a, v) => a + v, 0);
plan.summary.destructive = plan.destructive.length;
plan.summary.dataBearing = plan.destructive.filter((x) => x.dataBearing).length;
};
// Is the plan a no-op (nothing to apply)?
const isEmptyPlan = (plan) => plan && plan.summary && plan.summary.total === 0;
module.exports = {
computePlan,
isEmptyPlan,
keyOfKind,
PLAN_KINDS
};

74
lib/env.js Normal file
View file

@ -0,0 +1,74 @@
// This Saltcorn instance's site-sync identity (env_id, label, policies).
//
// Stored in _sc_config (key "site_sync_env") rather than a table. _sc_config
// survives backup AND is restored BEFORE the plugin's onLoad runs, so a restored
// instance keeps its env_id and onLoad does not create a duplicate (see
// lib/common/configStore.js). Reads are DB-fresh (no in-process cache needed).
const { randomUuid } = require("./ids");
const { readKey, writeKey } = require("./common/configStore");
const {
SYNC_MODES,
DELETE_POLICY,
APPLY_POLICY
} = require("./constants");
const ENV_KEY = "site_sync_env";
const getEnv = async () => {
return (await readKey(ENV_KEY)) || null;
};
const initEnvIfMissing = async () => {
const existing = await getEnv();
if (existing) {
return existing;
}
const env = {
env_id: randomUuid(),
env_label: null,
// Full-mirror is the default posture: the target converges exactly to the
// source, pruning entities the source no longer has (metadata prunes freely;
// data-bearing drops are still gated by on_delete + an explicit allow-list).
sync_mode: SYNC_MODES.MIRROR,
on_delete: DELETE_POLICY.CONFIRM,
on_apply: APPLY_POLICY.CONFIRM,
require_tls: 0,
created_at: new Date().toISOString()
};
await writeKey(ENV_KEY, env);
return env;
};
const setEnvFields = async (patch) => {
const env = await getEnv();
if (!env) {
return null;
}
const next = Object.assign({}, env, patch);
await writeKey(ENV_KEY, next);
return next;
};
// The identity is created in onLoad, so a missing one on a live request is an
// installation fault; fail loudly instead of answering with a null env_id.
const requireEnv = async () => {
const env = await getEnv();
if (!env) {
throw new Error("site-sync identity (site_sync_env) is missing; reload the plugin");
}
return env;
};
module.exports = {
getEnv,
requireEnv,
initEnvIfMissing,
setEnvFields
};

14
lib/ids.js Normal file
View file

@ -0,0 +1,14 @@
// UUID helper for site-sync: randomUuid() is a fresh v4 UUID (env_id, plan ids,
// per-row _ss_row_uuid identities).
const crypto = require("crypto");
const randomUuid = () => {
return crypto.randomUUID();
};
module.exports = {
randomUuid
};

314
lib/layoutFiles.js Normal file
View file

@ -0,0 +1,314 @@
// Content-addressed transfer of files referenced INSIDE opaque metadata blobs:
// page layouts, view configurations, and the site_logo_id config value. This is
// distinct from rowPayload.js (structured managed-row File fields). ALWAYS ON.
//
// * CAPTURE: placeholderize() replaces every resolvable file reference with a
// content-addressed placeholder "__ss_file_ref::<hash>" -- HASH ONLY, so the
// placeholder is fully content-stable (filename, mime and role are NOT encoded;
// they either churn or make byte-identical files diff forever). Both sides do
// this symmetrically, so the pack hash and diff are content-based.
// * SERVE: indexLocalFiles() adds live layout/logo files to the source's
// content-hash index so GET /api/file/:hash can serve them.
// * APPLY: resolvePlaceholders() replaces placeholders with a LOCAL file value:
// dedup against the target's OWN layout/logo files (never row files, so a file
// landed via the unrestricted row path can't be laundered into a layout); else
// fetch the bytes, verify the hash, and SNIFF the mime FROM THE BYTES (an image
// magic-number allow-list -- the peer's claimed mime is never trusted, and SVG
// is excluded to avoid script-in-SVG). Unresolvable placeholders are REMOVED
// (never persisted verbatim, which would break rendering / clobber the logo).
const File = require("@saltcorn/data/models/file");
const fsp = require("fs").promises;
const { getState } = require("@saltcorn/data/db/state");
const { hashFileContents } = require("./common/fileStore");
const { sha256Hex } = require("./common/hash");
const { ADMIN_USER_ID, PUBLIC_ROLE_ID } = require("./constants");
const PREFIX = "__ss_file_ref::";
const FILE_REF_KEYS = ["fileid", "file_id", "bgFileId", "image_id"];
const LOGO_CONFIG_KEY = "site_logo_id";
const MAX_DEPTH = 100; // guard against a hostile peer's deeply-nested layout JSON
const HASH_CACHE_MAX = 10000; // bound on the stat-hash cache (entries)
const FILENAME_HASH_CHARS = 16; // hash prefix used to name a materialized file
const SHORT_HASH_CHARS = 12; // hash prefix shown in warnings
const SNIFF_HEADER_BYTES = 12; // bytes needed to recognize every allowed format
const MIME_EXT = {
"image/png": "png", "image/jpeg": "jpg", "image/gif": "gif",
"image/webp": "webp", "image/bmp": "bmp", "image/x-icon": "ico", "image/avif": "avif"
};
const isPlaceholder = (v) => typeof v === "string" && v.startsWith(PREFIX);
const makePlaceholder = (hash) => `${PREFIX}${hash}`;
const parsePlaceholder = (v) => (isPlaceholder(v) ? { hash: v.slice(PREFIX.length) } : null);
const shortHash = (hash) => hash.slice(0, SHORT_HASH_CHARS);
// Determine an image mime from the CONTENT (magic numbers), or null if not a
// recognized raster image. Never trusts a caller-supplied mime. SVG is excluded on
// purpose (SVG can carry script and is served inline).
const startsWithBytes = (b, sig, offset = 0) => sig.every((v, i) => b[offset + i] === v);
const IMAGE_SIGNATURES = [
{ mime: "image/png", test: (b) => startsWithBytes(b, [0x89, 0x50, 0x4e, 0x47]) },
{ mime: "image/jpeg", test: (b) => startsWithBytes(b, [0xff, 0xd8, 0xff]) },
{ mime: "image/gif", test: (b) => startsWithBytes(b, [0x47, 0x49, 0x46, 0x38]) },
{ mime: "image/bmp", test: (b) => startsWithBytes(b, [0x42, 0x4d]) },
{ mime: "image/x-icon", test: (b) => startsWithBytes(b, [0x00, 0x00, 0x01, 0x00]) },
{ mime: "image/webp", test: (b) => startsWithBytes(b, [0x52, 0x49, 0x46, 0x46]) && startsWithBytes(b, [0x57, 0x45, 0x42, 0x50], 8) },
{ mime: "image/avif", test: (b) => b.slice(4, 8).toString("ascii") === "ftyp" && b.slice(8, 12).toString("ascii").startsWith("avif") }
];
const sniffImageMime = (b) => {
if (!b || b.length < SNIFF_HEADER_BYTES) {
return null;
}
const hit = IMAGE_SIGNATURES.find((s) => s.test(b));
return hit ? hit.mime : null;
};
const deriveFilename = (hash, mime) => `${hash.slice(0, FILENAME_HASH_CHARS)}.${MIME_EXT[(mime || "").toLowerCase()] || "img"}`;
// stat-based hash cache: re-hash a file only when its (mtime,size) changes, so
// repeated buildDesiredState/index passes don't re-read every file's bytes.
// Bounded: cleared once it reaches HASH_CACHE_MAX entries.
const hashCache = new Map();
const statHash = async (file) => {
if (file.s3_store) {
return await hashFileContents(file);
}
try {
const st = await fsp.stat(file.location);
const c = hashCache.get(file.location);
if (c && c.mtimeMs === st.mtimeMs && c.size === st.size) {
return c.hash;
}
const h = await hashFileContents(file);
if (hashCache.size >= HASH_CACHE_MAX) {
hashCache.clear();
}
hashCache.set(file.location, { mtimeMs: st.mtimeMs, size: st.size, hash: h });
return h;
} catch (e) {
return await hashFileContents(file);
}
};
// Content hash of the file a File-field value points at, or null (no value,
// missing file, unreadable).
const localFileHash = async (value) => {
if (!value) {
return null;
}
try {
const file = await File.findOne(value);
return file ? await statHash(file) : null;
} catch (e) {
return null;
}
};
// Recursively collect { obj, key, value } for every FILE_REF_KEYS string value,
// bounded by MAX_DEPTH so a hostile deeply-nested blob can't overflow the stack.
const collectRefs = (node, out, depth) => {
if (!node || typeof node !== "object" || depth > MAX_DEPTH) {
return out;
}
if (Array.isArray(node)) {
for (const el of node) {
collectRefs(el, out, depth + 1);
}
return out;
}
for (const [k, v] of Object.entries(node)) {
if (FILE_REF_KEYS.includes(k) && typeof v === "string" && v) {
out.push({ obj: node, key: k, value: v });
} else if (v && typeof v === "object") {
collectRefs(v, out, depth + 1);
}
}
return out;
};
// Every file ref inside page layouts and view configurations (of a pack, or of the
// live State), each tagged with its owning entity's min_role -- who can see it --
// which becomes the materialized file's min_role_read.
const layoutRefs = (pages, views) => {
const refs = [];
const add = (blob, role) => {
for (const r of collectRefs(blob, [], 0)) {
refs.push(Object.assign(r, { role }));
}
};
for (const p of (pages || [])) {
if (p && p.layout) {
add(p.layout, p.min_role);
}
}
for (const vw of (views || [])) {
if (vw && vw.configuration) {
add(vw.configuration, vw.min_role);
}
}
return refs;
};
// CAPTURE: replace resolvable file refs (layouts/configs + the logo) with hash-only
// placeholders. Dedup-within-pass via the stat hash cache (same value -> same file).
const placeholderize = async (pack) => {
const refs = layoutRefs(pack.pages, pack.views);
let count = 0;
for (const ref of refs) {
if (isPlaceholder(ref.value)) {
continue;
}
const h = await localFileHash(ref.value);
if (h) {
ref.obj[ref.key] = makePlaceholder(h);
count += 1;
}
}
if (pack.config && pack.config[LOGO_CONFIG_KEY] && !isPlaceholder(pack.config[LOGO_CONFIG_KEY])) {
const h = await localFileHash(pack.config[LOGO_CONFIG_KEY]);
if (h) {
pack.config[LOGO_CONFIG_KEY] = makePlaceholder(h);
count += 1;
}
}
return { count };
};
// SERVE / dedup: add every LIVE layout/config/logo file (hash -> field value) to
// `map`. Uses the State cache (synchronous). This is layout/logo files ONLY.
const indexLocalFiles = async (map) => {
const state = getState();
for (const ref of layoutRefs(state.pages, state.views)) {
if (isPlaceholder(ref.value)) {
continue;
}
const h = await localFileHash(ref.value);
if (h) {
map.set(h, ref.value);
}
}
const logo = state.getConfig(LOGO_CONFIG_KEY, null);
if (logo && !isPlaceholder(logo)) {
const h = await localFileHash(logo);
if (h) {
map.set(h, logo);
}
}
return map;
};
// Strip a placeholder that could not be resolved so it is never persisted verbatim:
// for the logo, DELETE the key (so install_pack's setConfig leaves the target's real
// logo untouched); for a layout ref, blank it (the Image element renders nothing).
const removeRef = (pack, ref) => {
if (ref.obj === pack.config && ref.key === LOGO_CONFIG_KEY) {
delete pack.config[LOGO_CONFIG_KEY];
} else {
ref.obj[ref.key] = "";
}
};
// APPLY: replace placeholders in layouts/configs/logo with LOCAL file values.
// opts: { fileFetcher, fileIndexSeed (obj hash->value, e.g. rollback pre-apply), warnings }
const resolvePlaceholders = async (pack, opts = {}) => {
const warnings = opts.warnings || [];
const result = { materialized: 0, deduped: 0, skipped: 0 };
const refs = layoutRefs(pack.pages, pack.views);
if (pack.config && isPlaceholder(pack.config[LOGO_CONFIG_KEY])) {
refs.push({ obj: pack.config, key: LOGO_CONFIG_KEY, value: pack.config[LOGO_CONFIG_KEY], role: PUBLIC_ROLE_ID });
}
const placeholders = refs.filter((r) => isPlaceholder(r.value));
if (placeholders.length === 0) {
return result; // nothing to do; skip building the index
}
// A file shared by several entities must be readable by the most permissive of
// them (the highest min_role; an unknown role is treated as public).
const readRole = new Map();
for (const ref of placeholders) {
const h = parsePlaceholder(ref.value).hash;
const role = Number.isInteger(ref.role) ? ref.role : PUBLIC_ROLE_ID;
readRole.set(h, Math.max(readRole.get(h) || 0, role));
}
// Dedup index = the target's OWN layout/logo files only (+ optional seed for
// rollback). Never row files -- so a file landed via the unrestricted row path
// cannot be laundered into a layout.
const local = new Map();
await indexLocalFiles(local);
if (opts.fileIndexSeed) {
for (const [h, v] of Object.entries(opts.fileIndexSeed)) {
if (!local.has(h)) {
local.set(h, v);
}
}
}
for (const ref of placeholders) {
const spec = parsePlaceholder(ref.value);
if (!spec || !spec.hash) {
removeRef(pack, ref);
continue;
}
if (local.has(spec.hash)) {
ref.obj[ref.key] = local.get(spec.hash);
result.deduped += 1;
continue;
}
if (typeof opts.fileFetcher !== "function") {
warnings.push(`no file transport; a layout/logo image was left unset`);
removeRef(pack, ref);
result.skipped += 1;
continue;
}
let bytes;
try {
bytes = await opts.fileFetcher(spec.hash);
} catch (e) {
warnings.push(`fetch of a layout/logo image (hash ${shortHash(spec.hash)}) failed: ${e.message}`);
removeRef(pack, ref);
result.skipped += 1;
continue;
}
if (sha256Hex(bytes) !== spec.hash) {
warnings.push(`content hash mismatch for a layout/logo image; refusing`);
removeRef(pack, ref);
result.skipped += 1;
continue;
}
const mime = sniffImageMime(bytes);
if (!mime) {
warnings.push(`a layout/logo reference resolved to non-image content; refusing`);
removeRef(pack, ref);
result.skipped += 1;
continue;
}
const file = await File.from_contents(deriveFilename(spec.hash, mime), mime, bytes, ADMIN_USER_ID, readRole.get(spec.hash), "/");
local.set(spec.hash, file.field_value);
ref.obj[ref.key] = file.field_value;
result.materialized += 1;
}
return result;
};
module.exports = {
placeholderize,
resolvePlaceholders,
indexLocalFiles,
localFileHash,
shortHash,
isPlaceholder,
parsePlaceholder,
makePlaceholder
};

461
lib/mcpTools.js Normal file
View file

@ -0,0 +1,461 @@
// MCP tools for the saltcorn-mcp server (its docs/pluginTools.md is the
// contract): an AI client drives site-sync the way an admin drives its UI -- pair
// peers, pull a peer's state into a plan, review it, apply it, roll it back, set
// the policies and the table data modes. Every tool is an adapter over the
// operations the admin routes use (operations.js), so the rules live in one place.
//
// Scopes: looking (status, peers, a peer's health, tables, plans, this site's
// state) is read; pulling a plan is write -- it records a plan here and changes
// nothing on either site; applying and rolling back (they rewrite this site,
// code pages -- server-side JavaScript -- included), settings, peers (credentials)
// and table data modes are admin. The machine API (peers calling peers) is never a
// tool, and there is no push: pulling is the path.
//
// Secrets: a peer's shared secret is never returned, except the one this site
// GENERATES when pairing or rotating, returned once by that tool (as the UI shows
// it once). A stored plan holds the source's full configurations and managed rows,
// so a plan is shown trimmed (names, counts, gates) or one entity at a time, both
// through ctx.redact; the managed rows themselves are never shown.
const ops = require("./operations");
const diff = require("./diff");
const planLib = require("./plan");
const peers = require("./peers");
const { requireEnv } = require("./env");
const {
PLAN_STATUS,
DATA_MODES,
DELETE_POLICY,
SYNCABLE_CONFIG_KEYS
} = require("./constants");
const PREFIX = "sitesync_";
// get_plan's entity kind for the config settings a plan overwrites.
const CONFIG_KIND = "config";
const PEER_ARG = { type: ["string", "integer"], description: "The peer: its id, env_id or label (sitesync_list_peers lists them)." };
const PLAN_ARG = { type: "string", minLength: 1, description: "The plan id (sitesync_plan_pull returns it; sitesync_list_plans lists plans)." };
const PRUNABLE_ARG = { type: "array", items: { type: "string", minLength: 1 }, description: "Names of the tables whose DATA may be destroyed (sitesync_get_plan data_drops.tables): a drop of a table with rows, or of a field of a populated table, happens only for a table named here. Never pre-fill it: name only the tables you mean to lose." };
const CONFIRM_ARG = { type: "boolean", description: "The data-loss acknowledgement the on_delete=confirm policy requires besides prunable (sitesync_get_plan data_drops.confirm_data_drops_needed)." };
const tool = (name, scope, title, description, properties, required, handler, flags) => {
return {
name: PREFIX + name,
title: title,
description: description,
scope: scope,
inputSchema: { type: "object", properties, required: required || [], additionalProperties: false },
handler: handler,
...(flags || {})
};
};
// An OpError as a tool error the model sees; a failed apply whose plan has a
// restore point says how to revert it.
const run = async (ctx, fn) => {
try {
return await fn();
} catch (e) {
if (e instanceof ops.OpError) {
const extra = e.extra || {};
ctx.fail(extra.canRollback ? `${e.message} (plan ${extra.planId} has a restore point: sitesync_rollback reverts what its apply changed)` : e.message);
}
throw e;
}
};
// ---------------- Views ----------------
const envView = (env) => {
return {
env_id: env.env_id,
label: env.env_label,
sync_mode: env.sync_mode,
on_delete: env.on_delete,
on_apply: env.on_apply,
require_tls: !!env.require_tls
};
};
const peerView = (peer) => {
return {
peer_id: peer.peer_id,
label: peer.label,
env_id: peer.env_id,
base_url: peer.base_url,
require_tls: peer.require_tls,
created_at: peer.created_at,
last_seen_at: peer.last_seen_at
};
};
// The name an entity of a plan kind is listed under (and found by, besides its
// identity key): roles by role name, models as <table>.<model>.
const entityName = (kind, entry) => {
if (kind === "roles") {
return entry.role || String(entry.id);
}
if (kind === "models") {
return `${entry.table_name}.${entry.name}`;
}
return entry.name;
};
// Names per change of every kind the plan touches (tables with their field and
// constraint changes; renames are the plan's rename_candidates).
const changesView = (plan) => {
const out = {};
for (const kind of diff.PLAN_KINDS.filter((k) => plan.summary[k] > 0)) {
const b = plan.entities[kind];
if (kind === "tables") {
out.tables = {
create: b.create.map((t) => t.name),
update: b.update.map((u) => ({
name: u.name,
props_changed: u.propsChanged,
fields_added: u.fields.create.map((f) => f.name),
fields_changed: u.fields.update.map((f) => f.name),
fields_dropped: u.fields.delete.map((f) => f.name),
constraints_added: u.constraints.add.length,
constraints_removed: u.constraints.remove.length,
row_count: u.rowCount
})),
delete: b.delete.map((d) => ({ name: d.name, row_count: d.rowCount }))
};
continue;
}
out[kind] = {
create: b.create.map((e) => entityName(kind, e)),
update: b.update.map((u) => entityName(kind, u.to)),
delete: b.delete.map((e) => entityName(kind, e))
};
}
return out;
};
// A plan, trimmed: what it does (names and counts, never definitions or rows),
// what it destroys, the gates an apply must pass, and what can be done with it now.
const planView = (info) => {
const { loaded, peer, env, standing } = info;
const plan = loaded.plan;
const dropTables = planLib.dataDropTables(plan);
return {
plan_id: loaded.plan_id,
status: loaded.status,
direction: loaded.direction,
peer: peer ? peerView(peer) : null,
source_env_id: loaded.source_env_id,
created_at: loaded.created_at,
...standing,
in_sync: diff.isEmptyPlan(plan),
sync_mode: plan.sync_mode,
summary: plan.summary,
changes: changesView(plan),
destructive: plan.destructive.map((d) => ({ text: d.text, data_bearing: d.dataBearing })),
rename_candidates: plan.entities.tables.rename.map((r) => ({ from: r.from, to: r.to, row_count: r.rowCount })),
data_drops: {
tables: dropTables,
on_delete: env.on_delete,
confirm_data_drops_needed: dropTables.length > 0 && env.on_delete === DELETE_POLICY.CONFIRM,
refused_by_policy: dropTables.length > 0 && env.on_delete === DELETE_POLICY.REFUSE
},
config_changes: Object.keys(plan.config.sets),
rows: plan.rows.perTable,
rows_skipped: plan.rows.skipped,
warnings: plan.warnings
};
};
// One entity of a plan: what the plan does to it, the source's definition (to)
// and this site's definition now (current; null when absent).
const entityView = (info, kind, name, ctx) => {
const { loaded, current } = info;
const plan = loaded.plan;
const fail = () => ctx.fail(`plan ${loaded.plan_id} does not change ${kind} "${name}" (sitesync_get_plan without entity lists what it changes)`);
if (kind === CONFIG_KIND) {
if (!Object.prototype.hasOwnProperty.call(plan.config.sets, name)) {
return fail();
}
const now = current.pack.config;
return { kind, name, action: "update", to: plan.config.sets[name], current: Object.prototype.hasOwnProperty.call(now, name) ? now[name] : null };
}
const byKey = (pack, key) => pack[kind].find((e) => diff.keyOfKind(kind, e) === key) || null;
const named = (e) => entityName(kind, e) === name || diff.keyOfKind(kind, e) === name;
const b = plan.entities[kind];
const created = b.create.find(named);
if (created) {
const key = diff.keyOfKind(kind, created);
return { kind, name: entityName(kind, created), action: "create", to: created, current: byKey(current.pack, key) };
}
if (kind === "tables") {
const u = b.update.find((x) => x.name === name);
if (u) {
return {
kind, name, action: "update",
props_changed: u.propsChanged,
fields: { added: u.fields.create.map((f) => f.name), changed: u.fields.update, dropped: u.fields.delete.map((f) => f.name) },
constraints: u.constraints,
row_count: u.rowCount,
to: byKey(loaded.desired, name),
current: byKey(current.pack, name)
};
}
const d = b.delete.find((x) => x.name === name);
if (d) {
return { kind, name, action: "delete", row_count: d.rowCount, to: null, current: byKey(current.pack, name) };
}
const r = b.rename.find((x) => x.from === name || x.to === name);
if (r) {
return { kind, name, action: "rename", from: r.from, row_count: r.rowCount, to: byKey(loaded.desired, r.to), current: byKey(current.pack, r.from) };
}
return fail();
}
const u = b.update.find((x) => named(x.to));
if (u) {
return { kind, name: entityName(kind, u.to), action: "update", to: u.to, current: byKey(current.pack, u.key) };
}
const d = b.delete.find(named);
if (d) {
return { kind, name: entityName(kind, d), action: "delete", to: null, current: byKey(current.pack, diff.keyOfKind(kind, d)) };
}
return fail();
};
// ---------------- Handlers ----------------
const getStatus = async () => {
const s = await ops.status();
return {
...envView(s.env),
created_at: s.env.created_at,
version: s.version,
peer_count: s.peerCount,
synced_tables: s.syncedTables,
session_secret_configured: s.sessionSecretConfigured,
apply_lock: s.applyLock
};
};
const listPeers = async () => {
const env = await requireEnv();
return { this_env_id: env.env_id, peers: (await peers.listPeers()).map(peerView) };
};
const peerHealth = async (args) => {
const h = await ops.peerHealth(args.peer);
if (!h.reachable) {
return { peer: peerView(h.peer), reachable: false, error: h.error };
}
return {
peer: peerView(h.peer),
reachable: true,
plugin: h.health.plugin,
version: h.health.version,
env_id: h.health.env_id,
label: h.health.label,
parity_warnings: h.parityWarnings
};
};
const getPlan = async (args, ctx) => {
const info = await ops.inspectPlan(args.plan_id);
return ctx.redact(args.entity ? entityView(info, args.entity.kind, args.entity.name, ctx) : planView(info));
};
const pullPlan = async (args, ctx) => {
const pulled = await ops.pullPlan(args.peer);
return getPlan({ plan_id: pulled.plan_id }, ctx);
};
const applyPlan = async (args, ctx) => {
const r = await ops.applyPlan(args.plan_id, {
prunable: args.prunable,
confirmDataDrops: args.confirm_data_drops,
confirmRenames: args.confirm_renames
});
return ctx.redact({
plan_id: r.plan_id,
applied: r.applied,
snapshot_taken: r.snapshotTaken,
summary: r.summary,
renamed: r.renames.renamed,
deleted: r.deletes.deleted,
deletes_skipped: r.deletes.skipped,
rows: r.rows.perTable,
warnings: r.warnings,
can_rollback: r.canRollback
});
};
const rollbackPlan = async (args, ctx) => {
const r = await ops.rollbackPlan(args.plan_id, { prunable: args.prunable, confirmDataDrops: args.confirm_data_drops });
return ctx.redact({ plan_id: r.plan_id, rolled_back: r.rolledBack, restored_tables: r.restoredTables, warnings: r.warnings });
};
// A pairing or rotation result: the secret only when this site generated it.
const secretResult = async (r, generatedStep, pastedStep) => {
const env = await requireEnv();
const out = { peer: peerView(r.peer), this_env_id: env.env_id };
if (r.generated) {
out.shared_secret = r.secret;
}
out.next_step = r.generated ? generatedStep : pastedStep;
return out;
};
const addPeer = async (args) => {
const r = await ops.addPeer({
envId: args.env_id,
label: args.label,
baseUrl: args.base_url,
requireTls: args.require_tls,
existingSecret: args.existing_secret
});
return secretResult(r,
"shared_secret is shown only now: enter it, with this_env_id, in the peer's own site-sync pairing form for this site.",
"The peer must pair with this site's this_env_id and the same secret.");
};
const rotatePeerSecret = async (args) => {
return secretResult(await ops.rotatePeerSecret(args.peer, args.secret),
"shared_secret is shown only now: on the peer, replace this site's pairing secret with it (Peers -> Replace a peer's secret); until then the pairing cannot authenticate.",
"The given secret is installed; the pairing works once the peer uses the same one.");
};
const deletePeer = async (args) => {
const r = await ops.deletePeer(args.peer);
return { deleted: peerView(r.peer), plans_kept_for_rollback: r.keptForRollback };
};
const setTableMode = async (args) => {
const r = await ops.setTableMode(args.table, args.mode);
return { table: r.table, mode: r.mode, previous_mode: r.previous };
};
const localState = async () => {
const s = await ops.localState();
return { hash: s.hash, counts: s.counts, config_settings: s.config, rows: s.rows, warnings: s.warnings };
};
// ---------------- Definitions ----------------
const SETTING_ENUM = (key) => ops.SETTING_OPTIONS[key].map((o) => o.value);
const KIND_ENUM = diff.PLAN_KINDS.concat([CONFIG_KIND]);
const tools = () => {
return [
tool("status", "read", "site-sync status",
"This site's site-sync identity and policies: env_id (a peer pairs with it), label, sync_mode (mirror prunes what the source dropped; additive never prunes), on_delete (how drops that destroy data are gated), on_apply (whether paired peers may apply remotely), require_tls, the plugin version, how many peers are paired, how many tables are managed / starter (row data synced), whether the session secret that seals peer secrets is configured, and apply_lock: idle, running (holder: the apply or rollback running now, its plan, since when, and when its lease lapses if its process stops) or interrupted (plans an apply or rollback left in flight when its process stopped -- this site may be partly changed; sitesync_rollback reverts a plan with a restore point, and any next apply or rollback releases them).",
{}, [], () => getStatus(), { idempotent: true }),
tool("list_peers", "read", "List peers",
"The paired peers (site-sync instances this site can pull from): id, label, env_id, base_url, require_tls, when paired and last seen; plus this site's own env_id. Shared secrets are never shown.",
{}, [], () => listPeers(), { idempotent: true }),
tool("peer_health", "read", "Check a peer",
"Call a peer's signed health endpoint: whether it answers and authenticates (a wrong or rotated secret shows as unreachable with the reason), its site-sync version, env_id and label, and plugin parity warnings (plugins missing on either side or at other versions -- applying state that needs a plugin this site lacks can fail). Changes nothing.",
{ peer: PEER_ARG }, ["peer"], (a, ctx) => run(ctx, () => peerHealth(a)), { idempotent: true, openWorld: true }),
tool("list_tables", "read", "List tables and data modes",
"Every application table with its row count and data mode: user (default: rows are this site's own and never synced), starter (rows seeded once from the source into a fresh table, then this site owns them; starter_shipped_at says when), managed (the source is canonical: rows are mirrored, added / updated / removed). locked tables (users) are always user.",
{}, [], async () => ({ tables: await ops.listTables() }), { idempotent: true }),
tool("list_plans", "read", "List plans",
"Recorded plans, newest first: status (pending = previewed, not applied; applied; rolled_back; applying / rolling_back = in flight; interrupted = its process stopped part-way, and can_apply / can_rollback then tell what the next apply or rollback can do once it recovers the plan), direction (pull; push = sent by a peer; rollback = the internal plan of a rollback), the source peer, has_restore_point, can_apply (pending or rolled_back and this site unchanged since the preview), can_rollback, and stale (this site changed since the preview, so an apply would be refused: pull again).",
{
status: { type: "string", enum: Object.values(PLAN_STATUS), description: "Only plans in this status." },
limit: { type: "integer", minimum: 1, maximum: planLib.PLAN_RETENTION, description: `At most this many (default ${ops.PLAN_LIST_DEFAULT}).` }
}, [], (a, ctx) => run(ctx, async () => ({ plans: await ops.listPlans({ status: a.status, limit: a.limit }) })), { idempotent: true }),
tool("get_plan", "read", "Review a plan",
"A plan, trimmed: status, standing (can_apply, stale, can_rollback, has_restore_point, interrupted = left applying / rolling_back by a process that stopped), in_sync (nothing to do), summary counts, changes (entity names per kind and per create / update / delete; table field and constraint changes), destructive (every drop, data_bearing ones destroy rows), rename_candidates (detected table renames: pass their from names in confirm_renames to keep the rows), data_drops (the tables apply_plan's prunable must name to destroy their data, and whether confirm_data_drops is needed under on_delete), config_changes, rows (managed / starter row inserts, updates, deletes per table), rows_skipped and warnings (code pages = server-side JavaScript it installs, plugin parity, pre-flight checks). With entity: that one entity -- the action, the source's definition (to) and this site's (current), secret-looking keys masked.",
{
plan_id: PLAN_ARG,
entity: {
type: "object",
description: "Show one entity of the plan instead of the summary.",
properties: {
kind: { type: "string", enum: KIND_ENUM, description: `The kind: ${KIND_ENUM.join(", ")} (config = a config setting the plan overwrites).` },
name: { type: "string", minLength: 1, description: "Its name as the plan's changes list it (roles by role name, models as <table>.<model>), or the setting name for config." }
},
required: ["kind", "name"],
additionalProperties: false
}
}, ["plan_id"], (a, ctx) => run(ctx, () => getPlan(a, ctx)), { idempotent: true }),
tool("local_state", "read", "This site's desired state",
"This site's design state as a peer pulling from it would get it: the content hash (equal hashes = identical state), entity counts per kind, the syncable config settings present (only " + SYNCABLE_CONFIG_KEYS.join(", ") + " ever sync), and the row count of each managed / starter table it ships.",
{}, [], () => localState(), { idempotent: true }),
tool("plan_pull", "write", "Pull a plan from a peer",
"Fetch a peer's current design state (signed; can take minutes for a large site) and record a plan for making THIS site match it. Nothing changes on either site: review the plan (returned as sitesync_get_plan shows it), then sitesync_apply_plan applies it (admin).",
{ peer: PEER_ARG }, ["peer"], (a, ctx) => run(ctx, () => pullPlan(a, ctx)), { openWorld: true }),
tool("apply_plan", "admin", "Apply a plan",
"Make this site match a pulled plan: creates / updates / (under sync_mode mirror) deletes tables, fields, views, pages, triggers, roles, library, tags, models and code pages (server-side JavaScript that then runs on this site), overwrites the synced config settings and syncs managed / starter rows. Refused if this site changed since the plan was pulled (pull again). A restore point is saved first (sitesync_rollback reverts). Data is destroyed only for tables named in prunable (plus confirm_data_drops under on_delete=confirm; never under refuse); a detected table rename keeps its rows only when confirmed, else it is a create + gated drop. Only one apply or rollback runs on this site at a time (across every server process); while one runs, this is refused with what is running.",
{
plan_id: PLAN_ARG,
confirm_renames: { type: "array", items: { type: "string", minLength: 1 }, description: "Old names (rename_candidates from) of the detected table renames to apply as data-preserving renames." },
prunable: PRUNABLE_ARG,
confirm_data_drops: CONFIRM_ARG
}, ["plan_id"], (a, ctx) => run(ctx, () => applyPlan(a, ctx)), { destructive: true }),
tool("rollback", "admin", "Roll back an apply",
"Revert an applied plan (or one whose apply failed or was interrupted part-way) to the restore point saved before it: metadata, config and code pages back to the pre-apply state (in mirror mode), inverse renames, rows it inserted into managed / starter tables removed, captured rows restored. Once per plan. A table the apply created that has rows by now is dropped only when named in prunable (plus confirm_data_drops under on_delete=confirm).",
{
plan_id: PLAN_ARG,
prunable: { ...PRUNABLE_ARG, description: "Names of tables the apply created that hold rows by now and may be dropped with them." },
confirm_data_drops: CONFIRM_ARG
}, ["plan_id"], (a, ctx) => run(ctx, () => rollbackPlan(a, ctx)), { destructive: true }),
tool("update_settings", "admin", "Change site-sync settings",
"Change some of this site's site-sync settings (the others are kept). on_apply=auto lets paired peers apply plans they push here remotely (never dropping data or renaming tables); sync_mode=mirror makes applies prune what the source dropped; on_delete governs drops that destroy data. Returns the settings.",
{
env_label: { type: ["string", "null"], description: "This site's label (dev, test, prod ...); empty or null clears it." },
sync_mode: { type: "string", enum: SETTING_ENUM("sync_mode"), description: "mirror = prune entities the source dropped; additive = never prune." },
on_delete: { type: "string", enum: SETTING_ENUM("on_delete"), description: "confirm = destroy data only for tables named in prunable plus confirm_data_drops; refuse = never drop a table or field holding data; auto = tables named in prunable, no acknowledgement." },
on_apply: { type: "string", enum: SETTING_ENUM("on_apply"), description: "confirm = only an admin applies (here or in the UI); auto = paired peers may also apply plans they pushed, remotely." },
require_tls: { type: "boolean", description: "Refuse inbound peer traffic that did not arrive over HTTPS." }
}, [], (a, ctx) => run(ctx, async () => envView(await ops.updateSettings(a))), { idempotent: true }),
tool("add_peer", "admin", "Pair with a peer",
"Pair with another site-sync instance (to pull from it). Without existing_secret a shared secret is generated and returned ONCE as shared_secret, as the admin UI shows it once: enter it with this site's env_id in the peer's pairing form. With existing_secret (the secret the peer generated for this pairing) nothing secret is returned.",
{
env_id: { type: "string", minLength: 1, description: "The peer's env_id (its sitesync_status / dashboard shows it)." },
base_url: { type: "string", minLength: 1, description: "The peer's base URL, http(s)://host[:port]." },
label: { type: "string", description: "A label for the peer (dev, prod ...)." },
require_tls: { type: "boolean", description: "Only talk to this peer over HTTPS." },
existing_secret: { type: "string", description: "The shared secret the peer already generated for this pairing (64 hex characters); omit to generate one." }
}, ["env_id", "base_url"], (a, ctx) => run(ctx, () => addPeer(a))),
tool("rotate_peer_secret", "admin", "Replace a peer's secret",
"Replace a pairing's shared secret. Without secret a new one is generated and returned ONCE as shared_secret (the pairing then fails until the peer installs it); with secret (what the peer displayed after rotating its side) that secret is installed and nothing secret is returned.",
{
peer: PEER_ARG,
secret: { type: "string", description: "The secret the peer generated (64 hex characters); omit to generate one here." }
}, ["peer"], (a, ctx) => run(ctx, () => rotatePeerSecret(a)), { destructive: true }),
tool("delete_peer", "admin", "Delete a peer",
"Unpair a peer: its pairing and the plans pulled from it are deleted, except applied plans that can still be rolled back (kept, detached from any peer).",
{ peer: PEER_ARG }, ["peer"], (a, ctx) => run(ctx, () => deletePeer(a)), { destructive: true }),
tool("set_table_mode", "admin", "Set a table's data mode",
"Choose whether site-sync syncs a table's ROWS: user (never), starter (seeded once from the source into a fresh table) or managed (mirrored from the source: later applies add, update and REMOVE rows here to match it). Use starter / managed only for reference / lookup tables, never user-entered data. starter / managed adds a hidden _ss_row_uuid identity field. The users table and _-prefixed tables cannot be synced.",
{
table: { type: "string", minLength: 1, description: "The table name (sitesync_list_tables)." },
mode: { type: "string", enum: Object.values(DATA_MODES), description: "user, starter or managed." }
}, ["table", "mode"], (a, ctx) => run(ctx, () => setTableMode(a)), { destructive: true, idempotent: true }),
];
};
module.exports = { tools, PREFIX };

512
lib/operations.js Normal file
View file

@ -0,0 +1,512 @@
// The site-sync admin operations, transport-free.
//
// The admin UI (routes.js) and the MCP tools (mcpTools.js, served by the
// saltcorn-mcp plugin) are both thin adapters over these, so the rules live in one
// place: which settings values are valid, which peer a reference names, how a pull
// is orchestrated (plugin parity, signed state fetch, plan build), what a plan
// allows now and which tables may take which data mode. That only one apply or
// rollback runs on a site at a time is enforced below these, by the site lock the
// apply and rollback libraries take (lib/siteLock.js), so every caller -- the
// machine API's push-apply included -- shares it.
//
// A failure the caller can act on throws OpError(message, extra); each adapter maps
// it to its own shape (a flash, the apply-failure page, a tool error, a signed
// reply). Library steps whose Error messages are written for the admin (what the UI
// has always flashed) are rethrown as OpError with the same message.
const Table = require("@saltcorn/data/models/table");
const { getSessionSecret } = require("./common/sessionKey");
const { parseSecretHex } = require("./common/peerStore");
const { localPluginList, comparePluginLists } = require("./common/pluginParity");
const {
PLUGIN_NAME,
PLUGIN_VERSION,
SYNC_MODES,
DELETE_POLICY,
APPLY_POLICY,
PLAN_STATUS,
DATA_MODES,
isInfraName,
isLockedTable,
isSyncedMode
} = require("./constants");
const { requireEnv, setEnvFields } = require("./env");
const peers = require("./peers");
const { peerJson, API_PATHS } = require("./transport");
const capture = require("./capture");
const planLib = require("./plan");
const applyLib = require("./apply");
const rollback = require("./rollback");
const rowsLib = require("./rows");
const siteLock = require("./siteLock");
// Plans listed when no limit is given.
const PLAN_LIST_DEFAULT = 20;
// Settings choices: one list per policy drives the dashboard form, the MCP tool
// schema and updateSettings' validation.
const SETTING_OPTIONS = {
sync_mode: [
{ label: "mirror (prune entities the source dropped)", value: SYNC_MODES.MIRROR },
{ label: "additive (never prune)", value: SYNC_MODES.ADDITIVE },
],
on_delete: [
{ label: "confirm (drop only tables typed into the apply form, plus the confirmation tick)", value: DELETE_POLICY.CONFIRM },
{ label: "refuse (never drop a table/field with data)", value: DELETE_POLICY.REFUSE },
{ label: "auto (drop tables typed into the apply form, no confirmation tick)", value: DELETE_POLICY.AUTO },
],
on_apply: [
{ label: "confirm (only this admin UI applies; remote push-apply is refused)", value: APPLY_POLICY.CONFIRM },
{ label: "auto (paired peers may also push-apply remotely; never drops data or renames tables)", value: APPLY_POLICY.AUTO },
]
};
class OpError extends Error {
constructor(message, extra) {
super(message);
this.name = "OpError";
this.extra = extra;
}
}
// Run a library step; an Error it throws (written for the admin: what the UI
// flashes) becomes an OpError with the same message.
const adminStep = async (fn) => {
try {
return await fn();
} catch (e) {
throw e instanceof OpError ? e : new OpError(e.message);
}
};
// ---------------- Environment ----------------
// Return `value` if it is one of the options' values; otherwise throw.
const pickOption = (field, value, options) => {
const hit = options.find((o) => o.value === value);
if (!hit) {
throw new OpError(`invalid ${field}: ${value}`);
}
return hit.value;
};
// Normalizer (and validator) of each setting updateSettings accepts.
const SETTINGS = {
env_label: (v) => String(v === null || v === undefined ? "" : v).trim() || null,
sync_mode: (v) => pickOption("sync mode", v, SETTING_OPTIONS.sync_mode),
on_delete: (v) => pickOption("delete policy", v, SETTING_OPTIONS.on_delete),
on_apply: (v) => pickOption("apply policy", v, SETTING_OPTIONS.on_apply),
require_tls: (v) => (v ? 1 : 0)
};
// Whether the session secret the peer-secret key derives from is configured (its
// value is never exposed).
const sessionSecretConfigured = () => {
try {
getSessionSecret(PLUGIN_NAME);
return true;
} catch (e) {
return false;
}
};
// The environment, its peers and synced tables at a glance.
const status = async () => {
const env = await requireEnv();
const modes = Object.values(await rowsLib.modeMap());
const syncedTables = {};
for (const mode of Object.values(DATA_MODES).filter(isSyncedMode)) {
syncedTables[mode] = modes.filter((m) => m === mode).length;
}
return {
env: env,
version: PLUGIN_VERSION,
peerCount: (await peers.listPeers()).length,
syncedTables: syncedTables,
sessionSecretConfigured: sessionSecretConfigured(),
applyLock: await applyLockView()
};
};
// The site lock for an admin: { state: running, holder } while an apply or
// rollback holds a live lease; { state: interrupted, plans } when plans were left
// in flight by a process that stopped (each with whether a restore point can roll
// it back; the next apply or rollback recovers them); else { state: idle }.
const applyLockView = async () => {
const lock = await siteLock.lockState();
if (lock && lock.live) {
return { state: siteLock.LOCK_STATES.RUNNING, holder: lock };
}
const restoring = await rollback.restorePointIds();
const plans = [];
for (const status of siteLock.IN_FLIGHT) {
for (const r of await planLib.listPlans({ status, limit: planLib.PLAN_RETENTION })) {
plans.push({ plan_id: r.plan_id, status: r.status, direction: r.direction, has_restore_point: restoring.has(r.plan_id) });
}
}
return plans.length > 0 ? { state: siteLock.LOCK_STATES.INTERRUPTED, plans } : { state: siteLock.LOCK_STATES.IDLE };
};
// Change some settings (a partial patch of SETTINGS keys; undefined = keep).
// Returns the updated environment.
const updateSettings = async (patch) => {
const fields = {};
for (const [key, value] of Object.entries(patch || {})) {
if (value === undefined) {
continue;
}
if (!Object.prototype.hasOwnProperty.call(SETTINGS, key)) {
throw new OpError(`unknown setting: ${key}`);
}
fields[key] = SETTINGS[key](value);
}
if (Object.keys(fields).length === 0) {
throw new OpError(`no settings given (any of ${Object.keys(SETTINGS).join(", ")})`);
}
return await setEnvFields(fields);
};
// ---------------- Peers ----------------
const peerName = (peer) => {
return peer.label || peer.env_id;
};
// A peer by reference: its peer_id (a number, or a string of digits), else its
// env_id, else its label (which must then be unique).
const resolvePeer = async (ref) => {
const key = String(ref === null || ref === undefined ? "" : ref).trim();
if (!key) {
throw new OpError("peer required");
}
const list = await peers.listPeers();
const found = list.find((p) => String(p.peer_id) === key) || list.find((p) => p.env_id === key);
if (found) {
return found;
}
const labelled = list.filter((p) => p.label === key);
if (labelled.length !== 1) {
throw new OpError(labelled.length ? `several peers are labelled "${key}"; name it by id or env_id` : `unknown peer "${key}"`);
}
return labelled[0];
};
// Pair with a peer. Returns { peer, secret (hex), generated }: the plaintext shared
// secret exists only here (the admin copies it to the other side); it is generated
// unless existingSecret (64 hex characters) was given.
const addPeer = async ({ envId, label, baseUrl, requireTls, existingSecret }) => {
return await adminStep(async () => {
const pasted = parseSecretHex(existingSecret);
const { peer, secret } = await peers.addPeer({
envId: String(envId || "").trim(),
label: String(label || "").trim() || null,
baseUrl: String(baseUrl || "").trim(),
requireTls: !!requireTls,
existingSecret: pasted
});
return { peer, secret: secret.toString("hex"), generated: !pasted };
});
};
// Replace a peer's shared secret: with secretHex (the value the OTHER side rotated
// to and displayed) or, when blank, a generated one this side displays. Returns
// { peer, secret (hex), generated }.
const rotatePeerSecret = async (ref, secretHex) => {
const peer = await resolvePeer(ref);
return await adminStep(async () => {
const pasted = parseSecretHex(secretHex);
const { secret } = await peers.rotatePeerSecret(peer.peer_id, pasted);
return { peer, secret: secret.toString("hex"), generated: !pasted };
});
};
// Unpair a peer. Its plans go with it (a reused peer_id must never inherit them),
// except those that can still be rolled back: kept, detached from any peer.
// Returns { peer, keptForRollback }.
const deletePeer = async (ref) => {
const peer = await resolvePeer(ref);
const keptForRollback = await rollback.detachRollbackablePlans(peer.peer_id);
await peers.deletePeer(peer.peer_id);
return { peer, keptForRollback };
};
// The peer's signed health answer ({ plugin, version, env_id, label, plugins }).
const fetchPeerHealth = async (peerRow, env) => {
return await peerJson(peerRow, env.env_id, "GET", API_PATHS.HEALTH, null);
};
// Plugin parity warnings from a health answer (empty when both sides match).
const parityWarnings = (localPlugins, health) => {
if (!Array.isArray(health.plugins)) {
return ["peer's health response carries no plugin list"];
}
return comparePluginLists(localPlugins, health.plugins);
};
// Compare local plugin list with peer's. Returns array of human-readable
// warning strings (empty if all match). Best-effort: if the peer's health
// endpoint is unreachable or errors, returns a single warning and lets the caller
// proceed. Plugin PARITY (presence + version) matters for state sync: applying a
// snapshot that references a plugin the peer lacks will fail -- but we never push
// plugin CONFIG (that would leak deploy_private_key), only check parity.
const diffPluginsWithPeer = async (peerRow, env, localPlugins) => {
let health;
try {
health = await fetchPeerHealth(peerRow, env);
} catch (e) {
return [`could not check peer plugin list: ${e.message}`];
}
return parityWarnings(localPlugins, health);
};
// Whether a peer answers, as whom, at which version, and its plugin parity.
// An unreachable or unauthenticated peer is a result (reachable: false), not an
// error.
const peerHealth = async (ref) => {
const peer = await resolvePeer(ref);
const env = await requireEnv();
let health;
try {
health = await fetchPeerHealth(peer, env);
} catch (e) {
return { peer, reachable: false, error: e.message };
}
return { peer, reachable: true, health, parityWarnings: parityWarnings(await localPluginList(), health) };
};
// Target-side helper: pull a peer's desired-state pack over the signed transport.
// Returns { hash, pack, rows, env_id }; throws on transport/authz failure or when
// the (signed) answer does not come from the paired env.
const fetchPeerState = async (peerRow, env) => {
const body = await peerJson(peerRow, env.env_id, "GET", API_PATHS.STATE, null);
if (!body.pack || body.env_id !== peerRow.env_id) {
throw new Error("peer state response has no pack or names the wrong env_id");
}
return { hash: body.hash, pack: body.pack, rows: body.rows || {}, env_id: body.env_id };
};
// ---------------- Plans ----------------
// Pull a peer's current desired state and persist a plan for making THIS site
// match it. Nothing changes on either site. Returns { plan_id, plan, peer }.
const pullPlan = async (ref) => {
const peer = await resolvePeer(ref);
return await adminStep(async () => {
const env = await requireEnv();
const warnings = await diffPluginsWithPeer(peer, env, await localPluginList());
const state = await fetchPeerState(peer, env);
const built = await planLib.buildPlan({
peerRow: peer, env, desired: state.pack, desiredHash: state.hash, sourceEnvId: state.env_id,
direction: "pull", extraWarnings: warnings, sourceRows: state.rows
});
return { ...built, peer };
});
};
const requirePlanId = (planId) => {
const id = String(planId || "").trim();
if (!id) {
throw new OpError("plan_id required");
}
return id;
};
// What can be done with a plan (row: its status, claimed_from + precondition hash)
// now: apply it (its status allows it and this site has not changed since its
// preview -- else the drift check refuses it: stale), and roll it back (a restore
// point exists and its status allows it). stale is null when the plan cannot be
// applied anyway. interrupted = in flight (applying / rolling_back) while no live
// lease holds the site lock (lock: siteLock.lockState()): the process running it
// stopped, so it is judged by the status the next apply or rollback recovers it to.
const planStanding = (row, hasRestorePoint, currentHash, lock) => {
const interrupted = siteLock.IN_FLIGHT.includes(row.status) && !(lock && lock.live);
const status = interrupted ? siteLock.recoveredStatus(row) : row.status;
const applicable = applyLib.APPLY_FROM.includes(status);
const stale = applicable ? currentHash !== row.precondition_hash : null;
return {
has_restore_point: hasRestorePoint,
can_apply: applicable && !stale,
can_rollback: hasRestorePoint && rollback.ROLLBACK_FROM.includes(status),
stale: stale,
interrupted: interrupted
};
};
// The most recent plans, newest first (status: only those; limit: 1 to
// PLAN_RETENTION, default PLAN_LIST_DEFAULT), each with its peer and standing.
const listPlans = async ({ status, limit } = {}) => {
if (status !== undefined && !Object.values(PLAN_STATUS).includes(status)) {
throw new OpError(`invalid plan status: ${status} (one of ${Object.values(PLAN_STATUS).join(", ")})`);
}
const n = limit === undefined ? PLAN_LIST_DEFAULT : limit;
if (!Number.isInteger(n) || n < 1 || n > planLib.PLAN_RETENTION) {
throw new OpError(`limit must be an integer from 1 to ${planLib.PLAN_RETENTION}`);
}
const rows = await planLib.listPlans({ status, limit: n });
const restoring = await rollback.restorePointIds();
const peerById = new Map((await peers.listPeers()).map((p) => [p.peer_id, p]));
const lock = await siteLock.lockState();
const current = rows.some((r) => applyLib.APPLY_FROM.includes(r.status) || siteLock.IN_FLIGHT.includes(r.status)) ? await capture.buildDesiredState() : null;
return rows.map((r) => ({
plan_id: r.plan_id,
status: r.status,
direction: r.direction,
peer: peerById.has(r.peer_id) ? peerName(peerById.get(r.peer_id)) : null,
source_env_id: r.source_env_id,
created_at: r.created_at,
...planStanding(r, restoring.has(r.plan_id), current && current.hash, lock)
}));
};
// A stored plan with what it needs to be judged: { loaded (planLib.loadPlan), peer
// (or null), env (current policies), current (this site's desired state now),
// standing }.
const inspectPlan = async (planId) => {
const id = requirePlanId(planId);
const loaded = await planLib.loadPlan(id);
if (!loaded) {
throw new OpError(`unknown plan ${id}`);
}
const current = await capture.buildDesiredState();
const hasPeer = loaded.peer_id !== null && loaded.peer_id !== undefined;
return {
loaded: loaded,
peer: hasPeer ? await peers.findPeer(loaded.peer_id) : null,
env: await requireEnv(),
current: current,
standing: planStanding(loaded, await rollback.hasRestorePoint(id), current.hash, await siteLock.lockState())
};
};
// Apply a previewed plan. The human gates are the only options a caller has (never
// force): prunable = table names whose data may be dropped, confirmDataDrops = the
// data-loss acknowledgement (on_delete=confirm), confirmRenames = old names of
// detected renames to apply as data-preserving renames. Returns apply.applyPlan's
// result plus canRollback; a failure carries extra = { planId, canRollback } (a
// part-way failure leaves its restore point) -- except a refusal because another
// apply or rollback holds the site lock, which changed nothing.
const applyPlan = async (planId, { prunable, confirmDataDrops, confirmRenames } = {}) => {
const id = requirePlanId(planId);
let result;
try {
result = await applyLib.applyPlan(id, {
applyDeletes: true,
prunable: prunable || [],
confirmDataDrops: !!confirmDataDrops,
confirmRenames: confirmRenames || []
});
} catch (e) {
if (e instanceof siteLock.LockBusyError) {
throw new OpError(e.message);
}
throw new OpError(e.message, { planId: id, canRollback: await rollback.hasRestorePoint(id) });
}
return { ...result, canRollback: await rollback.hasRestorePoint(id) };
};
// Revert an applied plan to its restore point. prunable / confirmDataDrops gate
// dropping a table the apply created that has gained rows since.
const rollbackPlan = async (planId, { prunable, confirmDataDrops } = {}) => {
const id = requirePlanId(planId);
return await adminStep(() => rollback.rollbackApply(id, {
prunable: prunable || [],
confirmDataDrops: !!confirmDataDrops
}));
};
// ---------------- Tables ----------------
// Every application table (infra excluded): row count, data mode, whether it is
// locked to user mode, and when a starter table was seeded.
const listTables = async () => {
const records = await rowsLib.modeRecords();
const out = [];
for (const t of (await Table.find({})).filter((x) => !isInfraName(x.name))) {
const rec = records.get(t.name);
out.push({
name: t.name,
rows: await t.countRows(),
mode: (rec && rec.data_mode) || DATA_MODES.USER,
locked: isLockedTable(t.name),
starter_shipped_at: (rec && rec.starter_shipped_at) || null
});
}
return out;
};
// Set a table's data mode (rows.setDataMode validates the mode, the table and the
// infra/locked rules). Returns { table, mode, previous }.
const setTableMode = async (table, mode) => {
const name = String(table || "").trim();
const previous = await rowsLib.dataModeOf(name);
await adminStep(() => rowsLib.setDataMode(name, mode));
return { table: name, mode, previous };
};
// ---------------- This site's state ----------------
// This site's desired state as a peer pulling from it sees it: the content hash,
// the entity count per kind, the syncable config keys present, and the row count of
// each table it ships. Read-only (no identity backfill, unlike a real state answer).
const localState = async () => {
const { pack, hash } = await capture.buildDesiredState();
const counts = {};
for (const kind of capture.PACK_KINDS) {
counts[kind] = pack[kind].length;
}
const shipped = await rowsLib.shippedRowCounts();
return { hash, counts, config: Object.keys(pack.config), rows: shipped.counts, warnings: shipped.warnings };
};
module.exports = {
OpError,
SETTING_OPTIONS,
PLAN_LIST_DEFAULT,
status,
updateSettings,
peerName,
resolvePeer,
addPeer,
rotatePeerSecret,
deletePeer,
peerHealth,
diffPluginsWithPeer,
fetchPeerState,
pullPlan,
listPlans,
inspectPlan,
applyPlan,
rollbackPlan,
listTables,
setTableMode,
localState
};

25
lib/peerAuth.js Normal file
View file

@ -0,0 +1,25 @@
// Verify incoming HMAC-signed peer requests (X-SS-* headers, see constants.js
// HEADERS) and sign the responses -- site-sync's binding of the shared inbound
// peer auth (lib/common/peerServer.js, which documents the protocol). Requests
// use Content-Type application/vnd.site-sync+json so Saltcorn's express.json()
// leaves the raw bytes for the HMAC.
//
// requirePeerAuth(req, res) returns the authenticated peer row (req.body = the
// signed JSON body, {} when empty) or null after sending a 4xx; handlers answer
// with sendSignedJson / sendSigned (transport.js refuses any response whose
// signature does not verify against its own nonce).
const { createPeerAuth } = require("./common/peerServer");
const peers = require("./peers");
const { getEnv } = require("./env");
const { HEADERS, SEEN_NONCES_TABLE } = require("./constants");
const { PEER_LIMITS } = require("./transport");
module.exports = createPeerAuth({
headers: HEADERS,
nonceTable: SEEN_NONCES_TABLE,
maxBodyBytes: PEER_LIMITS.REQUEST_BODY_BYTES,
peers: peers,
getEnv: getEnv
});

21
lib/peers.js Normal file
View file

@ -0,0 +1,21 @@
// Peer model: CRUD on _ss_peers -- site-sync's binding of the shared peer store
// (lib/common/peerStore.js).
//
// Peer secrets are stored AES-256-GCM sealed under site-sync's own KEK
// (PEER_KEK_INFO, independent of dev-deploy's); plaintext only crosses the
// process boundary at pairing time (when the operator copies the secret to the
// other instance's UI) and at HMAC sign/verify time.
const { createPeerStore } = require("./common/peerStore");
const { PLUGIN_NAME, PEERS_TABLE, PEER_KEK_INFO } = require("./constants");
// Plans authored for/by the peer go with it: a deleted max peer_id is handed out
// again (MAX+1) to the next pairing, which must never inherit them (apiApply binds
// a plan to its peer_id).
module.exports = createPeerStore({
owner: PLUGIN_NAME,
table: PEERS_TABLE,
kek: { salt: PEER_KEK_INFO, info: PEER_KEK_INFO },
dependentTables: ["_ss_plans"]
});

320
lib/plan.js Normal file
View file

@ -0,0 +1,320 @@
// Plan assembly, persistence, and preview rendering.
//
// A plan is computed on the MUTATING (target) side: we capture the target's own
// current state, gather live row counts (so data-bearing drops are flagged
// accurately), diff against the source's desired state, fold in can_install_pack
// warnings, and persist the whole thing (plan + desired pack + a precondition hash
// of the target's current state) into _ss_plans under a plan_id. Apply later
// re-checks that precondition hash and refuses if the target drifted since preview
// (optimistic concurrency). Plans are raw/regenerable -- a lost plan just has to be
// re-previewed.
const db = require("@saltcorn/data/db");
const Table = require("@saltcorn/data/models/table");
const capture = require("./capture");
const diff = require("./diff");
const rows = require("./rows");
const { randomUuid } = require("./ids");
const { escapeHtml, ADMIN_URLS } = require("./render");
const { PLAN_STATUS } = require("./constants");
// Keep at most this many recent plans (one row is written per preview).
const PLAN_RETENTION = 200;
// Count rows for every current (non-infra) table so the diff can flag which
// deletes / field-drops actually destroy data. COUNT(*) per table -- acceptable
// for a plan preview. A table that cannot be counted is reported as a warning
// (apply re-counts live before any drop, so the preview is never the last gate).
const gatherRowCounts = async (currentPack, warnings) => {
const counts = {};
for (const t of currentPack.tables) {
const tbl = Table.findOne({ name: t.name });
try {
counts[t.name] = tbl ? await tbl.countRows() : 0;
} catch (e) {
counts[t.name] = 0;
warnings.push(`could not count rows of "${t.name}" (${e.message}); its drop is re-checked at apply time`);
}
}
return counts;
};
// Best-effort advisory warnings from Saltcorn's own pre-flight validator.
const collectWarnings = async (desired) => {
const warnings = [];
try {
const pack = require("@saltcorn/admin-models/models/pack");
const res = await pack.can_install_pack(desired);
if (res && res.warning) {
warnings.push(res.warning);
}
} catch (e) {
warnings.push(`could not run pre-flight check: ${e.message}`);
}
return warnings;
};
// Compute + persist a plan for making THIS instance's state match `desired`.
// Returns { plan_id, plan }.
const buildPlan = async ({ peerRow, env, desired, desiredHash, sourceEnvId, direction, extraWarnings, sourceRows }) => {
// NEVER trust the sender's sanitization: re-run it on the target before the
// pack influences the diff or the persisted plan (drops infra tables the peer
// may have injected, and any non-allow-listed config key).
desired = capture.sanitizeDesired(desired);
const current = await capture.buildDesiredState();
const countWarnings = [];
const rowCounts = await gatherRowCounts(current.pack, countWarnings);
const plan = diff.computePlan(desired, current.pack, {
syncMode: env.sync_mode,
onDelete: env.on_delete,
rowCounts: rowCounts
});
// Reference-row plan (opt-in managed/starter tables only). The counts feed the
// preview; the source manifest is stashed on the plan so apply can replay it.
// Note: install_pack must run before rows on apply, so the schema exists.
const rowManifest = sourceRows || {};
const rowPreview = await rows.previewRowSync(rowManifest);
plan.rows = rowPreview;
plan.sourceRows = rowManifest;
const rowOps = Object.values(rowPreview.perTable || {}).reduce((a, t) => a + t.inserts + t.updates + t.deletes, 0);
plan.summary.rows = rowOps;
plan.summary.total = (plan.summary.total || 0) + rowOps;
plan.source_env_id = sourceEnvId || (peerRow && peerRow.env_id) || null;
plan.target_env_id = env.env_id;
plan.source_hash = desiredHash;
plan.precondition_hash = current.hash;
plan.generated_at = new Date().toISOString();
plan.warnings = plan.warnings.concat(countWarnings, rowPreview.warnings, extraWarnings || [], await collectWarnings(desired));
const planId = randomUuid();
const schema = db.getTenantSchemaPrefix();
await db.query(
`INSERT INTO ${schema}_ss_plans
(plan_id, peer_id, source_env_id, direction, desired_pack, plan_json, precondition_hash, source_hash, status, created_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)`,
[
planId,
peerRow ? peerRow.peer_id : null,
plan.source_env_id,
direction || "pull",
JSON.stringify(desired),
JSON.stringify(plan),
plan.precondition_hash,
plan.source_hash,
PLAN_STATUS.PENDING,
plan.generated_at
]
);
// Retention: _ss_plans gets a row on every preview, so cap it to the most recent
// PLAN_RETENTION (the just-inserted plan is newest, so never pruned). A plan
// whose restore point is still kept (_ss_rollback) stays too: rollback needs
// both, so a burst of previews must never cost a recent apply its rollback.
await db.query(
`DELETE FROM ${schema}_ss_plans WHERE plan_id NOT IN ` +
`(SELECT plan_id FROM ${schema}_ss_plans ORDER BY created_at DESC LIMIT ${PLAN_RETENTION}) ` +
`AND plan_id NOT IN (SELECT plan_id FROM ${schema}_ss_rollback)`
).catch(() => {});
return { plan_id: planId, plan };
};
const loadPlan = async (planId) => {
const schema = db.getTenantSchemaPrefix();
const rs = await db.query(`SELECT * FROM ${schema}_ss_plans WHERE plan_id = $1`, [planId]);
if (rs.rows.length === 0) {
return null;
}
const row = rs.rows[0];
return {
plan_id: row.plan_id,
peer_id: row.peer_id,
source_env_id: row.source_env_id,
direction: row.direction,
desired: JSON.parse(row.desired_pack),
plan: JSON.parse(row.plan_json),
precondition_hash: row.precondition_hash,
source_hash: row.source_hash,
status: row.status,
claimed_from: row.claimed_from,
created_at: row.created_at
};
};
// The most recent plans (newest first), optionally only those in `status`: what
// a listing needs, without the (large) desired pack and plan JSON.
const listPlans = async ({ status, limit }) => {
const schema = db.getTenantSchemaPrefix();
const where = status ? "WHERE status = $1" : "";
const params = status ? [status, limit] : [limit];
const rs = await db.query(
`SELECT plan_id, peer_id, source_env_id, direction, precondition_hash, status, claimed_from, created_at ` +
`FROM ${schema}_ss_plans ${where} ORDER BY created_at DESC LIMIT $${params.length}`,
params
);
return rs.rows;
};
const setPlanStatus = async (planId, status) => {
const schema = db.getTenantSchemaPrefix();
await db.query(`UPDATE ${schema}_ss_plans SET status = $1 WHERE plan_id = $2`, [status, planId]);
};
// Atomically move a plan from one of `fromStatuses` to `toStatus`, recording the
// status it left in claimed_from (SET expressions read the row as it was, on
// SQLite and Postgres alike). Returns true only for the single caller that won the
// transition (RETURNING is used because Saltcorn's SQLite query() exposes no
// affected-row count), so a double-submitted apply/rollback cannot run twice.
const claimPlanStatus = async (planId, fromStatuses, toStatus) => {
const schema = db.getTenantSchemaPrefix();
const placeholders = fromStatuses.map((_, i) => `$${i + 3}`).join(",");
const rs = await db.query(
`UPDATE ${schema}_ss_plans SET status = $1, claimed_from = status WHERE plan_id = $2 AND status IN (${placeholders}) RETURNING plan_id`,
[toStatus, planId].concat(fromStatuses)
);
return rs.rows.length === 1;
};
// The tables whose data a plan's apply drops (whole tables or fields of populated
// tables): each must be named in the apply's `prunable` allow-list to be dropped.
const dataDropTables = (plan) => {
return [...new Set(plan.destructive.filter((x) => x.dataBearing).map((x) => x.table))];
};
// --- preview rendering (returns an HTML string; the route wraps it) ---
const kindRows = (title, bucket) => {
const parts = [];
const list = (label, arr, render) => {
if (!arr || arr.length === 0) {
return;
}
parts.push(`<div><span class="badge bg-${label === "delete" ? "danger" : label === "rename" ? "info" : label === "create" ? "success" : "secondary"}">${label} ${arr.length}</span> ` +
arr.map(render).map((s) => `<code>${escapeHtml(s)}</code>`).join(" ") + `</div>`);
};
list("create", bucket.create, (e) => e.name || e.key || e.role || JSON.stringify(e).slice(0, 30));
list("update", bucket.update, (e) => e.name || e.key || (e.to && (e.to.name || e.to.role)) || JSON.stringify(e).slice(0, 30));
list("delete", bucket.delete, (e) => e.name || e.key || JSON.stringify(e).slice(0, 30));
list("rename", bucket.rename, (e) => `${e.from} -> ${e.to}`);
if (parts.length === 0) {
return "";
}
return `<div class="mb-2"><strong>${escapeHtml(title)}</strong>${parts.join("")}</div>`;
};
const renderPlanHtml = (planId, plan, peerLabel) => {
const s = plan.summary;
let html = "";
html += `<p>Plan <code>${escapeHtml(planId)}</code> to make this instance match <strong>${escapeHtml(peerLabel || plan.source_env_id || "source")}</strong> ` +
`(sync mode <span class="badge bg-info">${escapeHtml(plan.sync_mode)}</span>).</p>`;
if (diff.isEmptyPlan(plan)) {
html += `<div class="alert alert-success">Already in sync -- nothing to apply.</div>`;
return html;
}
const chip = (label, n) => n > 0 ? `<span class="badge bg-secondary me-1">${escapeHtml(label)}: ${n}</span>` : "";
html += `<p>${diff.PLAN_KINDS.concat(["config"]).map((k) => chip(k, s[k])).join("")}</p>`;
if (plan.destructive.length > 0) {
const items = plan.destructive.map((x) =>
`<li>${x.dataBearing ? '<span class="badge bg-danger">DATA</span> ' : ""}${escapeHtml(x.text)}</li>`).join("");
html += `<div class="alert alert-warning"><strong>Destructive operations (${plan.destructive.length}, ${s.dataBearing} touch data):</strong><ul class="mb-0">${items}</ul></div>`;
}
if (plan.warnings.length > 0) {
html += `<div class="alert alert-info"><strong>Warnings:</strong><ul class="mb-0">${plan.warnings.map((w) => `<li>${escapeHtml(w)}</li>`).join("")}</ul></div>`;
}
html += kindRows("Tables", plan.entities.tables);
// Nested table field/constraint changes.
for (const u of plan.entities.tables.update) {
const bits = [];
const counted = [
[u.fields.create.length, "+", "field(s)"],
[u.fields.update.length, "~", "field(s)"],
[u.fields.delete.length, "-", "field(s)"],
[u.constraints.add.length, "+", "constraint(s)"],
[u.constraints.remove.length, "-", "constraint(s)"]
];
if (u.propsChanged) {
bits.push("table props");
}
for (const [n, sign, what] of counted) {
if (n > 0) {
bits.push(`${sign}${n} ${what}`);
}
}
html += `<div class="ms-3 text-muted"><code>${escapeHtml(u.name)}</code>: ${escapeHtml(bits.join(", "))}</div>`;
}
for (const kind of diff.PLAN_KINDS.filter((k) => k !== "tables")) {
html += kindRows(kind.replace(/_/g, " "), plan.entities[kind]);
}
if (s.config > 0) {
html += `<div class="mb-2"><strong>Config</strong><div><span class="badge bg-secondary">overwrite ${s.config}</span> <code>${escapeHtml(Object.keys(plan.config.sets).join(", "))}</code></div></div>`;
}
if (plan.rows && plan.rows.perTable && Object.keys(plan.rows.perTable).length > 0) {
const rowBits = Object.entries(plan.rows.perTable)
.filter(([, c]) => c.inserts + c.updates + c.deletes > 0)
.map(([name, c]) => `<div><code>${escapeHtml(name)}</code> <span class="badge bg-info">${escapeHtml(c.mode)}</span> ` +
`+${c.inserts} ~${c.updates} -${c.deletes}</div>`).join("");
if (rowBits) {
html += `<div class="mb-2"><strong>Reference rows</strong>${rowBits}</div>`;
}
}
return html;
};
// The apply form. Detected table renames are offered as UNCHECKED checkboxes
// (`confirm_rename`, value = old table name): an unconfirmed rename is applied as
// create + (gated) drop, so a false-positive structural match never moves data.
// Data-bearing drops are listed as text and the admin must TYPE each table name
// into `prunable` (deliberately never pre-filled -- the server matches the typed
// names exactly) and tick `confirm_data` before applyDeletes will drop them.
const renderApplyForm = (planId, plan, csrfHtml) => {
const renames = plan.entities.tables.rename;
const dataDrops = plan.destructive.filter((x) => x.dataBearing);
const dropNames = dataDropTables(plan);
let form = `<form method="post" action="${ADMIN_URLS.APPLY}">`;
form += csrfHtml || "";
form += `<input type="hidden" name="plan_id" value="${escapeHtml(planId)}">`;
renames.forEach((r, i) => {
form += `<div class="form-check mb-2"><input class="form-check-input" type="checkbox" name="confirm_rename" value="${escapeHtml(r.from)}" id="ssRename${i}">` +
`<label class="form-check-label" for="ssRename${i}">Rename table <code>${escapeHtml(r.from)}</code> to <code>${escapeHtml(r.to)}</code>, keeping its ${r.rowCount} row(s)</label></div>`;
});
if (dataDrops.length > 0) {
form += `<div class="mb-2"><label class="form-label text-danger fw-bold" for="ssPrunable">` +
`${dataDrops.length} data-bearing drop(s) in: ${dropNames.map((n) => `<code>${escapeHtml(n)}</code>`).join(", ")}. ` +
`Type the names of the tables you allow to be dropped (comma-separated); their rows will be destroyed:</label>` +
`<input class="form-control" name="prunable" id="ssPrunable" autocomplete="off"></div>`;
form += `<div class="form-check mb-3"><input class="form-check-input" type="checkbox" name="confirm_data" value="1" id="ssConfirmData">` +
`<label class="form-check-label" for="ssConfirmData">I understand this will permanently destroy data in the tables I typed above.</label></div>`;
}
form += `<button class="btn btn-danger" type="submit">Apply this plan</button></form>`;
return form;
};
module.exports = {
PLAN_RETENTION,
buildPlan,
loadPlan,
listPlans,
setPlanStatus,
claimPlanStatus,
dataDropTables,
renderPlanHtml,
renderApplyForm
};

72
lib/render.js Normal file
View file

@ -0,0 +1,72 @@
// Shared admin-UI rendering helpers for site-sync route modules.
//
// Every admin page renders in the ACTIVE Saltcorn theme via res.sendWrap, built
// from the same native markup primitives the core admin pages use (mkTable /
// renderForm / post_btn), so the pages inherit the theme by construction. Route
// handlers are split across phase modules (routes.js, plan.js, apply.js, ...) and
// all import these helpers so there is one rendering path and one nav.
const { mkTable, post_btn, renderForm, link, tags } = require("@saltcorn/markup");
const { p, code, pre, span, strong } = tags;
const Form = require("@saltcorn/data/models/form");
// requireAdmin / escapeHtml / csrfField / navPills / flashRedirect are shared by
// the plugins (lib/common/); re-exported below so handler modules import from
// one place.
const { escapeHtml } = require("./common/htmlEscape");
const { csrfField, navPills, flashRedirect } = require("./common/adminHtml");
const { requireAdmin } = require("./common/adminGate");
// Every admin URL, in one place (routes.js registers them; nav + forms link them).
const ADMIN_BASE = "/admin/site-sync";
const ADMIN_URLS = {
DASHBOARD: `${ADMIN_BASE}/`,
SETTINGS: `${ADMIN_BASE}/settings`,
PEERS: `${ADMIN_BASE}/peers`,
PEERS_ADD: `${ADMIN_BASE}/peers/add`,
PEERS_ROTATE: `${ADMIN_BASE}/peers/rotate`,
PEERS_DELETE: `${ADMIN_BASE}/peers/delete`,
PLAN: `${ADMIN_BASE}/plan`,
APPLY: `${ADMIN_BASE}/apply`,
ROLLBACK: `${ADMIN_BASE}/rollback`,
TABLES: `${ADMIN_BASE}/tables`,
TABLES_SET: `${ADMIN_BASE}/tables/set`
};
// Nav tabs for the whole plugin.
const SS_NAV = [
[ADMIN_URLS.DASHBOARD, "Dashboard"],
[ADMIN_URLS.PEERS, "Peers"],
[ADMIN_URLS.PLAN, "Plan"],
[ADMIN_URLS.TABLES, "Tables"],
];
// Render an admin page: breadcrumbs + sub-nav + the given content segments (cards
// or strings), all via res.sendWrap (which also renders any req.flash messages).
const adminPage = (req, res, title, ...segments) => {
const above = [
{ type: "breadcrumbs", crumbs: [{ text: "Settings", href: "/settings" }, { text: "site-sync" }, { text: title }] },
navPills(req, SS_NAV),
];
res.sendWrap(`site-sync ${title}`, { above: above.concat(segments) });
};
module.exports = {
ADMIN_URLS,
requireAdmin,
escapeHtml,
csrfField,
adminPage,
flashRedirect,
// markup primitives re-exported so handler modules import from one place
mkTable,
post_btn,
renderForm,
link,
Form,
p, code, pre, span, strong
};

318
lib/rollback.js Normal file
View file

@ -0,0 +1,318 @@
// Rollback of an apply.
//
// Before an apply mutates anything, we capture a RESTORE POINT: the pre-apply
// metadata pack (schema/views/pages/triggers/roles/config/code pages) plus the RAW
// rows the apply can change or destroy: every managed/starter table (full rows),
// every table whose drop the delete gate WILL allow (full rows), and for a
// surviving table whose field drop the gate will allow, only the primary key and
// the doomed columns. A rollback then:
// 1. re-syncs the metadata + config back to the pre-apply pack with the normal
// plan/apply engine in MIRROR mode (whatever the instance's sync mode, so the
// apply's creates are removed), applying the detected inverse table renames;
// 2. removes rows the apply inserted into managed/starter tables, then restores
// the captured rows by primary key (update the captured columns of a row that
// still exists, insert a missing row WITH its original pk so FKs keep
// pointing at it), parents before children;
// 3. un-marks starter tables the apply seeded, so they can be seeded again.
// A plan is rolled back at most once (atomic status claim), under the site-wide
// apply lock (lib/siteLock.js) like an apply: its metadata step is an apply run
// under the same lease.
//
// Files need no byte capture: apply never deletes old file bytes (File.from_contents
// writes NEW files), so restoring a row's old File-field VALUE reverts the file
// reference to the still-present old file. (Exception: a managed row pruned from a
// File field with Saltcorn's also_delete_file attribute takes its file with it.)
const db = require("@saltcorn/data/db");
const Table = require("@saltcorn/data/models/table");
const capture = require("./capture");
const rowId = require("./rowIdentity");
const rowsLib = require("./rows");
const siteLock = require("./siteLock");
const layoutFiles = require("./layoutFiles");
const { gateDrop } = require("./deletes");
const { DATA_MODES, PLAN_STATUS, SYNC_MODES, isLockedTable, isSyncedMode } = require("./constants");
const { ROW_UUID_FIELD, withoutPk } = rowId;
// Keep at most this many recent restore points (one is written per apply); each can
// be a large JSON blob, so the cap is tighter than for plans.
const ROLLBACK_RETENTION = 50;
// The statuses a plan with a restore point can be rolled back from: applied, or
// pending again after an apply that failed part-way.
const ROLLBACK_FROM = [PLAN_STATUS.APPLIED, PLAN_STATUS.PENDING];
// name -> null (capture full rows) | [field names] (capture pk + these columns).
const tablesToCapture = async (plan, gateOpts) => {
const out = {};
for (const [name, mode] of Object.entries(await rowsLib.modeMap())) {
if (isSyncedMode(mode) && !isLockedTable(name) && Table.findOne({ name })) {
// Backfill identities BEFORE capturing: a row inserted locally since the
// last backfill has no uuid yet, and rollback matches synced rows by uuid.
await rowId.ensureManagedSchema(name);
out[name] = null;
}
}
const willDrop = async (name) => {
const tbl = Table.findOne({ name });
if (!tbl || isLockedTable(name)) {
return false;
}
const rowCount = await tbl.countRows();
return rowCount > 0 && gateDrop(Object.assign({ name, rowCount }, gateOpts)).allowed;
};
const dropCandidates = plan.entities.tables.delete.map((d) => d.name).concat(plan.entities.tables.rename.map((r) => r.from));
for (const name of dropCandidates) {
if (await willDrop(name)) {
out[name] = null;
}
}
for (const u of plan.entities.tables.update) {
if (u.fields.delete.length > 0 && !(u.name in out) && (await willDrop(u.name))) {
out[u.name] = u.fields.delete.map((f) => f.name);
}
}
return out;
};
const pickColumns = (row, names) => {
const out = {};
for (const n of names) {
if (Object.prototype.hasOwnProperty.call(row, n)) {
out[n] = row[n];
}
}
return out;
};
// gateOpts = the apply's { onDelete, prunable, confirmDataDrops } (what the delete
// gate will allow decides which rows are worth capturing).
const saveRestorePoint = async (planId, metadataPack, plan, gateOpts) => {
const rawRows = {};
for (const [name, columns] of Object.entries(await tablesToCapture(plan, gateOpts))) {
const tbl = Table.findOne({ name });
const rows = await tbl.getRows({}, {});
rawRows[name] = columns ? rows.map((r) => pickColumns(r, [tbl.pk_name].concat(columns))) : rows;
}
// Pre-apply LAYOUT/LOGO file index (content hash -> local file value). On
// rollback, a pre-apply file (e.g. an old logo) is still on disk but no longer
// REFERENCED, so it is absent from the target's live index and there is no peer to
// fetch it from; this seed lets rollback resolve its placeholders back to the old
// on-disk file. (Row-file rollback is handled separately by restoreTableRows.)
const fileIndex = Object.fromEntries(await layoutFiles.indexLocalFiles(new Map()));
const schema = db.getTenantSchemaPrefix();
const now = new Date().toISOString();
// upsert (a re-apply of the same plan_id replaces its restore point)
await db.query(`DELETE FROM ${schema}_ss_rollback WHERE plan_id = $1`, [planId]);
await db.query(
`INSERT INTO ${schema}_ss_rollback (plan_id, restore_pack, restore_rows, restore_files, created_at) VALUES ($1,$2,$3,$4,$5)`,
[planId, JSON.stringify(metadataPack), JSON.stringify(rawRows), JSON.stringify(fileIndex), now]
);
// Retention: cap _ss_rollback to the most recent restore points.
await db.query(
`DELETE FROM ${schema}_ss_rollback WHERE plan_id NOT IN ` +
`(SELECT plan_id FROM ${schema}_ss_rollback ORDER BY created_at DESC LIMIT ${ROLLBACK_RETENTION})`
).catch(() => {});
};
const hasRestorePoint = async (planId) => {
const schema = db.getTenantSchemaPrefix();
const rs = await db.query(`SELECT 1 FROM ${schema}_ss_rollback WHERE plan_id = $1`, [planId]);
return rs.rows.length > 0;
};
// The plan ids that have a restore point (at most ROLLBACK_RETENTION).
const restorePointIds = async () => {
const schema = db.getTenantSchemaPrefix();
const rs = await db.query(`SELECT plan_id FROM ${schema}_ss_rollback`);
return new Set(rs.rows.map((r) => r.plan_id));
};
// Before a peer is deleted (its plans go with it): detach from it the plans that
// can still be rolled back, so their rollback survives the unpairing. A detached
// plan (peer_id NULL) belongs to no peer, so a later pairing that is handed the same
// peer_id can never apply it remotely. Returns how many were kept.
const detachRollbackablePlans = async (peerId) => {
const schema = db.getTenantSchemaPrefix();
const placeholders = ROLLBACK_FROM.map((_, i) => `$${i + 2}`).join(",");
const rs = await db.query(
`UPDATE ${schema}_ss_plans SET peer_id = NULL WHERE peer_id = $1 AND status IN (${placeholders}) ` +
`AND plan_id IN (SELECT plan_id FROM ${schema}_ss_rollback) RETURNING plan_id`,
[peerId].concat(ROLLBACK_FROM)
);
return rs.rows.length;
};
const loadRestorePoint = async (planId) => {
const schema = db.getTenantSchemaPrefix();
const rs = await db.query(`SELECT * FROM ${schema}_ss_rollback WHERE plan_id = $1`, [planId]);
if (rs.rows.length === 0) {
return null;
}
const row = rs.rows[0];
return {
restore_pack: JSON.parse(row.restore_pack),
restore_rows: JSON.parse(row.restore_rows),
restore_files: row.restore_files ? JSON.parse(row.restore_files) : {}
};
};
// Restore one table's captured rows by primary key (see the header). A row still
// present gets its captured columns back (only when they differ); a missing row is
// re-inserted WITH its original pk unless another row now holds that pk. For a
// managed/starter table a pk only matches when the row identity matches too.
const restoreTableRows = async (tableName, rawRows, synced, warnings) => {
if (synced) {
await rowId.ensureManagedSchema(tableName);
}
const tbl = Table.findOne({ name: tableName });
const pk = tbl.pk_name;
const fields = new Set(tbl.getFields().map((f) => f.name));
const current = await tbl.getRows({}, {});
const byPk = new Map(current.map((r) => [r[pk], r]));
const byUuid = new Map(current.filter((r) => r[ROW_UUID_FIELD]).map((r) => [r[ROW_UUID_FIELD], r]));
const lost = new Set();
let insertedWithPk = false;
for (const raw of rawRows) {
const row = {};
for (const [k, v] of Object.entries(raw)) {
if (fields.has(k)) {
row[k] = v;
} else {
lost.add(k);
}
}
// A captured row without an identity (older restore points) matches by pk and
// keeps the identity the row has been given since.
let existing = byPk.get(raw[pk]);
if (synced && !raw[ROW_UUID_FIELD]) {
delete row[ROW_UUID_FIELD];
} else if (synced && existing && existing[ROW_UUID_FIELD] !== raw[ROW_UUID_FIELD]) {
existing = byUuid.get(raw[ROW_UUID_FIELD]);
}
if (existing) {
if (rowsLib.rowDiffers(existing, withoutPk(row, pk))) {
const err = await tbl.updateRow(withoutPk(row, pk), existing[pk]);
if (typeof err === "string") {
warnings.push(`"${tableName}" row ${raw[pk]} not restored: ${err}`);
}
}
continue;
}
const pkFree = !byPk.has(raw[pk]);
const r = await tbl.tryInsertRow(pkFree ? row : withoutPk(row, pk));
if (r.success === undefined) {
warnings.push(`"${tableName}" row ${raw[pk]} not restored: ${r.error}`);
continue;
}
insertedWithPk = insertedWithPk || pkFree;
}
if (lost.size > 0) {
warnings.push(`"${tableName}": captured column(s) ${[...lost].join(", ")} no longer exist and were not restored`);
}
if (insertedWithPk) {
await tbl.resetSequence();
}
};
// The rollback proper, run while this caller holds the site lock (lease) and the
// plan's ROLLING_BACK claim.
const runRollback = async (planId, rp, original, opts, lease) => {
const { getEnv } = require("./env");
const planLib = require("./plan");
const applyLib = require("./apply");
const env = Object.assign({}, await getEnv(), { sync_mode: SYNC_MODES.MIRROR });
// 1. metadata + config + code pages (raw rows are restored below).
const built = await planLib.buildPlan({
peerRow: null, env, desired: rp.restore_pack, desiredHash: capture.hashPack(rp.restore_pack),
sourceEnvId: "rollback", direction: "rollback", sourceRows: {}
});
const applied = await applyLib.applyPlan(built.plan_id, {
applyDeletes: true,
// the restore pack is this instance's own pre-apply state, so a detected
// rename is the inverse of one the apply performed
confirmRenames: built.plan.entities.tables.rename.map((r) => r.from),
prunable: opts.prunable || [],
confirmDataDrops: !!opts.confirmDataDrops,
skipRestorePoint: true,
// seed the file dedup index with pre-apply files so layout/logo placeholders
// resolve back to the old on-disk files without needing a peer to fetch from.
fileIndexSeed: rp.restore_files,
lease: lease
});
const warnings = applied.warnings.slice();
await lease.assertHeld();
// 2. rows: remove what the apply inserted into managed/starter tables (children
// first), then restore the captured rows (parents first).
const modes = await rowsLib.modeMap();
const names = Object.keys(rp.restore_rows).filter((n) => {
const present = !!Table.findOne({ name: n });
if (!present) {
warnings.push(`"${n}" is not present after the metadata rollback; its rows were not restored`);
}
return present;
});
const synced = names.filter((n) => isSyncedMode(modes[n]));
await rowsLib.pruneRowsNotIn(synced.map((n) => ({
name: n,
keep: new Set(rp.restore_rows[n].map((r) => r[ROW_UUID_FIELD]).filter(Boolean))
})), warnings);
for (const name of rowId.parentFirstOrder(names)) {
await restoreTableRows(name, rp.restore_rows[name], isSyncedMode(modes[name]), warnings);
}
// 3. starters this apply seeded become unseeded again.
for (const [name, c] of Object.entries(original.plan.rows ? original.plan.rows.perTable : {})) {
if (c.mode === DATA_MODES.STARTER && c.inserts > 0) {
await rowsLib.clearStarterShipped(name);
}
}
return { rolledBack: true, plan_id: planId, restoredTables: names.length, warnings };
};
// Revert the apply identified by planId, under the site lock (siteLock.LockBusyError
// while another apply or rollback runs). opts.prunable / opts.confirmDataDrops gate
// dropping any table the apply CREATED and that has since gained rows. Allowed for
// an applied plan, or a pending one whose apply failed part-way or was abandoned
// (acquiring the lock recovers it to pending; it then has a restore point); a plan
// is rolled back at most once.
const rollbackApply = async (planId, opts = {}) => {
return await siteLock.withSiteLock({ operation: "rollback", planId }, async (lease) => {
const planLib = require("./plan");
const rp = await loadRestorePoint(planId);
if (!rp) {
throw new Error(`no restore point for plan ${planId}`);
}
const original = await planLib.loadPlan(planId);
if (!original || !(await planLib.claimPlanStatus(planId, ROLLBACK_FROM, PLAN_STATUS.ROLLING_BACK))) {
throw new Error(`plan ${planId} cannot be rolled back: it is ${original ? original.status : "unknown"}`);
}
const result = await siteLock.finishClaimed(lease, planId, { prior: original.status, done: PLAN_STATUS.ROLLED_BACK }, () => runRollback(planId, rp, original, opts, lease));
result.warnings = siteLock.recoveryWarnings(lease).concat(result.warnings);
return result;
});
};
module.exports = {
ROLLBACK_FROM,
saveRestorePoint,
hasRestorePoint,
restorePointIds,
detachRollbackablePlans,
rollbackApply
};

659
lib/routes.js Normal file
View file

@ -0,0 +1,659 @@
// HTTP routes for site-sync: the single route-registration hub. The admin
// handlers are thin adapters over operations.js (shared with the MCP tools, so the
// rules live there); the heavy lifting lives in plan.js / apply.js / rollback.js /
// rows.js; this module holds the handlers, their rendering and the route table.
//
// Admin UI (session admin of THIS tenant; POSTs are CSRF-protected):
// GET /admin/site-sync/ dashboard + settings
// POST /admin/site-sync/settings
// GET /admin/site-sync/peers peers, pairing, secret replacement
// POST /admin/site-sync/peers/add
// POST /admin/site-sync/peers/rotate
// POST /admin/site-sync/peers/delete
// GET /admin/site-sync/plan choose a source peer
// POST /admin/site-sync/plan pull the peer's state + preview (persists a plan)
// POST /admin/site-sync/apply
// POST /admin/site-sync/rollback
// GET /admin/site-sync/tables reference-data modes
// POST /admin/site-sync/tables/set
//
// Machine API (HMAC peer auth, CSRF-exempt, every response signed):
// GET /site-sync/api/health
// GET /site-sync/api/state
// GET /site-sync/api/file/:hash
// POST /site-sync/api/plan
// POST /site-sync/api/apply
const fs = require("fs");
const db = require("@saltcorn/data/db");
const { toAbsolutePath } = require("./common/fileStore");
const { HTTP_STATUS } = require("./common/httpStatus");
const { localPluginList } = require("./common/pluginParity");
const {
PLUGIN_NAME,
PLUGIN_VERSION,
APPLY_POLICY,
DATA_MODES
} = require("./constants");
const { requireEnv } = require("./env");
const peers = require("./peers");
const { requirePeerAuth, sendSigned, sendSignedJson } = require("./peerAuth");
const { API_PATHS, PEER_LIMITS } = require("./transport");
const { buildDesiredState } = require("./capture");
const planLib = require("./plan");
const rowsLib = require("./rows");
const diff = require("./diff");
const ops = require("./operations");
const { LOCK_STATES } = require("./siteLock");
const {
ADMIN_URLS,
requireAdmin,
escapeHtml,
csrfField,
adminPage,
flashRedirect,
mkTable,
post_btn,
renderForm,
link,
Form,
p, code, pre, span, strong
} = require("./render");
// A content hash in a file URL: lowercase sha256 hex.
const HASH_RE = /^[0-9a-f]{64}$/;
// The only plan direction a remote peer may apply (pull/rollback plans are local).
const PUSH_DIRECTION = "push";
// Comma-separated (or repeated) form field -> trimmed non-empty strings.
const parseList = (raw) => {
if (Array.isArray(raw)) {
return raw.map((s) => String(s).trim()).filter(Boolean);
}
return String(raw || "").split(",").map((s) => s.trim()).filter(Boolean);
};
// ---------------- Admin dashboard ----------------
// The site lock's state (operations.applyLockView) as a dashboard value.
const lockBadge = (lock) => {
if (lock.state === LOCK_STATES.RUNNING) {
const h = lock.holder;
return span({ class: "badge bg-primary" }, escapeHtml(`${h.operation} of plan ${h.plan_id} running since ${h.acquired_at}`));
}
if (lock.state === LOCK_STATES.INTERRUPTED) {
return span({ class: "badge bg-danger" }, "interrupted (see below)");
}
return span({ class: "text-muted" }, "idle");
};
// A card for the plans an apply or rollback left in flight when the process running
// it stopped (none: no card): roll each back from its restore point.
const interruptedCards = (lock, csrf) => {
if (lock.state !== LOCK_STATES.INTERRUPTED) {
return [];
}
const items = lock.plans.map((pl) => {
return p("Plan ", code(escapeHtml(pl.plan_id)), ` (${escapeHtml(pl.direction)}) was left ${escapeHtml(pl.status)}.`) +
(pl.has_restore_point ? rollbackForm(pl.plan_id, csrf) : p({ class: "text-muted" }, "It has no restore point; the next apply or rollback releases it."));
});
return [{ type: "card", title: "Interrupted apply or rollback", contents:
`<div class="alert alert-danger">The process running an apply or rollback stopped part-way, so this site may be partly changed. ` +
`Roll the plan back to its restore point, or apply it again; either recovers it.</div>` + items.join("") }];
};
const dashboard = async (req, res) => {
if (!requireAdmin(req, res)) {
return;
}
const st = await ops.status();
const env = st.env;
const syncedCount = Object.values(st.syncedTables).reduce((a, n) => a + n, 0);
const envRows = [
{ k: "Env ID", v: code(escapeHtml(env.env_id)) },
{ k: "Label", v: env.env_label ? escapeHtml(env.env_label) : span({ class: "text-muted" }, "(unset)") },
{ k: "Sync mode", v: span({ class: "badge bg-info" }, escapeHtml(env.sync_mode)) },
{ k: "Delete policy", v: span({ class: "badge bg-warning text-dark" }, escapeHtml(env.on_delete)) },
{ k: "Apply policy", v: span({ class: "badge bg-secondary" }, escapeHtml(env.on_apply)) },
{ k: "Require TLS", v: env.require_tls ? "yes" : "no" },
{ k: "Apply / rollback", v: lockBadge(st.applyLock) },
{ k: "Peers configured", v: escapeHtml(st.peerCount) },
{ k: "Non-user tables", v: escapeHtml(syncedCount) },
{ k: "Plugin version", v: escapeHtml(st.version) },
];
const kv = mkTable([{ label: "Setting", key: "k" }, { label: "Value", key: (r) => r.v }], envRows);
const settingsForm = new Form({
action: ADMIN_URLS.SETTINGS,
submitLabel: "Save settings",
fields: [
{ name: "env_label", label: "Label", type: "String", default: env.env_label || "",
attributes: { placeholder: "dev, test, prod" } },
{ name: "sync_mode", label: "Sync mode", input_type: "select", default: env.sync_mode, options: ops.SETTING_OPTIONS.sync_mode },
{ name: "on_delete", label: "Data-bearing drop policy", input_type: "select", default: env.on_delete, options: ops.SETTING_OPTIONS.on_delete },
{ name: "on_apply", label: "Remote apply policy", input_type: "select", default: env.on_apply, options: ops.SETTING_OPTIONS.on_apply },
{ name: "require_tls", label: "Require TLS for inbound peer traffic", type: "Bool", default: !!env.require_tls },
],
});
adminPage(req, res, "dashboard",
...interruptedCards(st.applyLock, csrfField(req)),
{ type: "card", title: "Environment", contents: kv },
{ type: "card", title: "Settings", contents: renderForm(settingsForm, req.csrfToken()) }
);
};
const saveSettings = async (req, res) => {
if (!requireAdmin(req, res)) {
return;
}
try {
// The whole form: a blank label clears it, an unticked box turns TLS off.
await ops.updateSettings({
env_label: req.body.env_label || "",
sync_mode: req.body.sync_mode,
on_delete: req.body.on_delete,
on_apply: req.body.on_apply,
require_tls: !!req.body.require_tls
});
flashRedirect(req, res, ADMIN_URLS.DASHBOARD, "success", "settings saved");
} catch (err) {
flashRedirect(req, res, ADMIN_URLS.DASHBOARD, "danger", err.message);
}
};
// ---------------- Peers ----------------
const peerOptions = (list) => list.map((peer) => ({ label: peer.label || peer.env_id, value: String(peer.peer_id) }));
const peersView = async (req, res) => {
if (!requireAdmin(req, res)) {
return;
}
const env = await requireEnv();
const list = await peers.listPeers();
const actBtn = (url, label, cls, body, confirm) =>
post_btn(url, label, req.csrfToken(), { req, btnClass: cls, small: true, formClass: "d-inline me-1", body: body, confirm: confirm });
const peerTable = mkTable([
{ label: "id", key: "peer_id" },
{ label: "label", key: (peer) => escapeHtml(peer.label || "(unset)") },
{ label: "env_id", key: (peer) => code(escapeHtml(peer.env_id)) },
{ label: "base_url", key: (peer) => escapeHtml(peer.base_url) },
{ label: "last seen", key: (peer) => escapeHtml(peer.last_seen_at || "never") },
{ label: "actions", key: (peer) =>
actBtn(ADMIN_URLS.PLAN, "Plan", "btn-primary", { peer: peer.peer_id }, false) +
actBtn(ADMIN_URLS.PEERS_ROTATE, "Rotate", "btn-warning", { peer_id: peer.peer_id }, true) +
actBtn(ADMIN_URLS.PEERS_DELETE, "Delete", "btn-danger", { peer_id: peer.peer_id }, true),
},
], list);
const addForm = new Form({
action: ADMIN_URLS.PEERS_ADD,
submitLabel: "Pair",
blurb: "A peer is a site-sync instance you can pull design state FROM (or push TO). Leave the shared secret blank to generate one (shown once after submit); paste the same secret in the peer's own pairing form.",
fields: [
{ name: "env_id", label: "Peer env_id", type: "String", required: true },
{ name: "label", label: "Label", type: "String", attributes: { placeholder: "dev, prod, etc." } },
{ name: "base_url", label: "Base URL", type: "String", required: true, attributes: { placeholder: "https://dev.example.com" } },
{ name: "require_tls", label: "Require TLS", type: "Bool" },
{ name: "existing_secret", label: "Existing secret (hex)", type: "String", attributes: { placeholder: "64 hex characters" } },
],
});
const replaceForm = new Form({
action: ADMIN_URLS.PEERS_ROTATE,
submitLabel: "Replace secret",
blurb: "After the other instance rotated this pairing's secret, paste the new secret it displayed here. Leave it blank to generate a new secret on this side instead.",
fields: [
{ name: "peer_id", label: "Peer", input_type: "select", required: true, options: peerOptions(list) },
{ name: "secret", label: "New secret (hex)", type: "String", attributes: { placeholder: "64 hex characters" } },
],
});
adminPage(req, res, "peers",
{ type: "card", title: "Peers", contents:
p("This instance's ", strong("env_id"), " is ", code(escapeHtml(env.env_id)), ". Paste this into the other instance's peer form.") + peerTable },
{ type: "card", title: "Add peer", contents: renderForm(addForm, req.csrfToken()) },
{ type: "card", title: "Replace a peer's secret", contents: list.length === 0 ? p({ class: "text-muted" }, "No peers configured.") : renderForm(replaceForm, req.csrfToken()) }
);
};
// The one-time display of a shared secret (pairing / rotation).
const secretCard = (title, secret, instruction) => ({
type: "card",
title: title,
contents:
p("Shared secret (it will not be shown again):") +
pre({ class: "user-select-all p-2 bg-light text-dark border rounded", style: "white-space:pre-wrap;word-break:break-all" }, escapeHtml(secret.toString("hex"))) +
p(escapeHtml(instruction)) +
p(link(ADMIN_URLS.PEERS, "Back to peers"))
});
const peersAdd = async (req, res) => {
if (!requireAdmin(req, res)) {
return;
}
try {
const { peer, secret } = await ops.addPeer({
envId: req.body.env_id,
label: req.body.label,
baseUrl: req.body.base_url,
requireTls: req.body.require_tls,
existingSecret: req.body.existing_secret
});
adminPage(req, res, "peer paired",
secretCard(`Peer ${escapeHtml(ops.peerName(peer))} paired`, secret,
"Paste this secret into the peer's own pairing form for this instance (and give it this instance's env_id if it is a new pairing)."));
} catch (err) {
flashRedirect(req, res, ADMIN_URLS.PEERS, "danger", err.message);
}
};
// Rotate (generate + display) or replace (a secret pasted from the other side).
const peersRotate = async (req, res) => {
if (!requireAdmin(req, res)) {
return;
}
try {
const { peer, secret, generated } = await ops.rotatePeerSecret(req.body.peer_id, req.body.secret);
if (!generated) {
flashRedirect(req, res, ADMIN_URLS.PEERS, "success", `secret for peer ${ops.peerName(peer)} replaced`);
return;
}
adminPage(req, res, "secret rotated",
secretCard(`Peer ${escapeHtml(ops.peerName(peer))} secret rotated`, secret,
"On the other instance, open Peers -> Replace a peer's secret, choose this instance, and paste this secret. Until then the pairing cannot authenticate."));
} catch (err) {
flashRedirect(req, res, ADMIN_URLS.PEERS, "danger", err.message);
}
};
const peersDelete = async (req, res) => {
if (!requireAdmin(req, res)) {
return;
}
try {
const { keptForRollback } = await ops.deletePeer(req.body.peer_id);
flashRedirect(req, res, ADMIN_URLS.PEERS, "success",
keptForRollback ? `peer deleted (${keptForRollback} applied plan(s) kept so they can still be rolled back)` : "peer deleted");
} catch (err) {
flashRedirect(req, res, ADMIN_URLS.PEERS, "danger", err.message);
}
};
// ---------------- Plan (preview) ----------------
// GET: choose a source peer. Read-only -- the pull + plan persistence happen only
// on the CSRF-protected POST below.
const planSelect = async (req, res) => {
if (!requireAdmin(req, res)) {
return;
}
const peerList = await peers.listPeers();
const selForm = new Form({
action: ADMIN_URLS.PLAN,
submitLabel: "Preview plan",
fields: [
{ name: "peer", label: "Source peer", input_type: "select", options: peerOptions(peerList) },
],
});
adminPage(req, res, "plan",
{ type: "card", title: "Preview a sync plan", contents:
peerList.length === 0
? p({ class: "text-muted" }, "No peers configured. Add one on the Peers tab.")
: p("Pull a source peer's current design state and preview what would change here.") + renderForm(selForm, req.csrfToken()) }
);
};
// POST: pull the chosen peer's state, persist a plan, and render the preview.
const planPreview = async (req, res) => {
if (!requireAdmin(req, res)) {
return;
}
try {
const { plan_id, plan, peer } = await ops.pullPlan(req.body.peer);
const html = planLib.renderPlanHtml(plan_id, plan, ops.peerName(peer));
const applyForm = diff.isEmptyPlan(plan) ? "" : planLib.renderApplyForm(plan_id, plan, csrfField(req));
adminPage(req, res, "plan",
{ type: "card", title: "Plan preview", contents: html + applyForm }
);
} catch (err) {
flashRedirect(req, res, ADMIN_URLS.PLAN, "danger", err.message);
}
};
// ---------------- Apply ----------------
const warningList = (warnings) => {
if (!warnings || warnings.length === 0) {
return "";
}
return `<div class="alert alert-warning"><strong>Warnings (${warnings.length}):</strong><ul class="mb-0">` +
warnings.map((w) => `<li>${escapeHtml(w)}</li>`).join("") + `</ul></div>`;
};
// One-line outcome of an apply, from applyPlan's result.
const applySummary = (result) => {
const bits = [`${result.summary.total} planned change(s)`];
if (result.deletes.applied) {
bits.push(`${result.deletes.deleted || 0} deleted`);
bits.push(`${result.deletes.skipped.length} delete(s) skipped`);
}
if (result.rows.applied) {
const rowOps = Object.values(result.rows.perTable).reduce((a, t) => a + t.inserted + t.updated + t.deleted, 0);
bits.push(`${rowOps} row op(s)`);
}
return bits.join(", ");
};
// Admin apply of a previewed plan. Data-bearing drops need the table names TYPED
// into `prunable` (+ the confirmation tick under on_delete=confirm); table renames
// need their `confirm_rename` box ticked.
const applyView = async (req, res) => {
if (!requireAdmin(req, res)) {
return;
}
let result;
try {
result = await ops.applyPlan(req.body.plan_id, {
prunable: parseList(req.body.prunable),
confirmDataDrops: !!req.body.confirm_data,
confirmRenames: parseList(req.body.confirm_rename)
});
} catch (err) {
if (!err.extra) {
flashRedirect(req, res, ADMIN_URLS.PLAN, "danger", err.message);
return;
}
// A failure part-way through can leave the target half-applied; the restore
// point saved before any change is exactly what the admin needs now.
const { planId, canRollback } = err.extra;
adminPage(req, res, "apply failed",
{ type: "card", title: `Apply of plan ${escapeHtml(planId)} failed`, contents:
`<div class="alert alert-danger">${escapeHtml(err.message)}</div>` +
(canRollback
? p("A restore point was saved before this apply changed anything. Roll back to it:") + rollbackForm(planId, csrfField(req))
: p("No restore point exists for this plan.")) +
p(link(ADMIN_URLS.PLAN, "Back to plan")) }
);
return;
}
const planId = result.plan_id;
adminPage(req, res, "applied",
{ type: "card", title: `Applied plan ${escapeHtml(planId)}`, contents:
p("Applied: ", strong(escapeHtml(applySummary(result)))) +
warningList(result.warnings) +
(result.canRollback ? p("Revert if this was a mistake:") + rollbackForm(planId, csrfField(req)) : "") +
p(link(ADMIN_URLS.DASHBOARD, "Back to dashboard")) }
);
};
const rollbackForm = (planId, csrf) => {
return `<form method="post" action="${ADMIN_URLS.ROLLBACK}" onsubmit="return confirm('Revert this apply?');">` +
csrf +
`<input type="hidden" name="plan_id" value="${escapeHtml(planId)}">` +
`<div class="form-text mb-1">Rollback reverts metadata, config, and managed rows to the pre-apply state. ` +
`To also drop tables this apply created that have since gained rows, type their names and confirm:</div>` +
`<input class="form-control mb-2" name="prunable" placeholder="table names (comma-separated), optional">` +
`<div class="form-check mb-2"><input class="form-check-input" type="checkbox" name="confirm_data" value="1" id="rbConfirm">` +
`<label class="form-check-label" for="rbConfirm">Allow dropping the typed tables' data</label></div>` +
`<button class="btn btn-warning" type="submit">Rollback this apply</button></form>`;
};
const rollbackView = async (req, res) => {
if (!requireAdmin(req, res)) {
return;
}
try {
const result = await ops.rollbackPlan(req.body.plan_id, {
prunable: parseList(req.body.prunable),
confirmDataDrops: !!req.body.confirm_data
});
for (const w of result.warnings) {
req.flash("warning", escapeHtml(w));
}
flashRedirect(req, res, ADMIN_URLS.DASHBOARD, "success", `rolled back plan ${result.plan_id} (${result.restoredTables} table(s) restored)`);
} catch (err) {
flashRedirect(req, res, ADMIN_URLS.DASHBOARD, "danger", err.message);
}
};
// ---------------- Tables (reference-data modes) ----------------
const tablesView = async (req, res) => {
if (!requireAdmin(req, res)) {
return;
}
const info = await ops.listTables();
const modeBtn = (r, mode, label, cls) => {
if (r.mode === mode) {
return `<span class="badge bg-${cls} me-1">${escapeHtml(label)}</span>`;
}
return post_btn(ADMIN_URLS.TABLES_SET, label, req.csrfToken(),
{ req, btnClass: "btn-outline-" + cls, small: true, formClass: "d-inline me-1", body: { table: r.name, mode: mode } });
};
const tbl = mkTable([
{ label: "table", key: (r) => escapeHtml(r.name) },
{ label: "rows", key: (r) => escapeHtml(r.rows) },
{ label: "mode", key: (r) => r.locked ? `<span class="badge bg-dark">locked (user)</span>` : escapeHtml(r.mode) },
{ label: "set", key: (r) => r.locked
? span({ class: "text-muted" }, "system table")
: modeBtn(r, DATA_MODES.USER, "User", "secondary") + modeBtn(r, DATA_MODES.STARTER, "Starter", "info") + modeBtn(r, DATA_MODES.MANAGED, "Managed", "primary") },
], info);
adminPage(req, res, "tables",
{ type: "card", title: "Reference-data modes", contents:
p("Choose which tables site-sync propagates ", strong("row data"), " for. ",
strong("User"), " (default): rows are never touched. ",
strong("Starter"), ": rows are seeded once into a fresh table, then the target owns them. ",
strong("Managed"), ": the source is canonical; rows are mirrored (added/updated/removed). Mark Starter/Managed on ",
strong("reference/lookup"), " tables only -- never user-entered data.") + tbl }
);
};
const tablesSet = async (req, res) => {
if (!requireAdmin(req, res)) {
return;
}
try {
const { table, mode } = await ops.setTableMode(req.body.table, req.body.mode);
flashRedirect(req, res, ADMIN_URLS.TABLES, "success", `${table} set to ${mode}`);
} catch (err) {
flashRedirect(req, res, ADMIN_URLS.TABLES, "danger", err.message);
}
};
// ---------------- Machine API ----------------
// Wrap a machine-API handler: HMAC-authenticate the request, then run it; any
// error becomes a SIGNED 500 so the caller sees the real message instead of an
// unsigned (and therefore rejected) error page.
const peerRoute = (handler) => async (req, res) => {
const peer = await requirePeerAuth(req, res);
if (!peer) {
return;
}
try {
await handler(req, res, peer);
} catch (err) {
sendSignedJson(req, res, HTTP_STATUS.SERVER_ERROR, { error: err.message });
}
};
const apiHealth = async (req, res) => {
const env = await requireEnv();
sendSignedJson(req, res, HTTP_STATUS.OK, {
plugin: PLUGIN_NAME,
version: PLUGIN_VERSION,
env_id: env.env_id,
label: env.env_label,
plugins: await localPluginList()
});
};
// Serve THIS instance's sanitized desired-state pack to an authenticated peer.
// Read-only: the source never mutates; the target pulls this and computes/applies
// the plan on its own side, so every safety gate lives on the mutating side.
const apiState = async (req, res) => {
const env = await requireEnv();
const { pack, hash } = await buildDesiredState();
const { manifest } = await rowsLib.buildRowManifest();
sendSignedJson(req, res, HTTP_STATUS.OK, {
plugin: PLUGIN_NAME,
version: PLUGIN_VERSION,
env_id: env.env_id,
hash: hash,
pack: pack,
rows: manifest
});
};
// PUSH endpoint: a source posts its desired-state pack; the TARGET still computes
// and persists the plan on its own side (every gate lives on the mutating side).
const apiPlan = async (req, res, peer) => {
if (!req.body.pack) {
sendSignedJson(req, res, HTTP_STATUS.BAD_REQUEST, { error: "pack required" });
return;
}
const { plan_id, plan } = await planLib.buildPlan({
peerRow: peer, env: await requireEnv(), desired: req.body.pack, desiredHash: req.body.hash,
sourceEnvId: peer.env_id, direction: PUSH_DIRECTION, sourceRows: req.body.rows || {}
});
sendSignedJson(req, res, HTTP_STATUS.OK, {
plan_id: plan_id,
summary: plan.summary,
destructive: plan.destructive,
precondition_hash: plan.precondition_hash
});
};
// PUSH apply: a source triggers apply of a plan it pushed. Gated behind
// on_apply=auto -- by default the target refuses remote-triggered mutation and the
// admin must apply via the target's own UI. Even when enabled, all gates (drift
// re-check, config allow-list, delete policy) run locally.
const apiApply = async (req, res, peer) => {
const env = await requireEnv();
if (env.on_apply !== APPLY_POLICY.AUTO) {
sendSignedJson(req, res, HTTP_STATUS.FORBIDDEN, { error: "target requires admin confirmation (on_apply=confirm); apply via the target admin UI" });
return;
}
const planId = req.body.plan_id;
if (!planId) {
sendSignedJson(req, res, HTTP_STATUS.BAD_REQUEST, { error: "plan_id required" });
return;
}
// Bind the plan to the calling peer AND to the push direction: a peer may only
// apply a plan it pushed itself -- never another peer's, nor a pull plan the
// local admin previewed from it and chose not to apply.
const owned = await planLib.loadPlan(planId);
if (!owned || owned.peer_id !== peer.peer_id || owned.direction !== PUSH_DIRECTION) {
sendSignedJson(req, res, HTTP_STATUS.NOT_FOUND, { error: "unknown plan" });
return;
}
let result;
try {
// The SENDER can never satisfy the human gates: an empty typed allow-list
// means no data-bearing table/field is ever dropped remotely (whatever
// on_delete says), and no confirmed renames means no table is renamed.
result = await ops.applyPlan(planId, {
prunable: [],
confirmDataDrops: false,
confirmRenames: []
});
} catch (err) {
sendSignedJson(req, res, HTTP_STATUS.CONFLICT, { error: err.message });
return;
}
sendSignedJson(req, res, HTTP_STATUS.OK, { ok: true, result });
};
// Serve a file's bytes by CONTENT HASH to an authenticated peer. Only files
// referenced by this instance's synced (managed/starter) rows or by its page
// layouts / view configs / site logo are resolvable, the resolved path is
// containment-checked against the tenant file store, and the bytes are read whole
// (capped) so the response signature covers exactly what is sent.
const apiFile = async (req, res) => {
const hash = req.params.hash;
if (!HASH_RE.test(hash || "")) {
sendSignedJson(req, res, HTTP_STATUS.BAD_REQUEST, { error: "bad hash" });
return;
}
const fieldValue = await rowsLib.resolveFileByHash(hash);
if (!fieldValue) {
sendSignedJson(req, res, HTTP_STATUS.NOT_FOUND, { error: "file not found", hash });
return;
}
let absPath;
try {
absPath = toAbsolutePath(db, fieldValue);
} catch (e) {
sendSignedJson(req, res, HTTP_STATUS.BAD_REQUEST, { error: "refusing file path outside tenant store" });
return;
}
let stat;
try {
stat = await fs.promises.stat(absPath);
} catch (e) {
sendSignedJson(req, res, HTTP_STATUS.NOT_FOUND, { error: "file missing from the store", hash });
return;
}
if (stat.size > PEER_LIMITS.FILE_BYTES) {
sendSignedJson(req, res, HTTP_STATUS.PAYLOAD_TOO_LARGE, { error: `file larger than ${PEER_LIMITS.FILE_BYTES} bytes`, hash });
return;
}
sendSigned(req, res, HTTP_STATUS.OK, await fs.promises.readFile(absPath), "application/octet-stream");
};
// ---------------- Route registration ----------------
const routes = [
{ url: ADMIN_URLS.DASHBOARD, method: "get", callback: dashboard },
{ url: ADMIN_URLS.SETTINGS, method: "post", callback: saveSettings },
{ url: ADMIN_URLS.PEERS, method: "get", callback: peersView },
{ url: ADMIN_URLS.PEERS_ADD, method: "post", callback: peersAdd },
{ url: ADMIN_URLS.PEERS_ROTATE, method: "post", callback: peersRotate },
{ url: ADMIN_URLS.PEERS_DELETE, method: "post", callback: peersDelete },
{ url: ADMIN_URLS.PLAN, method: "get", callback: planSelect },
{ url: ADMIN_URLS.PLAN, method: "post", callback: planPreview },
{ url: ADMIN_URLS.APPLY, method: "post", callback: applyView },
{ url: ADMIN_URLS.ROLLBACK, method: "post", callback: rollbackView },
{ url: ADMIN_URLS.TABLES, method: "get", callback: tablesView },
{ url: ADMIN_URLS.TABLES_SET, method: "post", callback: tablesSet },
{ url: API_PATHS.HEALTH, method: "get", callback: peerRoute(apiHealth), noCsrf: true },
{ url: API_PATHS.STATE, method: "get", callback: peerRoute(apiState), noCsrf: true },
{ url: API_PATHS.FILE_ROUTE, method: "get", callback: peerRoute(apiFile), noCsrf: true },
{ url: API_PATHS.PLAN, method: "post", callback: peerRoute(apiPlan), noCsrf: true },
{ url: API_PATHS.APPLY, method: "post", callback: peerRoute(apiApply), noCsrf: true },
];
module.exports = {
routes
};

231
lib/rowIdentity.js Normal file
View file

@ -0,0 +1,231 @@
// Row identity for reference-data sync.
//
// Reference/lookup rows are matched across sites by a plugin-managed stable
// identity column, `_ss_row_uuid`, NEVER by the local integer primary key
// (reassigned per site) and NEVER by fragile business-key guessing. When a table
// is opted into managed/starter mode we add `_ss_row_uuid` as a REGISTERED Saltcorn
// Field (not a raw ALTER) -- registration is what makes the column survive
// Saltcorn backup/restore; a raw column would be dumped by backup but absent from
// the restored field list and roll back the whole table's restore = app-data loss.
//
// Because it is a real Field, insertRow/updateRow carry its value directly -- no
// separate raw write needed. The uuid is unique per table by construction: every
// ensureManagedSchema pass re-keys duplicates (a row copied with all its fields,
// e.g. by a "duplicate row" action, would otherwise collapse two source rows into
// one on the target).
const db = require("@saltcorn/data/db");
const Table = require("@saltcorn/data/models/table");
const Field = require("@saltcorn/data/models/field");
const { randomUuid } = require("./ids");
const { DATA_MODES, isInfraName, isLockedTable } = require("./constants");
const ROW_UUID_FIELD = "_ss_row_uuid";
// Schema-qualified, quoted PHYSICAL table reference for the few raw queries we
// need. Saltcorn names the physical table sqlsanitize(name) (e.g. "Blog Posts" ->
// "BlogPosts"), so the raw name must never be interpolated directly.
const tableSqlRef = (name) => `${db.getTenantSchemaPrefix()}"${db.sqlsanitize(name)}"`;
// Quoted physical column reference (primary-key names come from tbl.pk_name).
const columnSqlRef = (name) => `"${db.sqlsanitize(name)}"`;
// Name of the uuid index created before 0.0.2 (unquoted, so Postgres folded it to
// lower case; SQLite index names compare case-insensitively).
const legacyIndexName = (tableName) => `${tableName}_ss_row_uuid`.toLowerCase();
// Exactly one uuid lookup index per table: drop the pre-0.0.2 one, create the
// current (quoted, sanitized) one.
const ensureIndex = async (tableName) => {
await db.query(`DROP INDEX IF EXISTS ${db.getTenantSchemaPrefix()}"${legacyIndexName(tableName)}"`);
await db.query(
`CREATE INDEX IF NOT EXISTS "${db.sqlsanitize(tableName)}_ss_row_uuid_idx" ON ${tableSqlRef(tableName)} (${ROW_UUID_FIELD})`
);
};
// Give every row sharing a uuid with a lower-pk row a fresh uuid.
const rekeyDuplicateUuids = async (tableName, pk) => {
const ref = tableSqlRef(tableName);
const pkRef = columnSqlRef(pk);
const dups = (await db.query(
`SELECT ${pkRef} AS pk, ${ROW_UUID_FIELD} AS u FROM ${ref} WHERE ${ROW_UUID_FIELD} IN ` +
`(SELECT ${ROW_UUID_FIELD} FROM ${ref} GROUP BY ${ROW_UUID_FIELD} HAVING COUNT(*) > 1) ORDER BY ${pkRef}`
)).rows;
const seen = new Set();
for (const r of dups) {
if (seen.has(r.u)) {
await db.query(`UPDATE ${ref} SET ${ROW_UUID_FIELD} = $1 WHERE ${pkRef} = $2`, [randomUuid(), r.pk]);
} else {
seen.add(r.u);
}
}
return dups.length - seen.size;
};
// Ensure `tableName` carries a registered _ss_row_uuid String field and every row
// has a value, unique within the table. Idempotent. Returns { added, backfilled, rekeyed }.
const ensureManagedSchema = async (tableName) => {
const tbl = Table.findOne({ name: tableName });
if (!tbl) {
throw new Error(`ensureManagedSchema: no such table ${tableName}`);
}
let added = false;
if (!tbl.getField(ROW_UUID_FIELD)) {
await Field.create({
table: tbl,
name: ROW_UUID_FIELD,
label: ROW_UUID_FIELD,
type: "String",
required: false,
is_unique: false,
attributes: { hidden: true }
});
added = true;
}
// Backfill any NULL uuids (existing rows created before opt-in), then make the
// identity unique.
const fresh = Table.findOne({ name: tableName });
const pkRef = columnSqlRef(fresh.pk_name);
const nulls = (await db.query(
`SELECT ${pkRef} AS pk FROM ${tableSqlRef(tableName)} WHERE ${ROW_UUID_FIELD} IS NULL`
)).rows;
for (const r of nulls) {
await db.query(
`UPDATE ${tableSqlRef(tableName)} SET ${ROW_UUID_FIELD} = $1 WHERE ${pkRef} = $2`,
[randomUuid(), r.pk]
);
}
const rekeyed = await rekeyDuplicateUuids(tableName, fresh.pk_name);
await ensureIndex(tableName);
return { added, backfilled: nulls.length, rekeyed };
};
// uuid -> local primary key for every identified row of a table (empty when the
// table has no identity column yet). One query instead of a lookup per row.
const localUuidMap = async (tableName) => {
const tbl = Table.findOne({ name: tableName });
const map = new Map();
if (!tbl || !tbl.getField(ROW_UUID_FIELD)) {
return map;
}
const rs = await db.query(
`SELECT ${columnSqlRef(tbl.pk_name)} AS pk, ${ROW_UUID_FIELD} AS u FROM ${tableSqlRef(tableName)} WHERE ${ROW_UUID_FIELD} IS NOT NULL`
);
for (const r of rs.rows) {
map.set(r.u, r.pk);
}
return map;
};
// Resolve a portable row uuid to the LOCAL primary-key value, or null.
const findIdByRowUuid = async (tableName, uuid) => {
const tbl = Table.findOne({ name: tableName });
if (!tbl || !tbl.getField(ROW_UUID_FIELD)) {
return null;
}
const rs = await db.query(
`SELECT ${columnSqlRef(tbl.pk_name)} AS pk FROM ${tableSqlRef(tableName)} WHERE ${ROW_UUID_FIELD} = $1 LIMIT 1`,
[uuid]
);
return rs.rows.length > 0 ? rs.rows[0].pk : null;
};
// Resolve a LOCAL primary-key value to its portable row uuid, or null. Used when
// translating an FK column value to the referenced row's stable identity.
const getRowUuidById = async (tableName, id) => {
const tbl = Table.findOne({ name: tableName });
if (!tbl || !tbl.getField(ROW_UUID_FIELD) || id === null || id === undefined) {
return null;
}
const rs = await db.query(
`SELECT ${ROW_UUID_FIELD} AS u FROM ${tableSqlRef(tableName)} WHERE ${columnSqlRef(tbl.pk_name)} = $1 LIMIT 1`,
[id]
);
return rs.rows.length > 0 ? rs.rows[0].u : null;
};
// A shallow copy of `row` without its primary-key column.
const withoutPk = (row, pkName) => {
const out = Object.assign({}, row);
delete out[pkName];
return out;
};
// Order `names` so a table comes after every other listed table it references by
// FK (parents first). Reverse it for deletes (children first). FK cycles keep
// their input order.
const parentFirstOrder = (names) => {
const wanted = new Set(names);
const done = new Set();
const out = [];
const visit = (name, path) => {
if (done.has(name) || path.has(name)) {
return;
}
path.add(name);
const tbl = Table.findOne({ name });
for (const f of (tbl ? tbl.getFields() : [])) {
if (f.is_fkey && wanted.has(f.reftable_name) && f.reftable_name !== name) {
visit(f.reftable_name, path);
}
}
path.delete(name);
done.add(name);
out.push(name);
};
for (const n of names) {
visit(n, new Set());
}
return out;
};
// Receiving-side gate: a peer-supplied row op is refused unless THIS instance has
// the table in managed/starter mode and the table is not hard-locked. Never trust
// the sender. `mode` is the target's local data mode for the table.
const assertRowSyncAllowed = (tableName, mode) => {
if (isLockedTable(tableName)) {
throw new Error(`row sync refused: "${tableName}" is a locked system table`);
}
if (mode !== DATA_MODES.MANAGED && mode !== DATA_MODES.STARTER) {
throw new Error(`row sync refused: "${tableName}" is not in managed/starter mode locally`);
}
};
// Guard for setting a table's mode: infra tables never get a data mode, and a
// locked table may only ever be `user`.
const assertManagedModeAllowed = (tableName, mode) => {
if (isInfraName(tableName)) {
throw new Error(`"${tableName}" is plugin/system infrastructure and has no data mode`);
}
if (isLockedTable(tableName) && mode !== DATA_MODES.USER) {
throw new Error(`"${tableName}" is a locked system table and cannot be managed/starter`);
}
};
module.exports = {
ROW_UUID_FIELD,
ensureManagedSchema,
localUuidMap,
findIdByRowUuid,
getRowUuidById,
withoutPk,
parentFirstOrder,
assertRowSyncAllowed,
assertManagedModeAllowed
};

162
lib/rowPayload.js Normal file
View file

@ -0,0 +1,162 @@
// Portable row payloads: translate cross-site references so a reference row means
// the same thing on both instances.
//
// * The local primary key is dropped (reassigned per site).
// * The stable identity `_ss_row_uuid` travels as `__uuid` (the match key).
// * A foreign key into a managed/starter table is replaced with the referenced
// row's `_ss_row_uuid` under `<field>__uuid`; on apply it is resolved back to
// the local id. An FK into a USER-mode table (no cross-site identity) is nulled
// with a warning -- reference identity only exists for managed/starter tables.
// * File-typed fields travel as a CONTENT-ADDRESSED placeholder under
// `<field>__file` ({hash, filename, mime, min_role_read}); the row engine
// fetches the bytes by hash and materializes a local file.
// * Other infra columns (any other "_"-prefixed field, e.g. dev-deploy's
// _dd_row_uuid) are per-instance bookkeeping and never travel.
//
// This module is the single source of truth for the portable key format.
const File = require("@saltcorn/data/models/file");
const { ROW_UUID_FIELD, findIdByRowUuid, getRowUuidById } = require("./rowIdentity");
const { isInfraName, isSyncedMode } = require("./constants");
const { hashFileContents } = require("./common/fileStore");
// The row identity travels under PORTABLE_UUID_KEY; FK columns as
// "<field>__uuid" (same suffix); File fields as "<field>__file".
const UUID_SUFFIX = "__uuid";
const PORTABLE_UUID_KEY = UUID_SUFFIX;
const FILE_SUFFIX = "__file";
const isFkUuidKey = (key) => key !== PORTABLE_UUID_KEY && key.endsWith(UUID_SUFFIX);
const isFileKey = (key) => key.endsWith(FILE_SUFFIX);
const fileFieldOf = (key) => key.slice(0, -FILE_SUFFIX.length);
// Row -> portable. `modeOf(tableName)` yields the local data mode of a table so we
// know whether an FK target participates in cross-site identity.
const rowToPortable = async (row, table, modeOf) => {
const portable = {};
const warnings = [];
const pk = table.pk_name;
for (const field of table.getFields()) {
const name = field.name;
if (name === pk) {
continue;
}
if (name === ROW_UUID_FIELD) {
portable[PORTABLE_UUID_KEY] = row[name];
continue;
}
if (isInfraName(name)) {
continue;
}
const value = row[name];
if (field.type === "File") {
// Ship a CONTENT-ADDRESSED placeholder: the file's sha256 hash plus the
// metadata needed to recreate it. The bytes are fetched separately by the
// target from GET /api/file/:hash. Nulled (with a warning) if the file is
// missing on disk.
if (value === null || value === undefined || value === "") {
portable[`${name}${FILE_SUFFIX}`] = null;
} else {
try {
const file = await File.findOne(value);
if (file) {
portable[`${name}${FILE_SUFFIX}`] = {
hash: await hashFileContents(file),
filename: file.filename,
mime: file.mimetype,
min_role_read: file.min_role_read
};
} else {
warnings.push(`file field ${table.name}.${name} value "${value}" not found on disk; nulled`);
portable[`${name}${FILE_SUFFIX}`] = null;
}
} catch (e) {
warnings.push(`file field ${table.name}.${name}: ${e.message}`);
portable[`${name}${FILE_SUFFIX}`] = null;
}
}
continue;
}
if (field.is_fkey) {
const reftable = field.reftable_name;
if (reftable && isSyncedMode(modeOf(reftable))) {
if (value === null || value === undefined) {
portable[`${name}${UUID_SUFFIX}`] = null;
} else {
const refUuid = await getRowUuidById(reftable, value);
if (!refUuid) {
warnings.push(`FK ${table.name}.${name} -> ${reftable} could not resolve a row uuid`);
}
portable[`${name}${UUID_SUFFIX}`] = refUuid || null;
}
} else {
if (value !== null && value !== undefined) {
warnings.push(`FK ${table.name}.${name} into non-synced table ${reftable || "?"} nulled`);
}
portable[name] = null;
}
continue;
}
portable[name] = value;
}
return { portable, warnings };
};
// Portable -> row (local ids resolved). Returns { row, warnings }. The returned row
// includes _ss_row_uuid so a plain insertRow/updateRow carries the identity.
// `resolveRef(reftable, uuid)` maps a referenced row's uuid to its local pk (or
// null); the row engine passes a cached resolver, the default queries per call.
// Infra keys from a peer (other than the identity) are never written.
const portableToRow = async (portable, table, resolveRef = findIdByRowUuid) => {
const row = {};
const warnings = [];
for (const key of Object.keys(portable)) {
if (key === PORTABLE_UUID_KEY) {
row[ROW_UUID_FIELD] = portable[key];
continue;
}
if (isFileKey(key)) {
// File placeholders are materialized by the row engine (needs the peer
// transport to fetch bytes); skip here.
continue;
}
if (isFkUuidKey(key)) {
const fieldName = key.slice(0, -UUID_SUFFIX.length);
const field = table.getField(fieldName);
if (!field) {
continue;
}
const refUuid = portable[key];
if (refUuid === null || refUuid === undefined) {
row[fieldName] = null;
continue;
}
const localId = await resolveRef(field.reftable_name, refUuid);
if (localId === null) {
warnings.push(`FK ${table.name}.${fieldName} unresolved (referenced row not synced yet); set null`);
row[fieldName] = null;
} else {
row[fieldName] = localId;
}
continue;
}
if (isInfraName(key)) {
continue;
}
row[key] = portable[key];
}
return { row, warnings };
};
module.exports = {
PORTABLE_UUID_KEY,
isFileKey,
fileFieldOf,
rowToPortable,
portableToRow
};

660
lib/rows.js Normal file
View file

@ -0,0 +1,660 @@
// Reference-data (row) sync: the opt-in user/starter/managed layer.
//
// user (default) -- rows belong to the target; sync never touches them.
// starter -- rows are SEEDED once into a fresh table, then the target
// owns them (no later update/delete).
// managed -- the source is canonical: rows are mirrored (insert new,
// update changed, delete rows the source no longer has),
// matched by the stable _ss_row_uuid.
//
// Modes are stored per table in _ss_table_modes. The users table (and any
// ROW_SYNC_LOCKED_TABLES) is hard-locked to `user`. Both the produce side (which
// tables a source ships) and the apply side (which tables a target accepts) gate
// on the LOCAL mode, and the apply side re-enforces the lock -- never trusting the
// sender. FK columns are carried as the referenced row's uuid and resolved back to
// a local id in a second pass so cross-table ordering does not strand FKs.
//
// Re-applying an unchanged source is a no-op: a managed row is only written when
// its resolved values differ from the local row (updateRow fires Update triggers
// and writes history on versioned tables, so blind rewrites are not harmless).
const { isEqual } = require("lodash");
const db = require("@saltcorn/data/db");
const Table = require("@saltcorn/data/models/table");
const Field = require("@saltcorn/data/models/field");
const File = require("@saltcorn/data/models/file");
const rowId = require("./rowIdentity");
const { PORTABLE_UUID_KEY, isFileKey, fileFieldOf, rowToPortable, portableToRow } = require("./rowPayload");
const { sha256Hex } = require("./common/hash");
const { DATA_MODES, ADMIN_ROLE_ID, ADMIN_USER_ID, isInfraName, isLockedTable, isSyncedMode } = require("./constants");
const layoutFiles = require("./layoutFiles");
// Content hash of the file a File-field value points at (or null if missing);
// stat-cached, so re-syncing unchanged files does not re-read their bytes.
const hashOfFieldValue = layoutFiles.localFileHash;
const { withoutPk } = rowId;
// A table a target will consider for row sync: synced mode, not infra/locked.
const isRowSyncCandidate = (name, mode) => isSyncedMode(mode) && !isLockedTable(name) && !isInfraName(name);
// Resolve ONE File placeholder to a LOCAL File-field value: keep the existing file
// when its content already matches (idempotent re-sync), else fetch the bytes by
// hash via opts.fileFetcher, verify integrity, and File.from_contents it into this
// tenant's store. On any failure the existing value is KEPT (a transient fetch
// error must not blank a working reference) and a warning is pushed.
const materializeFile = async (spec, tbl, field, opts, existingValue) => {
const keep = existingValue === undefined ? null : existingValue;
if (!spec || !spec.hash) {
return null;
}
if (existingValue && (await hashOfFieldValue(existingValue)) === spec.hash) {
return existingValue;
}
if (typeof opts.fileFetcher !== "function") {
opts.warnings.push(`no file transport available; ${tbl.name}.${field} not updated`);
return keep;
}
let bytes;
try {
bytes = await opts.fileFetcher(spec.hash);
} catch (e) {
opts.warnings.push(`fetch of ${tbl.name}.${field} (hash ${layoutFiles.shortHash(spec.hash)}) failed: ${e.message}`);
return keep;
}
if (sha256Hex(bytes) !== spec.hash) {
opts.warnings.push(`content hash mismatch for ${tbl.name}.${field}; refusing`);
return keep;
}
try {
// File ownership is not portable across two independent user tables, so a
// synced reference file is owned by the admin user by design; min_role_read
// (the access gate that IS meaningful) is carried.
const file = await File.from_contents(spec.filename || "file", spec.mime || "application/octet-stream", bytes, ADMIN_USER_ID, spec.min_role_read || ADMIN_ROLE_ID, "/");
return file.field_value;
} catch (e) {
opts.warnings.push(`could not store ${tbl.name}.${field}: ${e.message}`);
return keep;
}
};
// Resolve a portable row's "<field>__file" placeholders (all of them, or only the
// fields in `onlyFields`) to a { <field>: fieldValue } patch to merge into the row.
const materializeFiles = async (portable, tbl, opts, existingRow, onlyFields) => {
const patch = {};
for (const key of Object.keys(portable).filter(isFileKey)) {
const field = fileFieldOf(key);
if (onlyFields && !onlyFields.has(field)) {
continue;
}
patch[field] = await materializeFile(portable[key], tbl, field, opts, existingRow ? existingRow[field] : undefined);
}
return patch;
};
// Would applying portable `desired` (already FK-resolved, File placeholders
// excluded) change `existing`? Values are compared in their JSON form, which is how
// they travel (Date -> ISO string, booleans/JSON as parsed by getRows).
const toJsonValue = (v) => (v === undefined || v === null ? null : JSON.parse(JSON.stringify(v)));
const rowDiffers = (existing, desired) => {
return Object.keys(desired).some((k) => !isEqual(toJsonValue(existing[k]), toJsonValue(desired[k])));
};
// Does any "<field>__file" placeholder point at different content than `existing`?
const filesDiffer = async (portable, existing) => {
for (const key of Object.keys(portable).filter(isFileKey)) {
const spec = portable[key];
const have = existing[fileFieldOf(key)];
const want = spec && spec.hash ? spec.hash : null;
if (want !== (have ? await hashOfFieldValue(have) : null)) {
return true;
}
}
return false;
};
// FK resolution with a per-table uuid->pk cache, kept current as rows are inserted
// (so the insert fixpoint does not issue a lookup query per row per round).
const makeRefResolver = () => {
const maps = new Map();
const mapOf = async (tableName) => {
if (!maps.has(tableName)) {
maps.set(tableName, await rowId.localUuidMap(tableName));
}
return maps.get(tableName);
};
return {
mapOf,
resolve: async (tableName, uuid) => {
const pk = (await mapOf(tableName)).get(uuid);
return pk === undefined ? null : pk;
},
add: async (tableName, uuid, pk) => {
(await mapOf(tableName)).set(uuid, pk);
}
};
};
// --- source-side file serving (content-addressed) ---
// hash -> File-field value, cached per tenant with a short TTL. Content-addressing
// makes the cache safe (a given hash always maps to identical bytes). The TTL bounds
// two things: (1) an authenticated peer spamming UNKNOWN hashes can force at most one
// index rebuild per window (a miss does NOT rebuild if the cache is fresh), and (2)
// staleness after a managed file changes -- the entry is refreshed within the window.
const fileIndexCache = {};
const FILE_INDEX_TTL_MS = 5000;
const buildFileIndex = async () => {
const modes = await modeMap();
const map = new Map();
for (const [name, mode] of Object.entries(modes)) {
if (!isRowSyncCandidate(name, mode)) {
continue;
}
const tbl = Table.findOne({ name });
if (!tbl) {
continue;
}
const fileFields = tbl.getFields().filter((f) => f.type === "File");
if (fileFields.length === 0) {
continue;
}
const rowsData = await tbl.getRows({}, {});
for (const r of rowsData) {
for (const f of fileFields) {
const fv = r[f.name];
if (!fv) {
continue;
}
const h = await hashOfFieldValue(fv);
if (h) {
map.set(h, fv);
}
}
}
}
// Also index files referenced inside page layouts / view configs / the site logo.
await layoutFiles.indexLocalFiles(map);
return map;
};
const resolveFileByHash = async (hash) => {
const schema = db.getTenantSchema();
const entry = fileIndexCache[schema];
const fresh = entry && (Date.now() - entry.builtAt) < FILE_INDEX_TTL_MS;
// Rebuild only when there is no cache, or the hash is missing AND the cache is
// stale. An unknown hash against a fresh cache returns null WITHOUT a rebuild,
// so unknown-hash spam cannot force a scan on every request.
if (!entry || (!entry.map.has(hash) && !fresh)) {
fileIndexCache[schema] = { map: await buildFileIndex(), builtAt: Date.now() };
}
return fileIndexCache[schema].map.get(hash) || null;
};
// --- data-mode storage (_ss_table_modes) ---
const modeRow = async (tableName) => {
return await db.selectMaybeOne("_ss_table_modes", { table_name: tableName });
};
const dataModeOf = async (tableName) => {
const row = await modeRow(tableName);
return (row && row.data_mode) || DATA_MODES.USER;
};
// name -> stored mode row ({ table_name, data_mode, starter_shipped_at, ... }) for
// every table that has one.
const modeRecords = async () => {
const rows = await db.select("_ss_table_modes", {});
return new Map(rows.map((r) => [r.table_name, r]));
};
// name -> mode for every non-user table (used to decide FK-target eligibility).
const modeMap = async () => {
const m = {};
for (const [name, r] of await modeRecords()) {
m[name] = r.data_mode;
}
return m;
};
const setDataMode = async (tableName, mode) => {
if (!Object.values(DATA_MODES).includes(mode)) {
throw new Error(`invalid data mode: ${mode} (one of ${Object.values(DATA_MODES).join(", ")})`);
}
rowId.assertManagedModeAllowed(tableName, mode);
if (!Table.findOne({ name: tableName })) {
throw new Error(`no such table ${tableName}`);
}
const now = new Date().toISOString();
const existing = await modeRow(tableName);
if (existing) {
await db.updateWhere("_ss_table_modes", { data_mode: mode, updated_at: now }, { table_name: tableName });
} else {
await db.insert("_ss_table_modes", { table_name: tableName, data_mode: mode, updated_at: now, starter_shipped_at: null }, { noid: true });
}
if (isSyncedMode(mode)) {
await rowId.ensureManagedSchema(tableName);
}
};
const markStarterShipped = async (tableName) => {
await db.updateWhere("_ss_table_modes", { starter_shipped_at: new Date().toISOString() }, { table_name: tableName });
};
// Forget that a starter table was seeded (rollback of the apply that seeded it), so
// the next apply seeds it again.
const clearStarterShipped = async (tableName) => {
await db.updateWhere("_ss_table_modes", { starter_shipped_at: null }, { table_name: tableName });
};
// Carry a table's data mode across a Table.rename (modes are keyed by name).
const renameModeRow = async (fromName, toName) => {
if (!(await modeRow(fromName))) {
return;
}
await db.deleteWhere("_ss_table_modes", { table_name: toName });
await db.updateWhere("_ss_table_modes", { table_name: toName, updated_at: new Date().toISOString() }, { table_name: fromName });
};
// --- produce side: the row manifest a source ships ---
// The present managed/starter tables a source ships (from a modeMap), and a
// warning for each synced table that no longer exists.
const shippedTables = (modes) => {
const candidates = Object.entries(modes)
.filter(([name, mode]) => isRowSyncCandidate(name, mode))
.map(([name]) => name);
const names = candidates.filter((name) => Table.findOne({ name }));
const warnings = candidates
.filter((name) => !names.includes(name))
.map((name) => `"${name}" is ${modes[name]} but no longer exists (renamed or dropped?); its rows are not shipped -- set the data mode on the current table`);
return { names, warnings };
};
// { counts: { tableName: { mode, rows } }, warnings } for the tables
// buildRowManifest would ship -- counted, not read, and without its identity
// backfill, so it changes nothing.
const shippedRowCounts = async () => {
const modes = await modeMap();
const { names, warnings } = shippedTables(modes);
const counts = {};
for (const name of names) {
counts[name] = { mode: modes[name], rows: await Table.findOne({ name }).countRows() };
}
return { counts, warnings };
};
// { tableName: { mode, rows: [portableRow] } } for every locally managed/starter
// table. Rows are read with no user (full system read). A PRE-PASS ensures every
// synced table has _ss_row_uuid backfilled BEFORE any row is read, so translating a
// row's FK to the referenced row's uuid never sees a not-yet-backfilled target
// (which would null the FK just because of table iteration order).
const buildRowManifest = async () => {
const modes = await modeMap();
const modeOf = (t) => modes[t] || DATA_MODES.USER;
const { names: syncedNames, warnings } = shippedTables(modes);
for (const name of syncedNames) {
await rowId.ensureManagedSchema(name);
}
const manifest = {};
for (const name of syncedNames) {
const fresh = Table.findOne({ name });
const rows = await fresh.getRows({}, {});
const portableRows = [];
for (const r of rows) {
const { portable, warnings: w } = await rowToPortable(r, fresh, modeOf);
portableRows.push(portable);
warnings.push(...w);
}
manifest[name] = { mode: modes[name], rows: portableRows };
}
return { manifest, warnings };
};
// Which manifest tables this target will actually sync, and why the rest are skipped.
const eligibleTables = async (manifest) => {
const eligible = [];
const skipped = [];
const warnings = [];
for (const name of Object.keys(manifest || {})) {
if (isLockedTable(name) || isInfraName(name)) {
skipped.push({ name, reason: "locked system / infrastructure table" });
continue;
}
const mode = await dataModeOf(name);
if (!isSyncedMode(mode)) {
skipped.push({ name, reason: "not managed/starter on this target" });
continue;
}
if (!Table.findOne({ name })) {
// The mode says managed/starter but the table isn't present locally (e.g.
// it wasn't created yet, or was dropped). Skip rather than crash.
skipped.push({ name, reason: "table not present locally" });
continue;
}
eligible.push({ name, mode, rows: identifiedRows(name, manifest[name].rows || [], warnings) });
}
return { eligible, skipped, warnings };
};
// The manifest rows that carry an identity, first occurrence per uuid. A row with
// no (or a repeated) uuid could only be inserted again on every apply, so it is
// skipped with a warning.
const identifiedRows = (name, rows, warnings) => {
const seen = new Set();
const out = [];
for (const p of rows) {
const uuid = p[PORTABLE_UUID_KEY];
if (!uuid || seen.has(uuid)) {
warnings.push(`a "${name}" row without a unique identity was skipped`);
continue;
}
seen.add(uuid);
out.push(p);
}
return out;
};
// --- apply side ---
const requiredFileFields = (tbl) => new Set(tbl.getFields().filter((f) => f.type === "File" && f.required).map((f) => f.name));
// INSERT FIXPOINT: insert rows whose uuid has no local id yet. A row with an
// unresolved REQUIRED FK fails tryInsertRow this round and is retried in later
// rounds once its parent exists (parent->child ordering to arbitrary depth); the
// loop ends when a round inserts nothing, and every row still pending is reported.
// Files are normally materialized only in the converge pass (once the row exists);
// a REQUIRED File field cannot be inserted empty, so it is materialized here ONCE
// per row (cached across rounds, so retries never write another copy).
// Returns name -> Set(uuid) of the rows inserted.
const insertMissingRows = async (active, resolver, fileOpts, result) => {
const pending = new Map();
const inserted = new Map();
const lastError = new Map();
const filePatch = new Map();
for (const t of active) {
const local = await resolver.mapOf(t.name);
pending.set(t.name, t.rows.filter((p) => !local.has(p[PORTABLE_UUID_KEY])));
inserted.set(t.name, new Set());
}
let progress = true;
while (progress) {
progress = false;
for (const t of active) {
const tbl = Table.findOne({ name: t.name });
const requiredFiles = requiredFileFields(tbl);
const left = [];
for (const portable of pending.get(t.name)) {
const uuid = portable[PORTABLE_UUID_KEY];
const { row } = await portableToRow(portable, tbl, resolver.resolve);
if (requiredFiles.size > 0) {
if (!filePatch.has(uuid)) {
filePatch.set(uuid, await materializeFiles(portable, tbl, fileOpts, null, requiredFiles));
}
Object.assign(row, filePatch.get(uuid));
}
const r = await tbl.tryInsertRow(withoutPk(row, tbl.pk_name));
if (r.success === undefined) {
lastError.set(uuid, r.error);
left.push(portable);
continue;
}
await resolver.add(t.name, uuid, r.success);
inserted.get(t.name).add(uuid);
result.perTable[t.name].inserted += 1;
progress = true;
}
pending.set(t.name, left);
}
}
for (const [name, left] of pending) {
for (const p of left) {
result.warnings.push(`"${name}" row ${p[PORTABLE_UUID_KEY]} could not be inserted: ${lastError.get(p[PORTABLE_UUID_KEY])}`);
}
}
return inserted;
};
// CONVERGE pass: now that all rows exist, resolve FKs, materialize files, and write
// a row only when its resolved values differ from the local row. Managed rows
// always converge; starter rows only in the run that seeds them (afterwards the
// target owns them). One getRows per table supplies the comparison baseline.
const convergeRows = async (active, resolver, fileOpts, inserted, result) => {
for (const t of active) {
const tbl = Table.findOne({ name: t.name });
const pk = tbl.pk_name;
const local = await resolver.mapOf(t.name);
const fresh = inserted.get(t.name);
const existingByPk = new Map((await tbl.getRows({}, {})).map((r) => [r[pk], r]));
for (const portable of t.rows) {
const uuid = portable[PORTABLE_UUID_KEY];
const existing = existingByPk.get(local.get(uuid));
if (!existing || (t.mode === DATA_MODES.STARTER && !fresh.has(uuid))) {
continue;
}
const { row, warnings } = await portableToRow(portable, tbl, resolver.resolve);
result.warnings.push(...warnings);
Object.assign(row, await materializeFiles(portable, tbl, fileOpts, existing));
if (!rowDiffers(existing, row)) {
continue;
}
const err = await tbl.updateRow(withoutPk(row, pk), existing[pk]);
if (typeof err === "string") {
result.warnings.push(`"${t.name}" row ${uuid} was not updated: ${err}`);
continue;
}
if (!fresh.has(uuid)) {
result.perTable[t.name].updated += 1;
}
}
}
};
// Tables OUTSIDE `pruning` whose FK columns reference `tableName`. A managed row
// they still point at is never pruned: a RESTRICT FK would abort the apply, and a
// CASCADE / SET NULL FK would silently change user-mode data.
const protectingRefs = async (tableName, pruning) => {
const refs = [];
for (const f of await Field.find({ reftable_name: tableName })) {
const refTbl = Table.findOne({ id: f.table_id });
if (refTbl && !pruning.has(refTbl.name)) {
refs.push({ tbl: refTbl, field: f.name });
}
}
return refs;
};
const referencingTable = async (refs, pk) => {
for (const r of refs) {
if ((await r.tbl.countRows({ [r.field]: pk })) > 0) {
return r.tbl.name;
}
}
return null;
};
// PRUNE: for each target { name, keep: Set(uuid) }, delete the identified local
// rows whose uuid is not kept -- children-first, retrying failed deletes until a
// round makes no progress (resolves FK chains between the pruned tables, including
// self-references). Each failure is isolated and reported -- a prune error never
// aborts the caller. Returns name -> number of rows deleted. Shared by the managed
// row sync and rollback (removing rows an apply inserted).
const pruneRowsNotIn = async (targets, warnings) => {
const order = rowId.parentFirstOrder(targets.map((t) => t.name)).reverse();
const pruning = new Set(order);
const deleted = new Map(order.map((n) => [n, 0]));
let pending = [];
for (const name of order) {
const keep = targets.find((t) => t.name === name).keep;
const tbl = Table.findOne({ name });
const refs = await protectingRefs(name, pruning);
for (const [uuid, pk] of await rowId.localUuidMap(name)) {
if (keep.has(uuid)) {
continue;
}
const holder = await referencingTable(refs, pk);
if (holder) {
warnings.push(`"${name}" row ${uuid} is still referenced by "${holder}"; not removed`);
continue;
}
pending.push({ tbl, pk, uuid, error: null });
}
}
let progress = true;
while (pending.length > 0 && progress) {
progress = false;
const round = pending;
pending = [];
for (const p of round) {
try {
await p.tbl.deleteRows({ [p.tbl.pk_name]: p.pk });
deleted.set(p.tbl.name, deleted.get(p.tbl.name) + 1);
progress = true;
} catch (e) {
p.error = e.message;
pending.push(p);
}
}
}
for (const p of pending) {
warnings.push(`"${p.tbl.name}" row ${p.uuid} could not be removed: ${p.error}`);
}
return deleted;
};
const applyRowSync = async (manifest, opts = {}) => {
const { eligible, skipped, warnings } = await eligibleTables(manifest);
const result = { perTable: {}, skipped, warnings };
for (const t of eligible) {
// Never trust the sender: re-enforce the lock + local mode gate.
rowId.assertRowSyncAllowed(t.name, t.mode);
await rowId.ensureManagedSchema(t.name);
}
// Which tables are actively synced this run (starters already shipped are inert).
const active = [];
for (const t of eligible) {
const info = await modeRow(t.name);
if (t.mode === DATA_MODES.STARTER && info && info.starter_shipped_at) {
result.perTable[t.name] = { mode: t.mode, inserted: 0, updated: 0, deleted: 0, note: "starter already shipped" };
continue;
}
result.perTable[t.name] = { mode: t.mode, inserted: 0, updated: 0, deleted: 0 };
active.push(t);
}
const fileOpts = { fileFetcher: opts.fileFetcher, warnings: result.warnings };
const resolver = makeRefResolver();
const inserted = await insertMissingRows(active, resolver, fileOpts, result);
await convergeRows(active, resolver, fileOpts, inserted, result);
const managed = eligible.filter((t) => t.mode === DATA_MODES.MANAGED);
const pruned = await pruneRowsNotIn(managed.map((t) => ({ name: t.name, keep: new Set(t.rows.map((p) => p[PORTABLE_UUID_KEY])) })), result.warnings);
for (const [name, n] of pruned) {
result.perTable[name].deleted = n;
}
// A starter is marked shipped (never touched again) only once EVERY one of its
// rows is present; an incomplete seed is retried on the next apply.
for (const t of active.filter((x) => x.mode === DATA_MODES.STARTER)) {
const local = await resolver.mapOf(t.name);
const missing = t.rows.filter((p) => !local.has(p[PORTABLE_UUID_KEY])).length;
if (missing === 0) {
await markStarterShipped(t.name);
} else {
result.warnings.push(`starter "${t.name}": ${missing} row(s) not seeded; the seed is retried on the next apply`);
}
}
return result;
};
// Preview: per-table insert/update/delete counts, computed exactly as apply would
// decide them but WITHOUT mutating (no uuid backfill: rows that have no identity
// yet are backfilled at apply time and then pruned, so they count as deletes).
const previewRowSync = async (manifest) => {
const { eligible, skipped, warnings } = await eligibleTables(manifest);
const perTable = {};
const resolver = makeRefResolver();
for (const t of eligible) {
const info = await modeRow(t.name);
if (t.mode === DATA_MODES.STARTER && info && info.starter_shipped_at) {
perTable[t.name] = { mode: t.mode, inserts: 0, updates: 0, deletes: 0 };
continue;
}
const tbl = Table.findOne({ name: t.name });
const local = await resolver.mapOf(t.name);
const managed = t.mode === DATA_MODES.MANAGED;
const existingByPk = managed ? new Map((await tbl.getRows({}, {})).map((r) => [r[tbl.pk_name], r])) : null;
let inserts = 0;
let updates = 0;
let deletes = 0;
for (const portable of t.rows) {
const localId = local.get(portable[PORTABLE_UUID_KEY]);
if (localId === undefined) {
inserts += 1;
} else if (managed) {
const existing = existingByPk.get(localId);
const { row } = await portableToRow(portable, tbl, resolver.resolve);
if (rowDiffers(existing, row) || (await filesDiffer(portable, existing))) {
updates += 1;
}
}
}
if (managed) {
const sourceUuids = new Set(t.rows.map((p) => p[PORTABLE_UUID_KEY]));
deletes = [...local.keys()].filter((u) => !sourceUuids.has(u)).length + (existingByPk.size - local.size);
}
perTable[t.name] = { mode: t.mode, inserts, updates, deletes };
}
return { perTable, skipped, warnings };
};
module.exports = {
dataModeOf,
modeRecords,
modeMap,
setDataMode,
clearStarterShipped,
renameModeRow,
buildRowManifest,
shippedRowCounts,
applyRowSync,
previewRowSync,
pruneRowsNotIn,
rowDiffers,
resolveFileByHash
};

151
lib/schema.js Normal file
View file

@ -0,0 +1,151 @@
// Schema setup for site-sync's plugin tables.
//
// Three storage strategies, chosen by WHEN the state is written, so each survives
// Saltcorn backup/restore without fighting the plugin lifecycle:
//
// * ENV IDENTITY -> _sc_config (lib/env.js + lib/common/configStore.js). Created during
// onLoad; _sc_config is restored BEFORE onLoad, so a restored host keeps its
// env_id and onLoad does not create a duplicate.
//
// * PEERS + TABLE MODES -> REGISTERED Saltcorn tables (auto `id` PK + the logical
// keys kept as unique fields). Written only at runtime (not during
// onLoad/restore), so being in _sc_tables they ride backup. Peer SECRETS must
// survive backup, so they belong here, not in a raw table.
//
// * NONCE STORE (_ss_seen_nonces), PERSISTED PLANS (_ss_plans), rollback
// RESTORE POINTS (_ss_rollback) and the APPLY LOCK (_ss_apply_lock) stay RAW:
// they are a replay cache / short-lived plan tokens / short-lived restore points
// / a lease, so losing them on a restored host is acceptable, and they must
// never carry must-survive state.
const db = require("@saltcorn/data/db");
const { PLUGIN_NAME, PEERS_TABLE, SEEN_NONCES_TABLE, APPLY_LOCK_TABLE } = require("./constants");
const { PEER_TABLE_FIELDS } = require("./common/peerStore");
const { createNonceStore } = require("./common/peerServer");
const { ensureRegisteredTable, physicalColumns } = require("./common/registeredTable");
// Field definitions (excluding the auto `id` PK) of the registered table-modes
// table (the peers table's come from lib/common/peerStore.js).
const TABLE_MODES_FIELDS = [
{ name: "table_name", type: "String", is_unique: true, required: true },
{ name: "data_mode", type: "String", required: true },
{ name: "updated_at", type: "String", required: true },
{ name: "starter_shipped_at", type: "String" }
];
// Make `name` a registered Saltcorn table with an auto `id` PK plus `fieldDefs`
// (lib/common/registeredTable.js: decided from _sc_tables, never the stale
// per-process cache; an unregistered stray table of that name is adopted with
// its rows, keyed by keyField; racing workers are serialized). A cheap no-op
// once registered. site-sync has no CRUD wrap layer, so (unlike dev-deploy)
// there is nothing to suppress here.
const registerTable = async (name, fieldDefs, keyField) => {
await ensureRegisteredTable({ owner: PLUGIN_NAME, name: name, fields: fieldDefs, keyField: keyField });
};
// Idempotent migration: add `column` to a raw table created before it existed (the
// CREATE TABLE IF NOT EXISTS leaves such a table as it was). Workers booting
// together race here; losing the race (the column exists now) is fine.
const addColumnIfMissing = async (table, column, sqlType) => {
if ((await physicalColumns(table)).has(column)) {
return;
}
try {
await db.query(`ALTER TABLE ${db.getTenantSchemaPrefix()}${table} ADD COLUMN ${column} ${sqlType}`);
} catch (e) {
if (!(await physicalColumns(table)).has(column)) {
throw e;
}
}
};
// Persisted plans for the two-phase preview -> apply flow. A plan is computed on
// the mutating (target) side, previewed, then applied by plan_id. Raw + regenerable
// (a lost plan just has to be re-previewed). desired_pack + plan_json are TEXT JSON.
// claimed_from = the status the last apply / rollback claimed the plan from: where
// lib/siteLock.js returns a plan whose apply or rollback was abandoned.
const createPlans = async () => {
const schema = db.getTenantSchemaPrefix();
await db.query(`
CREATE TABLE IF NOT EXISTS ${schema}_ss_plans (
plan_id TEXT PRIMARY KEY,
peer_id INTEGER,
source_env_id TEXT,
direction TEXT,
desired_pack TEXT,
plan_json TEXT,
precondition_hash TEXT,
source_hash TEXT,
status TEXT NOT NULL DEFAULT 'pending',
claimed_from TEXT,
created_at TEXT NOT NULL
)
`);
await db.query(`CREATE INDEX IF NOT EXISTS _ss_plans_created ON ${schema}_ss_plans (created_at)`);
await addColumnIfMissing("_ss_plans", "claimed_from", "TEXT");
};
// The site-wide apply lock (lib/siteLock.js): at most one row per tenant schema,
// the holder's lease ending at expires_ms (epoch milliseconds).
const createApplyLock = async () => {
await db.query(`
CREATE TABLE IF NOT EXISTS ${db.getTenantSchemaPrefix()}${APPLY_LOCK_TABLE} (
lock_name TEXT PRIMARY KEY,
holder TEXT NOT NULL,
operation TEXT NOT NULL,
plan_id TEXT,
acquired_at TEXT NOT NULL,
expires_ms BIGINT NOT NULL
)
`);
};
// 0.0.1 created a per-peer last-applied-hash table that nothing ever read; drop it.
const dropLegacyPeerState = async () => {
await db.query(`DROP TABLE IF EXISTS ${db.getTenantSchemaPrefix()}_ss_peer_state`);
};
// Pre-apply restore points for rollback: the pre-apply metadata pack + raw rows of
// the tables an apply will touch/drop, keyed by plan_id. Raw + short-lived (a lost
// restore point just means that apply can no longer be one-click reverted).
const createRollback = async () => {
const schema = db.getTenantSchemaPrefix();
await db.query(`
CREATE TABLE IF NOT EXISTS ${schema}_ss_rollback (
plan_id TEXT PRIMARY KEY,
restore_pack TEXT,
restore_rows TEXT,
restore_files TEXT,
created_at TEXT NOT NULL
)
`);
// Without the column (an install created before it) every saveRestorePoint
// INSERT would throw and silently disable ALL rollback.
await addColumnIfMissing("_ss_rollback", "restore_files", "TEXT");
};
const createAllTables = async () => {
// Backed-up state -> registered Saltcorn tables.
await registerTable(PEERS_TABLE, PEER_TABLE_FIELDS, "env_id");
await registerTable("_ss_table_modes", TABLE_MODES_FIELDS, "table_name");
// Regenerable -> raw tables.
await createNonceStore(SEEN_NONCES_TABLE);
await createPlans();
await createRollback();
await createApplyLock();
await dropLegacyPeerState();
};
module.exports = {
createAllTables
};

308
lib/siteLock.js Normal file
View file

@ -0,0 +1,308 @@
// The site-wide apply lock: at most ONE apply or rollback runs on a site (tenant)
// at a time, across every cluster worker and every server sharing the database.
//
// The lock is one row of the raw _ss_apply_lock table (per tenant schema), held
// under a LEASE that the holder renews RENEWALS_PER_LEASE times per LEASE_MS while
// it works, so the lock of a holder that stops (crash, kill, restart) lapses within
// LEASE_MS. Acquiring is one atomic statement each way -- INSERT ... ON CONFLICT DO
// NOTHING while the lock is free, else an UPDATE that only matches a lapsed lease --
// so of any number of racing callers exactly one wins.
//
// Every apply and rollback runs under the lock (apply.applyPlan,
// rollback.rollbackApply), so its holder knows no other apply or rollback is
// running: a plan still marked applying / rolling_back was abandoned by a holder
// that stopped. acquire() therefore returns each such plan to the status it was
// claimed from (_ss_plans.claimed_from), exactly as a failed apply or rollback
// restores it: an interrupted apply is pending again (its restore point rolls back
// what it changed), an interrupted rollback can be retried.
//
// A holder that stalls past its lease (event loop blocked, database unreachable)
// can lose the lock to another caller. It notices at its next checkpoint
// (assertHeld) and stops without writing plan statuses again: the caller that took
// over has already recovered them.
//
// Lease times are read from the DATABASE's clock (dbNowMs), never a server's own,
// so servers sharing the database agree on when a lease lapses even when their
// clocks disagree.
const db = require("@saltcorn/data/db");
const planLib = require("./plan");
const { randomUuid } = require("./ids");
const { PLAN_STATUS, APPLY_LOCK_TABLE } = require("./constants");
// The one row per tenant schema.
const LOCK_NAME = "apply";
// How long a holder's lock outlives its last renewal: the wait before a stopped
// holder's plans can be recovered.
const LEASE_MS = 60 * 1000;
// Renewals per lease: a holder only loses the lock after missing all but the last.
const RENEWALS_PER_LEASE = 4;
// SQLite reports a write it could not start within its busy timeout (another
// process is writing -- usually the running apply) as SQLITE_BUSY: retry the
// claim this many times, this far apart, before refusing.
const SQLITE_BUSY = "SQLITE_BUSY";
const BUSY_RETRIES = 3;
const BUSY_RETRY_MS = 250;
// The plan statuses only a running apply / rollback holds, and where an abandoned
// one returns when it has no claimed_from (claimed before 0.0.4): an apply is
// pending again, a rollback applied again (so it can be retried).
const RECOVERY = [
{ inFlight: PLAN_STATUS.APPLYING, fallback: PLAN_STATUS.PENDING },
{ inFlight: PLAN_STATUS.ROLLING_BACK, fallback: PLAN_STATUS.APPLIED }
];
const IN_FLIGHT = RECOVERY.map((r) => r.inFlight);
// How the lock stands for an admin (operations.applyLockView): nothing running, an
// apply or rollback running, or plans left in flight by a process that stopped.
const LOCK_STATES = {
IDLE: "idle",
RUNNING: "running",
INTERRUPTED: "interrupted"
};
class LockBusyError extends Error {
constructor(message, holder) {
super(message);
this.name = "LockBusyError";
this.holder = holder;
}
}
class LockLostError extends Error {
constructor(message) {
super(message);
this.name = "LockLostError";
}
}
const lockRef = () => `${db.getTenantSchemaPrefix()}${APPLY_LOCK_TABLE}`;
// SQL for the database clock's current time in epoch milliseconds.
const dbNowMs = () => {
return db.isSQLite ? "CAST((julianday('now') - 2440587.5) * 86400000 AS INTEGER)" : "CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)";
};
// Take the lock row for `holder`: insert it while the lock is free, else take over
// a lapsed lease. True only for the one caller that won. (Placeholders stay in
// order of appearance: SQLite numbers `$n` parameters as it meets them.)
const claimRow = async (ref, holder, operation, planId, leaseMs) => {
const values = [holder, operation, planId, new Date().toISOString(), leaseMs];
const inserted = await db.query(
`INSERT INTO ${ref} (holder, operation, plan_id, acquired_at, expires_ms, lock_name) VALUES ($1,$2,$3,$4,${dbNowMs()} + $5,$6) ` +
`ON CONFLICT DO NOTHING RETURNING holder`,
values.concat([LOCK_NAME])
);
if (inserted.rows.length === 1) {
return true;
}
const taken = await db.query(
`UPDATE ${ref} SET holder = $1, operation = $2, plan_id = $3, acquired_at = $4, expires_ms = ${dbNowMs()} + $5 ` +
`WHERE lock_name = $6 AND expires_ms < ${dbNowMs()} RETURNING holder`,
values.concat([LOCK_NAME])
);
return taken.rows.length === 1;
};
// claimRow, riding out SQLITE_BUSY: false as soon as a live holder can be read
// (reads still work while another process writes), a retry otherwise, and
// LockBusyError when the database stays busy.
const claimRowPatiently = async (ref, holder, operation, planId, leaseMs) => {
for (let attempt = 0; ; attempt++) {
try {
return await claimRow(ref, holder, operation, planId, leaseMs);
} catch (e) {
if (e.code !== SQLITE_BUSY) {
throw e;
}
const state = await lockState().catch(() => null);
if (state && state.live) {
return false;
}
if (attempt >= BUSY_RETRIES) {
throw new LockBusyError("the site database is busy; try again in a moment", null);
}
await new Promise((resolve) => setTimeout(resolve, BUSY_RETRY_MS));
}
}
};
// Return every plan left in flight to the status it was claimed from. Only safe
// while holding the lock (no apply or rollback can be running then).
const recoverAbandoned = async () => {
const recovered = [];
for (const r of RECOVERY) {
const rs = await db.query(
`UPDATE ${db.getTenantSchemaPrefix()}_ss_plans SET status = COALESCE(claimed_from, $1) WHERE status = $2 RETURNING plan_id, status`,
[r.fallback, r.inFlight]
);
for (const row of rs.rows) {
recovered.push({ plan_id: row.plan_id, was: r.inFlight, status: row.status });
}
}
return recovered;
};
// The held lock: renewed in the background until released.
const makeLease = (ref, holder, leaseMs) => {
let lost = false;
const renew = async () => {
if (lost) {
return false;
}
const rs = await db.query(
`UPDATE ${ref} SET expires_ms = ${dbNowMs()} + $1 WHERE lock_name = $2 AND holder = $3 RETURNING holder`,
[leaseMs, LOCK_NAME, holder]
);
lost = rs.rows.length === 0;
return !lost;
};
// A failed renewal (database hiccup) is retried on the next tick; only finding
// the row gone or taken over loses the lock.
const timer = setInterval(() => {
renew().catch(() => {});
}, Math.floor(leaseMs / RENEWALS_PER_LEASE));
timer.unref();
return {
recovered: [],
renew,
assertHeld: async () => {
if (!(await renew())) {
throw new LockLostError(`lost the site lock after stalling longer than its ${leaseMs / 1000}s lease, so stopped here; another apply or rollback may have taken over and recovered this plan`);
}
},
release: async () => {
clearInterval(timer);
// A release that fails lapses with the lease instead.
await db.query(`DELETE FROM ${ref} WHERE lock_name = $1 AND holder = $2`, [LOCK_NAME, holder]).catch(() => {});
}
};
};
// Who holds the lock, read-only: null when free; else the operation, its plan,
// when it started, when its lease runs out, and live (false: its holder stopped
// renewing, so the next acquire takes over).
const lockState = async () => {
const rs = await db.query(`SELECT *, ${dbNowMs()} AS db_now_ms FROM ${lockRef()} WHERE lock_name = $1`, [LOCK_NAME]);
if (rs.rows.length === 0) {
return null;
}
const row = rs.rows[0];
const expires = Number(row.expires_ms);
return {
operation: row.operation,
plan_id: row.plan_id,
acquired_at: row.acquired_at,
expires_at: new Date(expires).toISOString(),
live: expires >= Number(row.db_now_ms)
};
};
const busyMessage = (holder) => {
if (!holder) {
return "another apply or rollback was just finishing on this site; try again";
}
return `another ${holder.operation} is running on this site (plan ${holder.plan_id}, since ${holder.acquired_at}); ` +
`try again once it has finished. If the process running it stopped, its lock lapses by ${holder.expires_at} ` +
`and the next apply or rollback recovers that plan.`;
};
// Acquire the lock for `operation` ("apply" / "rollback") of planId, or throw
// LockBusyError. The returned lease lists the abandoned plans it recovered.
const acquire = async ({ operation, planId, leaseMs = LEASE_MS }) => {
const ref = lockRef();
const holder = randomUuid();
const won = await claimRowPatiently(ref, holder, operation, planId || null, leaseMs);
if (!won) {
const state = await lockState();
throw new LockBusyError(busyMessage(state), state);
}
const lease = makeLease(ref, holder, leaseMs);
try {
lease.recovered = await recoverAbandoned();
} catch (e) {
await lease.release();
throw e;
}
return lease;
};
// Run fn(lease) holding the lock. With `lease` (a step of an operation that holds
// it already, such as the apply inside a rollback) fn runs under that lease, with
// nothing recovered of its own.
const withSiteLock = async ({ operation, planId, lease }, fn) => {
if (lease) {
return await fn(Object.assign({}, lease, { recovered: [] }));
}
const held = await acquire({ operation, planId });
try {
return await fn(held);
} finally {
await held.release();
}
};
// Run fn() for a plan this caller claimed (moved to an in-flight status). On
// success the plan moves to `done`, on failure back to `prior` -- unless the lock
// was lost meanwhile: the caller that took it over has recovered the plan already
// and may be working on it. A failure carries `planId`.
const finishClaimed = async (lease, planId, { prior, done }, fn) => {
try {
const result = await fn();
await lease.assertHeld();
await planLib.setPlanStatus(planId, done);
return result;
} catch (e) {
if (!(e instanceof LockLostError) && (await lease.renew().catch(() => false))) {
await planLib.setPlanStatus(planId, prior);
}
e.planId = planId;
throw e;
}
};
// Where an in-flight plan goes when recovered (row: status + claimed_from).
const recoveredStatus = (row) => {
const r = RECOVERY.find((x) => x.inFlight === row.status);
return r ? row.claimed_from || r.fallback : row.status;
};
// Warnings telling the caller which abandoned plans its lease recovered.
const recoveryWarnings = (lease) => {
return lease.recovered.map((r) => {
const next = r.was === PLAN_STATUS.APPLYING
? "this site may be partly changed by it: roll it back from its restore point, or apply it again"
: "its rollback can be retried";
return `plan ${r.plan_id} was left ${r.was} by a process that stopped part-way; it is ${r.status} again (${next})`;
});
};
module.exports = {
IN_FLIGHT,
LOCK_STATES,
LockBusyError,
LockLostError,
acquire,
withSiteLock,
finishClaimed,
lockState,
recoveredStatus,
recoveryWarnings
};

95
lib/transport.js Normal file
View file

@ -0,0 +1,95 @@
// Outbound signed peer requests -- site-sync's binding of the shared client
// (lib/common/peerClient.js, which documents the protocol: every response
// signature is verified BEFORE the body is parsed or used, bodies are byte-capped
// and every exchange is bounded by a timeout). The target side mutates itself
// from what a pull returns, so an unauthenticated response must never be acted on.
const { createPeerClient, parseJson } = require("./common/peerClient");
const { HEADERS, PEER_CONTENT_TYPE } = require("./constants");
const { peerSecret } = require("./peers");
// The machine API (routes.js registers these; index.js exempts the prefix from CSRF).
const API_PREFIX = "/site-sync/api/";
const API_PATHS = {
HEALTH: `${API_PREFIX}health`,
STATE: `${API_PREFIX}state`,
PLAN: `${API_PREFIX}plan`,
APPLY: `${API_PREFIX}apply`,
FILE_ROUTE: `${API_PREFIX}file/:hash`,
file: (hash) => `${API_PREFIX}file/${hash}`
};
// Wire limits shared by both directions of the peer protocol.
const PEER_LIMITS = {
// Whole-request timeout for an outbound peer call (headers + body).
TIMEOUT_MS: 2 * 60 * 1000,
// Largest signed request body a peer will accept (a pushed desired-state pack).
REQUEST_BODY_BYTES: 64 * 1024 * 1024,
// Largest JSON response a client will read (/api/state: pack + row manifest).
JSON_RESPONSE_BYTES: 128 * 1024 * 1024,
// Largest file served / fetched over GET /api/file/:hash.
FILE_BYTES: 256 * 1024 * 1024
};
const client = createPeerClient({ headers: HEADERS, contentType: PEER_CONTENT_TYPE });
// The one signed round trip (see lib/common/peerClient.js signedRequest). The
// timeout is read per call so it can be tuned at runtime (tests shorten it).
const signedRequest = async (opts) => {
return await client.signedRequest({ ...opts, timeoutMs: PEER_LIMITS.TIMEOUT_MS });
};
// Shared per-peer wiring: the paired secret + TLS flag come from the peer row.
const requestPeer = async (peerRow, sourceEnvId, method, path, body, maxBytes) => {
return await signedRequest({
baseUrl: peerRow.base_url,
method: method,
path: path,
body: body,
sourceEnvId: sourceEnvId,
secret: await peerSecret(peerRow.peer_id),
requireTls: peerRow.require_tls,
maxBytes: maxBytes
});
};
// Signed JSON call to a paired peer. Returns the parsed body of a 2xx response;
// throws on a non-2xx status (with the peer's signed error message), on a body
// that is not JSON, and when a response naming an env_id names a different one
// than the paired peer's (the responder is not who this pairing says it is).
const peerJson = async (peerRow, sourceEnvId, method, path, body) => {
const r = await requestPeer(peerRow, sourceEnvId, method, path, body, PEER_LIMITS.JSON_RESPONSE_BYTES);
const parsed = parseJson(r);
if (!r.ok) {
throw new Error(`peer ${path} returned status ${r.status}: ${parsed && parsed.error ? parsed.error : "(no error message)"}`);
}
if (parsed && parsed.env_id !== undefined && parsed.env_id !== peerRow.env_id) {
throw new Error(`peer ${path} answered as env_id ${parsed.env_id}, but this pairing is for ${peerRow.env_id}`);
}
return parsed;
};
// Signed binary GET from a paired peer (GET /site-sync/api/file/:hash). Returns the
// verified bytes of a 2xx response; throws otherwise.
const peerBytes = async (peerRow, sourceEnvId, path) => {
const r = await requestPeer(peerRow, sourceEnvId, "GET", path, null, PEER_LIMITS.FILE_BYTES);
if (!r.ok) {
throw new Error(`peer ${path} returned status ${r.status}`);
}
return r.bytes;
};
module.exports = {
API_PREFIX,
API_PATHS,
PEER_LIMITS,
signedRequest,
peerJson,
peerBytes
};

27
package.json Normal file
View file

@ -0,0 +1,27 @@
{
"name": "site-sync",
"version": "0.0.4",
"description": "Saltcorn plugin: one-way desired-state synchronization. Snapshot a source site's current design state, diff it against a target site, preview a plan, and apply the difference so the target converges to the source. No edit log. Reuses Saltcorn's own pack/backup machinery for serialization and install; HMAC-authenticated peer endpoints; user data protected via per-table data modes; the users table is hard-locked.",
"main": "index.js",
"files": [
"index.js",
"lib/",
"README.md",
"docs/"
],
"scripts": {
"test": "test/run.sh"
},
"keywords": [
"deploy",
"desired-state",
"site-sync",
"hmac",
"pack"
],
"engines": {
"node": ">=20"
},
"author": "Scott Duensing",
"license": "MIT"
}

529
test/applyLock.js Normal file
View file

@ -0,0 +1,529 @@
// site-sync apply lock (lib/siteLock.js): one apply or rollback per site at a
// time across PROCESSES, and recovery of the plans a stopped process left in
// flight. In-process checks of the lock itself, then real worker processes: they
// race different plans at once, and one is SIGKILLed mid-apply.
//
// ./test/run.sh applyLock.js (SQLite)
// ./test/runPg.sh applyLock.js (Postgres, plus the per-tenant check)
//
// SS_LOCK_LIB may point the lib modules at a pre-lock copy to prove the process
// checks fail there; only those run then (the in-process checks need the lock).
const childProcess = require("child_process");
const path = require("path");
const { getState } = require("@saltcorn/data/db/state");
const Plugin = require("@saltcorn/data/models/plugin");
const { db, ok, section, step, summary, installPlugin, bootstrapPlugin, mkRes, mkAdminReq, routeOf } = require("./harness");
const PROOF = !!process.env.SS_LOCK_LIB;
const LIB = process.env.SS_LOCK_LIB || path.resolve(__dirname, "..", "lib");
const capture = require(path.join(LIB, "capture"));
const planLib = require(path.join(LIB, "plan"));
const applyLib = require(path.join(LIB, "apply"));
const rollback = require(path.join(LIB, "rollback"));
const ops = require(path.join(LIB, "operations"));
const env = require(path.join(LIB, "env"));
const siteLock = PROOF ? null : require(path.join(LIB, "siteLock"));
const routes = PROOF ? null : require(path.join(LIB, "routes"));
const { physicalColumns } = require(path.join(LIB, "common", "registeredTable"));
const { APPLY_LOCK_TABLE } = require(path.resolve(__dirname, "..", "lib", "constants"));
const WORKER_FLAG = "--worker";
const WORKERS = 4;
// Every worker has booted by then, so all start together.
const START_DELAY_MS = 8000;
// How long a worker's install_pack is held up: the window the others race into.
const SLOW_MS = 2000;
// A lease short enough to outlive several times within a check.
const SHORT_LEASE_MS = 400;
// How far this process's clock is put ahead to show it is not the lease clock.
const CLOCK_SKEW_MS = 60 * 60 * 1000;
const STALLED = "STALLED";
const RESULT = "RESULT ";
// A refusal by the lock: naming the holder, or (SQLite under load) the database
// staying busy while the holder writes. Either way nothing was changed.
const BUSY = /is running on this site|the site database is busy/;
const BASE_SITE_NAME = "apply-lock-base";
const TENANT = "locktenant";
const sleep = (ms) => {
return new Promise((resolve) => setTimeout(resolve, ms));
};
const outcome = async (fn) => {
try {
return { value: await fn() };
} catch (e) {
return { error: e };
}
};
const plansRef = () => `${db.getTenantSchemaPrefix()}_ss_plans`;
// What LEASE_MS passing does to a lock whose holder stopped renewing it.
const ageLease = async () => {
await db.query(`UPDATE ${db.getTenantSchemaPrefix()}${APPLY_LOCK_TABLE} SET expires_ms = 0`);
};
const planStatus = async (planId) => {
return (await planLib.loadPlan(planId)).status;
};
const setStatus = async (planId, status, claimedFrom) => {
await db.query(`UPDATE ${plansRef()} SET status = $1, claimed_from = $2 WHERE plan_id = $3`, [status, claimedFrom, planId]);
};
const dropPlans = async (ids) => {
for (const id of ids) {
await db.query(`DELETE FROM ${plansRef()} WHERE plan_id = $1`, [id]);
}
};
// A plan, previewed against this site as it is now, that only renames the site.
const variantPlan = async (siteName) => {
const desired = JSON.parse(JSON.stringify((await capture.buildDesiredState()).pack));
desired.config.site_name = siteName;
const built = await planLib.buildPlan({
peerRow: null, env: await env.getEnv(), desired, desiredHash: capture.hashPack(desired),
sourceEnvId: "src", sourceRows: {}
});
return built.plan_id;
};
// ---------------- worker processes ----------------
// A worker: boot like a server process, hold up (or, for "stall", never finish)
// install_pack, then at startAt run one apply or rollback through the admin
// operations and print its outcome.
const worker = async () => {
const [task, planId, startAt, slowMs] = process.argv.slice(process.argv.indexOf(WORKER_FLAG) + 1);
await Plugin.loadAllPlugins();
const packMod = require("@saltcorn/admin-models/models/pack");
const realInstall = packMod.install_pack;
packMod.install_pack = async (...args) => {
if (task === "stall") {
process.stdout.write(STALLED + "\n");
return await new Promise(() => {});
}
await sleep(Number(slowMs));
return await realInstall(...args);
};
await sleep(Math.max(0, Number(startAt) - Date.now()));
const r = await outcome(() => (task === "rollback" ? ops.rollbackPlan(planId, {}) : ops.applyPlan(planId, {})));
process.stdout.write(RESULT + JSON.stringify(r.error ? { ok: false, error: r.error.message } : { ok: true, warnings: r.value.warnings }) + "\n");
process.exit(0);
};
// Start a worker: { child, done (resolves { code, signal, out }), waitFor(text) }.
// waitFor rejects if the worker exits without printing text.
const startWorker = (args) => {
let out = "";
let exited = null;
const waiters = [];
const child = childProcess.spawn(process.execPath, [__filename, WORKER_FLAG].concat(args), { env: process.env, stdio: ["ignore", "pipe", "inherit"] });
child.stdout.on("data", (d) => {
out += d;
for (const w of waiters.filter((x) => out.includes(x.text))) {
w.resolve();
}
});
const done = new Promise((resolve) => {
child.on("exit", (code, signal) => {
exited = { code, signal, out };
for (const w of waiters) {
w.reject(new Error(`worker exited (${code} ${signal || ""}) without printing ${w.text}: ${out.trim()}`));
}
resolve(exited);
});
});
const waitFor = (text) => new Promise((resolve, reject) => {
if (out.includes(text)) {
resolve();
} else if (exited) {
reject(new Error(`worker exited without printing ${text}: ${out.trim()}`));
} else {
waiters.push({ text, resolve, reject });
}
});
return { child, done, waitFor };
};
const resultOf = (run) => {
const line = run.out.split("\n").find((l) => l.startsWith(RESULT));
return line ? JSON.parse(line.slice(RESULT.length)) : { ok: false, error: `no result (exit ${run.code} ${run.signal || ""})` };
};
// Reload this process's cached Saltcorn state after workers changed the database.
const resync = async () => {
await getState().refresh(true);
};
// WORKERS workers each run `task` at the same moment, worker i on planIds[i].
const raceWorkers = async (task, planIds) => {
const startAt = Date.now() + START_DELAY_MS;
const runs = await Promise.all(planIds.map((id) => startWorker([task, id, String(startAt), String(SLOW_MS)]).done));
await resync();
return runs.map(resultOf);
};
const outcomesText = (results) => JSON.stringify(results.map((r) => (r.ok ? "ok" : r.error)));
// ---------------- in-process checks ----------------
const schemaChecks = async () => {
section("schema");
await step("the lock table and _ss_plans.claimed_from exist", async () => {
const cols = await physicalColumns(APPLY_LOCK_TABLE);
ok("lock table columns", ["lock_name", "holder", "operation", "plan_id", "acquired_at", "expires_ms"].every((c) => cols.has(c)), [...cols].join(","));
ok("_ss_plans.claimed_from", (await physicalColumns("_ss_plans")).has("claimed_from"));
});
await step("upgrade: a pre-0.0.4 _ss_plans gains claimed_from, even with workers booting together", async () => {
await db.query(`ALTER TABLE ${plansRef()} DROP COLUMN claimed_from`);
ok("column dropped (as created by 0.0.3)", !(await physicalColumns("_ss_plans")).has("claimed_from"));
const { createAllTables } = require(path.join(LIB, "schema"));
const r = await outcome(() => Promise.all([createAllTables(), createAllTables()]));
ok("two concurrent onLoad schema passes both succeed", !r.error, r.error && r.error.message);
ok("claimed_from added back", (await physicalColumns("_ss_plans")).has("claimed_from"));
});
};
const lockChecks = async () => {
section("the lock");
await step("acquire, refuse a second holder, release", async () => {
const a = await siteLock.acquire({ operation: "apply", planId: "plan-a" });
const st = await siteLock.lockState();
ok("held: live, naming the operation and plan", !!st && st.live && st.operation === "apply" && st.plan_id === "plan-a");
const b = await outcome(() => siteLock.acquire({ operation: "rollback", planId: "plan-b" }));
ok("a second acquire is refused with LockBusyError", b.error instanceof siteLock.LockBusyError, b.error && b.error.message);
ok("the refusal names what is running", !!b.error && /another apply is running on this site \(plan plan-a,/.test(b.error.message), b.error && b.error.message);
await a.release();
ok("released: free", (await siteLock.lockState()) === null);
const c = await siteLock.acquire({ operation: "apply", planId: "plan-c" });
ok("acquirable again", (await siteLock.lockState()).plan_id === "plan-c");
await c.release();
});
await step("a held lease is renewed: it outlives its length several times", async () => {
const a = await siteLock.acquire({ operation: "apply", planId: "renewed", leaseMs: SHORT_LEASE_MS });
await sleep(SHORT_LEASE_MS * 3);
const b = await outcome(() => siteLock.acquire({ operation: "apply", planId: "thief" }));
ok("still held after three lease lengths", b.error instanceof siteLock.LockBusyError);
ok("the holder still holds it", await a.renew());
await a.release();
});
await step("a lapsed lease is taken over, and its old holder finds out", async () => {
const a = await siteLock.acquire({ operation: "apply", planId: "stalled" });
await ageLease();
ok("lapsed: no longer live", (await siteLock.lockState()).live === false);
const b = await siteLock.acquire({ operation: "rollback", planId: "taker" });
ok("taken over", (await siteLock.lockState()).plan_id === "taker");
const lost = await outcome(() => a.assertHeld());
ok("the old holder's checkpoint throws LockLostError", lost.error instanceof siteLock.LockLostError);
ok("and it does not renew any more", (await a.renew()) === false);
await a.release();
ok("its release leaves the new holder's lock alone", (await siteLock.lockState()).plan_id === "taker");
await b.release();
});
await step("lease times come from the database clock, not this server's", async () => {
const a = await siteLock.acquire({ operation: "apply", planId: "clock-holder" });
const realNow = Date.now;
Date.now = () => realNow() + CLOCK_SKEW_MS;
try {
ok("a server whose clock runs an hour fast still sees the lease live", (await siteLock.lockState()).live === true);
const b = await outcome(() => siteLock.acquire({ operation: "apply", planId: "fast-clock" }));
ok("and cannot take it over", b.error instanceof siteLock.LockBusyError);
} finally {
Date.now = realNow;
}
await a.release();
});
await step("withSiteLock: a nested step reuses the lease; released on success and failure", async () => {
await siteLock.withSiteLock({ operation: "apply", planId: "outer" }, async (lease) => {
const nested = await siteLock.withSiteLock({ operation: "apply", planId: "inner", lease }, async (inner) => ({ holder: (await siteLock.lockState()).plan_id, recovered: inner.recovered.length }));
ok("a nested step runs under the outer lease", nested.holder === "outer" && nested.recovered === 0);
});
ok("released after success", (await siteLock.lockState()) === null);
const r = await outcome(() => siteLock.withSiteLock({ operation: "apply", planId: "boom" }, async () => {
throw new Error("boom");
}));
ok("the failure propagates", !!r.error && r.error.message === "boom");
ok("released after a failure", (await siteLock.lockState()) === null);
});
};
const recoveryChecks = async () => {
section("recovery of plans a stopped holder left in flight");
await step("claimPlanStatus records the status it claimed from", async () => {
const id = await variantPlan("claim");
await planLib.claimPlanStatus(id, ["pending"], "applying");
const p = await planLib.loadPlan(id);
ok("applying, claimed_from pending", p.status === "applying" && p.claimed_from === "pending");
await dropPlans([id]);
});
await step("acquiring the lock returns each in-flight plan to the status it was claimed from", async () => {
const ids = [];
for (const tag of ["a", "b", "c", "d"]) {
ids.push(await variantPlan(`recover-${tag}`));
}
await setStatus(ids[0], "applying", "rolled_back");
await setStatus(ids[1], "rolling_back", "pending");
await setStatus(ids[2], "applying", null);
await setStatus(ids[3], "rolling_back", null);
const lease = await siteLock.acquire({ operation: "apply", planId: "recoverer" });
ok("all four recovered", lease.recovered.length === 4 && ids.every((id) => lease.recovered.some((r) => r.plan_id === id)), JSON.stringify(lease.recovered));
ok("an apply claimed from rolled_back is rolled_back again", (await planStatus(ids[0])) === "rolled_back");
ok("a rollback claimed from pending is pending again", (await planStatus(ids[1])) === "pending");
ok("a 0.0.3 apply (no claimed_from) is pending again", (await planStatus(ids[2])) === "pending");
ok("a 0.0.3 rollback (no claimed_from) is applied again", (await planStatus(ids[3])) === "applied");
const warnings = siteLock.recoveryWarnings(lease);
ok("one warning per recovered plan, naming it", warnings.length === 4 && ids.every((id) => warnings.some((w) => w.includes(id))), warnings.join(" | "));
await lease.release();
await dropPlans(ids);
});
};
const standingChecks = async () => {
section("a plan left in flight is reported (read-only)");
await step("running while its lease is live, interrupted once nobody holds the lock", async () => {
const id = await variantPlan("standing");
const lease = await siteLock.acquire({ operation: "apply", planId: id });
await planLib.claimPlanStatus(id, ["pending"], "applying");
const running = await ops.status();
ok("status: running, naming the plan", running.applyLock.state === "running" && running.applyLock.holder.plan_id === id);
ok("get_plan: in flight, not interrupted", (await ops.inspectPlan(id)).standing.interrupted === false);
await lease.release();
const st = await ops.status();
ok("status: interrupted, listing the plan", st.applyLock.state === "interrupted" && st.applyLock.plans.some((p) => p.plan_id === id && p.status === "applying"));
const standing = (await ops.inspectPlan(id)).standing;
ok("get_plan: interrupted, and appliable once recovered (pending, site unchanged)", standing.interrupted === true && standing.can_apply === true && standing.stale === false, JSON.stringify(standing));
const listed = (await ops.listPlans({})).find((p) => p.plan_id === id);
ok("list_plans agrees", !!listed && listed.interrupted === true && listed.status === "applying");
const res = mkRes();
await routeOf(routes.routes, "get", "/admin/site-sync/")(mkAdminReq(), res);
const html = JSON.stringify(res._wrap || {});
ok("the dashboard shows an interrupted card naming the plan", html.includes("Interrupted apply or rollback") && html.includes(id));
ok("reading changed nothing", (await planStatus(id)) === "applying");
const recoverer = await siteLock.acquire({ operation: "apply", planId: "recoverer" });
ok("the next acquire recovers it", recoverer.recovered.some((r) => r.plan_id === id) && (await planStatus(id)) === "pending");
await recoverer.release();
ok("status: idle", (await ops.status()).applyLock.state === "idle");
await dropPlans([id]);
});
};
const lostLeaseChecks = async () => {
section("a holder that loses the lock stops, leaving the plan to the new holder");
await step("lock taken over during install_pack: the apply stops at its next checkpoint", async () => {
const id = await variantPlan("lost");
const packMod = require("@saltcorn/admin-models/models/pack");
const realInstall = packMod.install_pack;
let thief = null;
packMod.install_pack = async (...args) => {
packMod.install_pack = realInstall;
await ageLease();
thief = await siteLock.acquire({ operation: "apply", planId: "thief" });
return await realInstall(...args);
};
let r;
try {
r = await outcome(() => applyLib.applyPlan(id, {}));
} finally {
packMod.install_pack = realInstall;
}
ok("the apply stopped with LockLostError", r.error instanceof siteLock.LockLostError, r.error ? r.error.message : "it finished");
ok("the error names the plan", !!r.error && r.error.planId === id);
ok("the new holder recovered the plan", !!thief && thief.recovered.some((x) => x.plan_id === id));
ok("its status is what the new holder made it (not overwritten)", (await planStatus(id)) === "pending");
ok("the new holder still holds the lock", (await siteLock.lockState()).plan_id === "thief");
await thief.release();
const rb = await rollback.rollbackApply(id, {});
ok("the half-applied plan rolls back from its restore point", rb.rolledBack === true && getState().getConfig("site_name") === BASE_SITE_NAME);
});
};
const opsChecks = async () => {
section("the admin operations while the lock is held");
await step("apply and rollback are refused cleanly, then work once it is free", async () => {
const id = await variantPlan("ops");
const lease = await siteLock.acquire({ operation: "rollback", planId: "busy-holder" });
const a = await outcome(() => ops.applyPlan(id, {}));
ok("apply: OpError naming the running rollback", a.error instanceof ops.OpError && /another rollback is running on this site \(plan busy-holder,/.test(a.error.message), a.error && a.error.message);
ok("no restore-point offer (nothing changed)", !!a.error && a.error.extra === undefined);
const b = await outcome(() => ops.rollbackPlan(id, {}));
ok("rollback: refused the same way", b.error instanceof ops.OpError && BUSY.test(b.error.message));
ok("the plan was not touched", (await planStatus(id)) === "pending");
await lease.release();
const c = await ops.applyPlan(id, {});
ok("applies once the lock is free", c.applied === true && getState().getConfig("site_name") === "ops");
await ops.rollbackPlan(id, {});
ok("and rolls back", getState().getConfig("site_name") === BASE_SITE_NAME);
});
};
// ---------------- process checks ----------------
const raceChecks = async () => {
section(`${WORKERS} processes apply ${WORKERS} different plans at the same moment`);
await step("exactly one runs; the others are refused before changing anything", async () => {
const ids = [];
for (let i = 0; i < WORKERS; i++) {
ids.push(await variantPlan(`race-${i}`));
}
const results = await raceWorkers("apply", ids);
ok("one apply ran", results.filter((r) => r.ok).length === 1, outcomesText(results));
ok("every other one was refused by the lock", results.filter((r) => !r.ok && BUSY.test(r.error)).length === WORKERS - 1, outcomesText(results));
const statuses = [];
for (const id of ids) {
statuses.push(await planStatus(id));
}
ok("one plan applied, the rest still pending", statuses.filter((s) => s === "applied").length === 1 && statuses.filter((s) => s === "pending").length === WORKERS - 1, statuses.join(","));
if (!PROOF) {
ok("the lock is free again", (await siteLock.lockState()) === null);
}
for (const id of ids.filter((x, i) => statuses[i] === "applied")) {
await rollback.rollbackApply(id, {});
}
await dropPlans(ids);
});
};
const crashChecks = async () => {
section("a process killed mid-apply");
await step("its plan is recovered once its lease lapses, by exactly one of several racing rollbacks", async () => {
const before = (await capture.buildDesiredState()).hash;
const x = await variantPlan("crash-x");
const y = await variantPlan("crash-y");
const w = startWorker(["stall", x, "0", "0"]);
await w.waitFor(STALLED);
w.child.kill("SIGKILL");
const killed = await w.done;
ok("the worker was killed inside install_pack", killed.signal === "SIGKILL");
ok("its plan is left applying", (await planStatus(x)) === "applying");
if (!PROOF) {
const st = await siteLock.lockState();
ok("its lock is still held under a live lease", !!st && st.live && st.plan_id === x);
const busy = await outcome(() => ops.applyPlan(y, {}));
ok("until the lease lapses other applies are refused, naming it", !!busy.error && busy.error.message.includes(`another apply is running on this site (plan ${x},`), busy.error && busy.error.message);
ok("the dashboard cannot tell yet: running", (await ops.status()).applyLock.state === "running");
await ageLease();
const lapsed = await ops.status();
ok("lease lapsed: the dashboard reports it interrupted, with its restore point", lapsed.applyLock.state === "interrupted" && lapsed.applyLock.plans.some((p) => p.plan_id === x && p.has_restore_point));
const standing = (await ops.inspectPlan(x)).standing;
ok("get_plan: interrupted, can roll back", standing.interrupted === true && standing.can_rollback === true, JSON.stringify(standing));
}
const results = await raceWorkers("rollback", Array(WORKERS).fill(x));
ok("exactly one rollback ran", results.filter((r) => r.ok).length === 1, outcomesText(results));
ok("the others were refused by the lock", results.filter((r) => !r.ok && BUSY.test(r.error)).length === WORKERS - 1, outcomesText(results));
const winner = results.find((r) => r.ok);
ok("the rollback reports the recovery", !!winner && winner.warnings.some((m) => m.includes(x) && /left applying by a process that stopped/.test(m)), winner && JSON.stringify(winner.warnings));
ok("the plan is rolled back", (await planStatus(x)) === "rolled_back");
ok("this site is as it was before the killed apply", (await capture.buildDesiredState()).hash === before);
if (!PROOF) {
ok("nothing in flight, lock free", (await ops.status()).applyLock.state === "idle" && (await siteLock.lockState()) === null);
}
const applied = await outcome(() => ops.applyPlan(y, {}));
ok("the plan the kill blocked now applies", !applied.error && applied.value.applied === true, applied.error && applied.error.message);
if (!applied.error) {
await ops.rollbackPlan(y, {});
}
await dropPlans([x, y]);
});
};
// ---------------- tenants (Postgres) ----------------
const tenantChecks = async () => {
if (db.isSQLite) {
return;
}
section("tenants have their own locks (Postgres)");
const { add_tenant } = require("@saltcorn/data/db/state");
const { create_tenant, switchToTenant, insertTenant } = require("@saltcorn/admin-models/models/tenant");
await step("a held root lock does not block a tenant; the tenant's lease renews in its own schema", async () => {
add_tenant(TENANT);
await switchToTenant(await insertTenant(TENANT, `admin@${TENANT}.local`, "apply lock tenant check"), `http://${TENANT}.example.com/`);
await create_tenant({ t: TENANT, loadAndSaveNewPlugin() {}, plugin_loader() {} });
const rootLease = await siteLock.acquire({ operation: "apply", planId: "root-holder" });
await db.runWithTenant(TENANT, async () => {
await Plugin.loadAllPlugins();
await db.runWithTenant(db.connectObj.default_schema, () => getState().setConfig("tenants_unsafe_plugins", true));
await installPlugin();
ok("running in the tenant schema", db.getTenantSchema() === TENANT);
ok("the tenant's lock is free", (await siteLock.lockState()) === null);
const id = await variantPlan("tenant-plan");
const r = await ops.applyPlan(id, {});
ok("the tenant applies while the root lock is held", r.applied === true);
const tenantLease = await siteLock.acquire({ operation: "apply", planId: "tenant-holder", leaseMs: SHORT_LEASE_MS });
await sleep(SHORT_LEASE_MS * 3);
const st = await siteLock.lockState();
ok("the tenant's short lease was renewed in the tenant schema", !!st && st.live && st.plan_id === "tenant-holder");
await db.runWithTenant(db.connectObj.default_schema, async () => {
ok("the root sees only its own holder", (await siteLock.lockState()).plan_id === "root-holder");
});
await tenantLease.release();
});
await rootLease.release();
});
};
const main = async () => {
const where = db.isSQLite ? `sqlite ${process.env.SQLITE_FILEPATH}` : `postgres ${db.connectObj.database} schema ${db.getTenantSchema()}`;
console.log(`DB = ${where} LIB = ${LIB}${PROOF ? " (proof mode: process checks only)" : ""}`);
await bootstrapPlugin();
await getState().setConfig("site_name", BASE_SITE_NAME);
if (!PROOF) {
await schemaChecks();
await lockChecks();
await recoveryChecks();
await standingChecks();
await lostLeaseChecks();
await opsChecks();
}
await raceChecks();
await crashChecks();
if (!PROOF) {
await tenantChecks();
}
process.exit(summary() === 0 ? 0 : 1);
};
if (process.argv.includes(WORKER_FLAG)) {
worker();
} else {
// main() ends with process.exit; an event loop that drains first means a check
// waited on something that can no longer happen -- a failure, never a pass.
process.on("beforeExit", () => {
console.log("\nFAILURES: the run ended before finishing its checks");
process.exitCode = 1;
});
main();
}

103
test/compatVectors.js Normal file
View file

@ -0,0 +1,103 @@
// Compatibility proof for site-sync's peer-secret sealing and peer signatures.
//
// compatVectors.json was recorded from earlier site-sync code with a fixed FAKE
// session secret. This suite asserts that lib/common/ (and site-sync's own peers
// store) still derives the same key-encryption key, opens a peer secret sealed by
// that code, seals what the old algorithm (plain AES-256-GCM with the recorded key)
// opens, and produces / verifies the same HMAC request and response signatures --
// so stored peer secrets and existing pairings keep working across upgrades.
//
// node test/compatVectors.js
//
// Exit code is 0 only if every check passed.
const assert = require("node:assert/strict");
const crypto = require("node:crypto");
const fs = require("node:fs");
const path = require("node:path");
const LIB = path.resolve(__dirname, "..", "lib");
const vectors = JSON.parse(fs.readFileSync(path.join(__dirname, "compatVectors.json"), "utf8"));
process.env.SALTCORN_SESSION_SECRET = vectors.sessionSecret;
const { deriveKey } = require(path.join(LIB, "common", "sessionKey"));
const { createSealer } = require(path.join(LIB, "common", "aesGcm"));
const protocol = require(path.join(LIB, "common", "peerProtocol"));
const peers = require(path.join(LIB, "peers"));
const OWNER = "compatVectors";
const TAG_BYTES = 16;
// Hex digits kept when a test truncates a 16-byte tag.
const SHORT_TAG_HEX = 24;
let passed = 0;
let failed = 0;
const check = (name, fn) => {
try {
fn();
passed++;
console.log(` PASS ${name}`);
} catch (e) {
failed++;
console.log(` FAIL ${name}\n ${e && e.message ? e.message.split("\n")[0] : e}`);
}
};
// The OLD algorithm, independently: AES-256-GCM with the recorded key.
const legacyOpenHex = (keyHex, sealed) => {
const decipher = crypto.createDecipheriv("aes-256-gcm", Buffer.from(keyHex, "hex"), Buffer.from(sealed.iv, "hex"), { authTagLength: TAG_BYTES });
decipher.setAuthTag(Buffer.from(sealed.tag, "hex"));
return Buffer.concat([decipher.update(Buffer.from(sealed.ciphertext, "hex")), decipher.final()]);
};
const kek = vectors.keys.siteSyncPeerKek;
const sealer = createSealer({ owner: OWNER, salt: kek.salt, info: kek.info });
const stored = vectors.sealed.siteSyncPeerSecret;
console.log("[peer-secret key-encryption key]");
check("HKDF key matches the recorded one", () => {
assert.equal(deriveKey({ owner: OWNER, salt: kek.salt, info: kek.info, bytes: kek.bytes }).toString("hex"), kek.keyHex);
});
check("the recorded sealed peer secret opens", () => {
assert.equal(sealer.openHex(stored.sealed).toString("hex"), stored.plaintextHex);
});
check("a truncated tag is rejected", () => {
assert.throws(() => sealer.openHex({ ...stored.sealed, tag: stored.sealed.tag.slice(0, SHORT_TAG_HEX) }));
});
check("a new seal opens with the old algorithm", () => {
assert.equal(legacyOpenHex(kek.keyHex, sealer.sealHex("round trip é")).toString("utf8"), "round trip é");
});
check("the peers store opens the recorded peer secret", () => {
const row = { peer_secret_ciphertext: stored.sealed.ciphertext, peer_secret_iv: stored.sealed.iv, peer_secret_tag: stored.sealed.tag };
assert.equal(peers.openSecret(row).toString("hex"), stored.plaintextHex);
});
console.log("\n[HMAC request + response signatures]");
const secret = Buffer.from(vectors.hmac.secretHex, "hex");
vectors.hmac.siteSync.requests.forEach((r, i) => {
check(`request #${i}: host, canonical and signature identical`, () => {
assert.equal(protocol.normalizeHost(r.targetHost), r.normalizedHost);
const canonical = protocol.buildCanonical({ ...r, targetHost: protocol.normalizeHost(r.targetHost) });
assert.equal(canonical, r.canonical);
assert.equal(protocol.sign(secret, canonical), r.signature);
assert.ok(protocol.verifySignature(secret, r.canonical, r.signature));
assert.ok(!protocol.verifySignature(secret, r.canonical, "00" + r.signature.slice(2)));
});
});
vectors.hmac.siteSync.responses.forEach((r, i) => {
check(`response #${i}: canonical and signature identical`, () => {
const response = { nonce: r.nonce, status: r.status, body: Buffer.from(r.bodyHex, "hex") };
assert.equal(protocol.buildResponseCanonical(response), r.canonical);
assert.equal(protocol.signResponse(secret, response), r.signature);
assert.ok(protocol.verifyResponseSignature(secret, response, r.signature));
assert.ok(!protocol.verifyResponseSignature(secret, { ...response, status: r.status + 1 }, r.signature));
});
});
console.log(`\n${passed} passed, ${failed} failed`);
process.exit(failed === 0 ? 0 : 1);

85
test/compatVectors.json Normal file
View file

@ -0,0 +1,85 @@
{
"comment": "Compatibility vectors recorded (by the 2026-10-15 consolidation's genVectors.js) from the plugin code BEFORE its at-rest/peer code was reorganized, with a FAKE session secret. Never regenerate: they prove values sealed or signed by earlier versions still work.",
"sessionSecret": "pluginCommon-FAKE-test-vector-session-secret-v1",
"keys": {
"siteSyncPeerKek": {
"salt": "site-sync:peer-secrets:aes-gcm-key:v1",
"info": "site-sync:peer-secrets:aes-gcm-key:v1",
"bytes": 32,
"keyHex": "2235e85431fc6b45766a8b7df3aa2d498b3de3fac3edba13770ae854d07283f1"
}
},
"sealed": {
"siteSyncPeerSecret": {
"plaintextHex": "ffeeddccbbaa99887766554433221100ffeeddccbbaa99887766554433221100",
"sealed": {
"ciphertext": "c30d83e6bcc55ef2accbcda6692b08a419b4970cbf55e0cf3dc204d2d9bd8ffb",
"iv": "0981a8ee93756a26d3fe2da6",
"tag": "dd8ee153b98ef8014ab3afa0c110cb0f"
}
}
},
"hmac": {
"secretHex": "a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5",
"siteSync": {
"requests": [
{
"timestamp": "1760000000000",
"nonce": "0123456789abcdef0123456789abcdef",
"method": "post",
"path": "/dev-deploy/api/ingest",
"targetHost": "Receiver.Test:443",
"body": "{\"ops\":[{\"op_id\":\"x\",\"name\":\"caf\u00e9\"}]}",
"normalizedHost": "receiver.test",
"canonical": "1760000000000\n0123456789abcdef0123456789abcdef\nPOST\n/dev-deploy/api/ingest\nreceiver.test\n19561c759eb8fe34a7488d923c6efeafe391c851142520a605f66112e90d0014",
"signature": "0e8130aa79c634edcd2f73d899d7ade75f90e202ab1d74e752de4d3762e19f5d"
},
{
"timestamp": "1760000000001",
"nonce": "fedcba9876543210fedcba9876543210",
"method": "GET",
"path": "/site-sync/api/state?x=1",
"targetHost": " t1.Example.COM:3001 ",
"body": "",
"normalizedHost": "t1.example.com:3001",
"canonical": "1760000000001\nfedcba9876543210fedcba9876543210\nGET\n/site-sync/api/state?x=1\nt1.example.com:3001\ne3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"signature": "e501b8584736fdc26e4e46b55d4135c7cc9e8e8c5f9a628b4f39547152f48815"
},
{
"timestamp": "1760000000002",
"nonce": "00000000000000000000000000000000",
"method": "GET",
"path": "/dev-deploy/api/health",
"targetHost": "host.example:80",
"body": "",
"normalizedHost": "host.example",
"canonical": "1760000000002\n00000000000000000000000000000000\nGET\n/dev-deploy/api/health\nhost.example\ne3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"signature": "41b910cad5748d358f3010c8cb432064e82a062f0836ef05bf3f2cf58782e52d"
}
],
"responses": [
{
"nonce": "0123456789abcdef0123456789abcdef",
"status": 200,
"bodyHex": "7b22656e765f6964223a2265222c226f6b223a747275657d",
"canonical": "resp\n0123456789abcdef0123456789abcdef\n200\nfbe91e6256eb1cb9578f8aeef012d376965f058d86992c0b46537ac36ff9cac5",
"signature": "3ff12daa9570064b5a5cca116ece8ed68945d486c38dee22da7d51dc9c533400"
},
{
"nonce": "fedcba9876543210fedcba9876543210",
"status": 401,
"bodyHex": "",
"canonical": "resp\nfedcba9876543210fedcba9876543210\n401\ne3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"signature": "5bb09193b258f65a509b093b3dc7223896e71d64a8083630785fd0247fd70554"
},
{
"nonce": "00000000000000000000000000000000",
"status": 200,
"bodyHex": "00fffe807fc328",
"canonical": "resp\n00000000000000000000000000000000\n200\n1296c9aa23d20a7a3ee8b87e556a404ea4b5092156257835f784b831d75eb25f",
"signature": "075978d453c1a4d94fb9c109676eb1c2f758037ba1b418c1f1bbc3258cb40910"
}
]
}
}
}

614
test/coreUnit.js Normal file
View file

@ -0,0 +1,614 @@
// site-sync CORE regression suite (capture/diff/plan/apply/rollback/deletes/rows/
// rowIdentity/rowPayload/layoutFiles). Each check pins a bug found in the
// 2026-10-15 re-analysis: it fails on the pre-fix code and passes after.
//
// ./test/run.sh coreUnit.js
//
// SS_CORE_LIB may point the lib modules at another copy (e.g. the pre-fix
// snapshot) to prove the checks fail there; the plugin itself is bootstrapped from
// this checkout either way.
const path = require("path");
const { getState } = require("@saltcorn/data/db/state");
const Table = require("@saltcorn/data/models/table");
const Field = require("@saltcorn/data/models/field");
const File = require("@saltcorn/data/models/file");
const View = require("@saltcorn/data/models/view");
const Page = require("@saltcorn/data/models/page");
const Tag = require("@saltcorn/data/models/tag");
const TableConstraint = require("@saltcorn/data/models/table_constraints");
const { db, ok, section, step, summary, installPlugin, bootstrapPlugin } = require("./harness");
const LIB = process.env.SS_CORE_LIB || path.resolve(__dirname, "..", "lib");
const capture = require(path.join(LIB, "capture"));
const diff = require(path.join(LIB, "diff"));
const planLib = require(path.join(LIB, "plan"));
const applyLib = require(path.join(LIB, "apply"));
const rollback = require(path.join(LIB, "rollback"));
const rowsLib = require(path.join(LIB, "rows"));
const rowId = require(path.join(LIB, "rowIdentity"));
const env = require(path.join(LIB, "env"));
// Smallest buffer the layout-file sniffer accepts as a PNG.
const PNG_BYTES = Buffer.concat([Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), Buffer.alloc(24, 7)]);
const mkTable = async (name, fields) => {
const t = await Table.create(name, { min_role_read: 1, min_role_write: 1 });
for (const f of fields) {
await Field.create(Object.assign({ table: t, label: f.name }, f));
}
return Table.findOne({ name });
};
const rowsOf = async (name) => {
return await Table.findOne({ name }).getRows({}, {});
};
// Names of the indexes covering a table's _ss_row_uuid column, in the current schema.
const uuidIndexes = async (tableName) => {
const physical = db.sqlsanitize(tableName);
const rs = db.isSQLite
? await db.query(`SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = $1 AND sql LIKE '%_ss_row_uuid%'`, [physical])
: await db.query(`SELECT indexname AS name FROM pg_indexes WHERE schemaname = $1 AND tablename = $2 AND indexdef LIKE '%_ss_row_uuid%'`, [db.getTenantSchema(), physical]);
return rs.rows.map((r) => r.name);
};
const current = async () => {
return JSON.parse(JSON.stringify((await capture.buildDesiredState()).pack));
};
const planFor = async (desired, sourceRows) => {
return await planLib.buildPlan({
peerRow: null, env: await env.getEnv(), desired, desiredHash: capture.hashPack(desired),
sourceEnvId: "src", sourceRows: sourceRows || {}
});
};
const outcome = async (fn) => {
try {
return { value: await fn() };
} catch (e) {
return { error: e };
}
};
const dropTableFromPack = (pack, name) => {
pack.tables = pack.tables.filter((t) => t.name !== name);
return pack;
};
const rollbackChecks = async () => {
section("rollback restores without duplicating, at most once");
await step("retained drop candidate is not duplicated by rollback", async () => {
const t = await mkTable("cu_orders", [{ name: "total", type: "Integer" }]);
for (const v of [10, 20, 30]) {
await t.insertRow({ total: v });
}
const desired = dropTableFromPack(await current(), "cu_orders");
desired.config.site_name = "cu-rollback-1";
const { plan_id } = await planFor(desired);
await applyLib.applyPlan(plan_id, { prunable: [], confirmDataDrops: false });
ok("gated drop retained the table", (await rowsOf("cu_orders")).length === 3);
await rollback.rollbackApply(plan_id, {});
const n = (await rowsOf("cu_orders")).length;
ok("rollback leaves exactly the original 3 rows", n === 3, `rows=${n}`);
const again = await outcome(() => rollback.rollbackApply(plan_id, {}));
ok("a second rollback of the same plan is refused", !!again.error);
ok("...and does not duplicate rows", (await rowsOf("cu_orders")).length === 3);
});
await step("dropped field data comes back on rollback", async () => {
const t = await mkTable("cu_notes", [{ name: "title", type: "String" }, { name: "note", type: "String" }]);
await t.insertRow({ title: "a", note: "keep-me" });
const desired = await current();
const spec = desired.tables.find((x) => x.name === "cu_notes");
spec.fields = spec.fields.filter((f) => f.name !== "note");
const { plan_id } = await planFor(desired);
await applyLib.applyPlan(plan_id, { prunable: ["cu_notes"], confirmDataDrops: true });
ok("apply dropped the field", !Table.findOne({ name: "cu_notes" }).getField("note"));
await rollback.rollbackApply(plan_id, {});
const rs = await rowsOf("cu_notes");
ok("one row after rollback", rs.length === 1, `rows=${rs.length}`);
ok("field value restored", rs.length === 1 && rs[0].note === "keep-me", JSON.stringify(rs));
});
await step("dropped table rows return with their original primary keys", async () => {
const parent = await mkTable("cu_par", [{ name: "nm", type: "String" }]);
const id1 = await parent.insertRow({ nm: "p1" });
const id2 = await parent.insertRow({ nm: "p2" });
await Table.findOne({ name: "cu_par" }).deleteRows({ id: id1 });
const desired = dropTableFromPack(await current(), "cu_par");
const { plan_id } = await planFor(desired);
await applyLib.applyPlan(plan_id, { prunable: ["cu_par"], confirmDataDrops: true });
ok("table dropped", !Table.findOne({ name: "cu_par" }));
await rollback.rollbackApply(plan_id, {});
const rs = await rowsOf("cu_par");
ok("row restored under its original id", rs.length === 1 && rs[0].id === id2, JSON.stringify(rs));
});
await step("rows restored with explicit primary keys leave the id sequence usable", async () => {
const t = await mkTable("cu_seq", [{ name: "v", type: "String" }]);
for (const v of ["a", "b", "c"]) {
await t.insertRow({ v });
}
const { plan_id } = await planFor(dropTableFromPack(await current(), "cu_seq"));
await applyLib.applyPlan(plan_id, { prunable: ["cu_seq"], confirmDataDrops: true });
await rollback.rollbackApply(plan_id, {});
ok("three rows back", (await rowsOf("cu_seq")).length === 3);
const r = await Table.findOne({ name: "cu_seq" }).tryInsertRow({ v: "d" });
ok("a new row inserts after the restored ids", r.success !== undefined && r.success > 3, JSON.stringify(r));
});
await step("a confirmed table rename keeps rows and data mode, and rolls back", async () => {
await mkTable("cu_rn_a", [{ name: "qq", type: "String" }]);
await rowsLib.setDataMode("cu_rn_a", "managed");
await Table.findOne({ name: "cu_rn_a" }).insertRow({ qq: "r1" });
const desired = await current();
desired.tables.find((x) => x.name === "cu_rn_a").name = "cu_rn_b";
const { plan_id, plan } = await planFor(desired);
ok("rename detected", plan.entities.tables.rename.some((r) => r.from === "cu_rn_a" && r.to === "cu_rn_b"));
const res = await applyLib.applyPlan(plan_id, { confirmRenames: ["cu_rn_a"] });
if (db.isSQLite) {
ok("SQLite: rename falls back to create + gated drop", res.renames.failed.some((r) => r.from === "cu_rn_a"));
ok("SQLite: old table keeps its rows", (await rowsOf("cu_rn_a")).length === 1);
return;
}
ok("PG: renamed", res.renames.renamed.some((r) => r.from === "cu_rn_a"), JSON.stringify(res.renames));
ok("PG: rows moved with the table", !Table.findOne({ name: "cu_rn_a" }) && (await rowsOf("cu_rn_b")).length === 1);
ok("PG: data mode moved", (await rowsLib.dataModeOf("cu_rn_b")) === "managed" && (await rowsLib.dataModeOf("cu_rn_a")) === "user");
await rollback.rollbackApply(plan_id, {});
const aRows = Table.findOne({ name: "cu_rn_a" }) ? await rowsOf("cu_rn_a") : [];
ok("PG rollback: original name back with its rows", !Table.findOne({ name: "cu_rn_b" }) && aRows.length === 1, JSON.stringify(aRows));
ok("PG rollback: data mode moved back", (await rowsLib.dataModeOf("cu_rn_a")) === "managed");
});
await step("a managed row inserted locally (no identity yet) is not duplicated by rollback", async () => {
await mkTable("cu_nouuid", [{ name: "v", type: "String" }]);
await rowsLib.setDataMode("cu_nouuid", "managed");
await Table.findOne({ name: "cu_nouuid" }).insertRow({ v: "local" });
const desired = await current();
desired.config.site_name = "cu-nouuid";
const { plan_id } = await planFor(desired);
await applyLib.applyPlan(plan_id, {});
await rollback.rollbackApply(plan_id, {});
const rs = await rowsOf("cu_nouuid");
ok("still exactly one row", rs.length === 1, JSON.stringify(rs));
ok("the row has an identity", rs.length === 1 && !!rs[0]._ss_row_uuid);
});
await step("rollback under additive sync mode still removes what the apply created", async () => {
await env.setEnvFields({ sync_mode: "additive" });
try {
const desired = await current();
const live = await mkTable("cu_added", [{ name: "x", type: "String" }]);
const withAdded = await current();
await live.delete();
const { plan_id } = await planFor(withAdded);
await applyLib.applyPlan(plan_id, {});
ok("apply created the table", !!Table.findOne({ name: "cu_added" }));
await rollback.rollbackApply(plan_id, {});
ok("rollback removed it", !Table.findOne({ name: "cu_added" }));
ok("desired pack untouched", desired.tables.every((x) => x.name !== "cu_added"));
} finally {
await env.setEnvFields({ sync_mode: "mirror" });
}
});
await step("rollback of a starter seed lets it seed again", async () => {
await mkTable("cu_seed", [{ name: "v", type: "String" }]);
await rowsLib.setDataMode("cu_seed", "starter");
const manifest = { cu_seed: { mode: "starter", rows: [{ __uuid: "cu-s1", v: "a" }, { __uuid: "cu-s2", v: "b" }] } };
const first = await planFor(await current(), manifest);
await applyLib.applyPlan(first.plan_id, {});
ok("seeded", (await rowsOf("cu_seed")).length === 2);
await rollback.rollbackApply(first.plan_id, {});
ok("rollback removed the seed", (await rowsOf("cu_seed")).length === 0);
const second = await planFor(await current(), manifest);
await applyLib.applyPlan(second.plan_id, {});
const n = (await rowsOf("cu_seed")).length;
ok("re-apply seeds again", n === 2, `rows=${n}`);
});
};
const planApplyChecks = async () => {
section("plan and apply agree");
await step("code pages are previewed, applied, and pruned", async () => {
const desired = await current();
desired.code_pages = [{ name: "cu_code", code: "const cuFn = () => 1;", tags: [] }];
const { plan_id, plan } = await planFor(desired);
ok("plan is not empty", !diff.isEmptyPlan(plan), JSON.stringify(plan.summary));
ok("plan lists the code page", !!(plan.entities.code_pages && plan.entities.code_pages.create.length === 1));
ok("plan warns about server-side code", plan.warnings.some((w) => /code page/.test(w)));
await applyLib.applyPlan(plan_id, {});
ok("installed", "cu_code" in getState().getConfig("function_code_pages", {}));
const without = await current();
without.code_pages = [];
const p2 = await planFor(without);
ok("deletion is previewed", !!(p2.plan.entities.code_pages && p2.plan.entities.code_pages.delete.length === 1));
await applyLib.applyPlan(p2.plan_id, {});
ok("pruned by a mirror apply", !("cu_code" in getState().getConfig("function_code_pages", {})));
});
await step("view rename is planned as create + delete and fully applied", async () => {
const t = await mkTable("cu_vt", [{ name: "x", type: "String" }]);
await View.create({ name: "cu_old_list", viewtemplate: "List", table_id: t.id, configuration: { columns: [] }, min_role: 100 });
const desired = await current();
desired.views.find((v) => v.name === "cu_old_list").name = "cu_new_list";
const { plan_id, plan } = await planFor(desired);
ok("old view is a planned delete", plan.entities.views.delete.some((v) => v.name === "cu_old_list"));
await applyLib.applyPlan(plan_id, {});
ok("new view exists", !!View.findOne({ name: "cu_new_list" }));
ok("old view removed", !View.findOne({ name: "cu_old_list" }));
});
await step("a removed constraint is removed", async () => {
const t = await mkTable("cu_con", [{ name: "code", type: "String" }]);
await TableConstraint.create({ table: t, type: "Unique", configuration: { fields: ["code"] } });
const desired = await current();
desired.tables.find((x) => x.name === "cu_con").constraints = [];
const { plan_id } = await planFor(desired);
await applyLib.applyPlan(plan_id, {});
const left = await TableConstraint.find({ table_id: t.id });
ok("constraint gone", left.length === 0, `left=${left.length}`);
});
await step("a model the source dropped is deleted", async () => {
const Model = require("@saltcorn/data/models/model");
const t = await mkTable("cu_mt", [{ name: "x", type: "String" }]);
await Model.create({ name: "cu_model", table_id: t.id, modelpattern: "cu_pattern", configuration: {} });
const desired = await current();
desired.models = desired.models.filter((m) => m.name !== "cu_model");
const { plan_id, plan } = await planFor(desired);
ok("model delete planned", plan.entities.models.delete.some((m) => m.name === "cu_model"));
await applyLib.applyPlan(plan_id, {});
ok("model deleted", !(await Model.findOne({ name: "cu_model", table_id: t.id })));
});
await step("a view moved to another table is re-bound", async () => {
const a = await mkTable("cu_va", [{ name: "x", type: "String" }]);
const b = await mkTable("cu_vb", [{ name: "x", type: "String" }]);
await View.create({ name: "cu_moving", viewtemplate: "List", table_id: a.id, configuration: { columns: [] }, min_role: 100 });
const desired = await current();
desired.views.find((v) => v.name === "cu_moving").table = "cu_vb";
const { plan_id } = await planFor(desired);
await applyLib.applyPlan(plan_id, {});
ok("view now bound to cu_vb", View.findOne({ name: "cu_moving" }).table_id === b.id);
});
await step("lookup table and its inbound FK field drop in ONE apply", async () => {
const cats = await mkTable("cu_cats", [{ name: "label", type: "String" }]);
const c1 = await cats.insertRow({ label: "a" });
const items = await mkTable("cu_items", [{ name: "nm", type: "String" }, { name: "cat", type: "Key to cu_cats" }]);
await items.insertRow({ nm: "i1", cat: c1 });
const desired = dropTableFromPack(await current(), "cu_cats");
const it = desired.tables.find((x) => x.name === "cu_items");
it.fields = it.fields.filter((f) => f.name !== "cat");
const { plan_id } = await planFor(desired);
const res = await applyLib.applyPlan(plan_id, { prunable: ["cu_cats", "cu_items"], confirmDataDrops: true });
ok("cu_cats dropped", !Table.findOne({ name: "cu_cats" }), JSON.stringify(res.deletes.skipped));
ok("cu_items.cat dropped", !Table.findOne({ name: "cu_items" }).getField("cat"));
});
await step("unchanged tags are not deleted and re-created", async () => {
await Tag.create({ name: "cu_tag", entries: [] });
const { plan_id } = await planFor(await current());
// SQLite reuses a deleted row's id, so count the deletes instead of comparing ids.
const realDelete = Tag.prototype.delete;
let deletes = 0;
Tag.prototype.delete = async function () {
deletes += this.name === "cu_tag" ? 1 : 0;
return realDelete.apply(this, arguments);
};
try {
await applyLib.applyPlan(plan_id, {});
} finally {
Tag.prototype.delete = realDelete;
}
ok("unchanged tag never deleted", deletes === 0, `deletes=${deletes}`);
ok("tag still present", !!(await Tag.findOne({ name: "cu_tag" })));
});
await step("a failed apply restores the plan status and names the plan", async () => {
const desired = await current();
desired.tags.push({ name: "cu_badtag", entries: [{ page_name: "cu_no_such_page" }] });
const { plan_id } = await planFor(desired);
const r = await outcome(() => applyLib.applyPlan(plan_id, {}));
ok("apply threw", !!r.error);
ok("error carries planId", !!r.error && r.error.planId === plan_id);
ok("status restored to pending", (await planLib.loadPlan(plan_id)).status === "pending");
});
await step("concurrent applies of one plan: exactly one runs", async () => {
const desired = await current();
desired.config.site_name = "cu-concurrent";
const { plan_id } = await planFor(desired);
const results = await Promise.all([outcome(() => applyLib.applyPlan(plan_id, {})), outcome(() => applyLib.applyPlan(plan_id, {}))]);
const failed = results.filter((x) => x.error);
ok("one succeeded, one refused", results.filter((x) => x.value).length === 1 && failed.length === 1);
ok("refusal is the site lock or the claim, not a mid-apply error", failed.length === 1 && /another apply is running on this site|cannot be applied/.test(failed[0].error.message), failed.length ? failed[0].error.message : "");
const redo = await outcome(() => applyLib.applyPlan(plan_id, {}));
ok("re-applying an applied plan is refused", !!redo.error);
});
await step("a structural rename match is not applied without confirmation", async () => {
await mkTable("cu_ren_old", [{ name: "zz", type: "String" }]);
const desired = await current();
desired.tables.find((x) => x.name === "cu_ren_old").name = "cu_ren_new";
const { plan_id, plan } = await planFor(desired);
ok("rename detected", plan.entities.tables.rename.some((r) => r.from === "cu_ren_old"));
const res = await applyLib.applyPlan(plan_id, {});
ok("no rename attempted without confirmRenames", res.renames.renamed.length === 0 && res.renames.failed.length === 0, JSON.stringify(res.renames));
});
await step("data-bearing drop form never pre-fills the allow-list", async () => {
const t = await mkTable("cu_formdrop", [{ name: "x", type: "String" }]);
await t.insertRow({ x: "1" });
const { plan_id, plan } = await planFor(dropTableFromPack(await current(), "cu_formdrop"));
const html = planLib.renderApplyForm(plan_id, plan, "");
ok("prunable input has no value", /name="prunable"/.test(html) && !/name="prunable"[^>]*value="[^"]+"/.test(html));
});
};
const rowChecks = async () => {
section("reference rows");
await step("managed parent+child prune does not abort the apply", async () => {
await mkTable("cu_countries", [{ name: "nm", type: "String" }]);
await rowsLib.setDataMode("cu_countries", "managed");
await mkTable("cu_cities", [{ name: "nm", type: "String" }, { name: "country", type: "Key to cu_countries" }]);
await rowsLib.setDataMode("cu_cities", "managed");
const cid = await Table.findOne({ name: "cu_countries" }).insertRow({ nm: "X" });
await Table.findOne({ name: "cu_cities" }).insertRow({ nm: "Y", country: cid });
await rowsLib.buildRowManifest();
const manifest = { cu_countries: { mode: "managed", rows: [] }, cu_cities: { mode: "managed", rows: [] } };
const { plan_id } = await planFor(await current(), manifest);
const r = await outcome(() => applyLib.applyPlan(plan_id, {}));
ok("apply succeeded", !r.error, r.error ? r.error.message : "");
ok("both rows pruned", (await rowsOf("cu_countries")).length === 0 && (await rowsOf("cu_cities")).length === 0);
});
await step("a managed row still referenced by user data is kept, not cascaded", async () => {
await mkTable("cu_kinds", [{ name: "nm", type: "String" }]);
await rowsLib.setDataMode("cu_kinds", "managed");
const kid = await Table.findOne({ name: "cu_kinds" }).insertRow({ nm: "k" });
const orders = await mkTable("cu_userorders", [{ name: "kind", type: "Key to cu_kinds", attributes: { on_delete: "Cascade" } }]);
await orders.insertRow({ kind: kid });
await rowsLib.buildRowManifest();
const { plan_id } = await planFor(await current(), { cu_kinds: { mode: "managed", rows: [] } });
const r = await outcome(() => applyLib.applyPlan(plan_id, {}));
ok("apply succeeded", !r.error, r.error ? r.error.message : "");
ok("user row survives", (await rowsOf("cu_userorders")).length === 1);
ok("referenced managed row kept", (await rowsOf("cu_kinds")).length === 1);
ok("reported as a warning", !r.error && r.value.warnings.some((w) => /still referenced/.test(w)));
});
await step("an unchanged managed table is in sync and not rewritten", async () => {
await mkTable("cu_colors", [{ name: "nm", type: "String" }, { name: "on", type: "Bool" }, { name: "at", type: "Date" }]);
await rowsLib.setDataMode("cu_colors", "managed");
await Table.findOne({ name: "cu_colors" }).insertRow({ nm: "red", on: true, at: new Date("2024-01-02T03:04:05Z") });
const { manifest } = await rowsLib.buildRowManifest();
const shipped = JSON.parse(JSON.stringify({ cu_colors: manifest.cu_colors }));
const { plan_id, plan } = await planFor(await current(), shipped);
ok("preview shows no row updates", plan.rows.perTable.cu_colors.updates === 0, JSON.stringify(plan.rows.perTable.cu_colors));
const res = await applyLib.applyPlan(plan_id, {});
ok("apply rewrote nothing", res.rows.perTable.cu_colors.updated === 0, JSON.stringify(res.rows.perTable.cu_colors));
});
await step("a managed row with a REQUIRED File field is inserted", async () => {
const bytes = Buffer.from("cu required file");
const hash = require("../lib/common/hash").sha256Hex(bytes);
await mkTable("cu_docs", [{ name: "title", type: "String" }, { name: "scan", type: "File", required: true }]);
await rowsLib.setDataMode("cu_docs", "managed");
const manifest = { cu_docs: { mode: "managed", rows: [{ __uuid: "cu-d1", title: "t", scan__file: { hash, filename: "s.txt", mime: "text/plain", min_role_read: 1 } }] } };
const { plan_id } = await planFor(await current(), manifest);
// SQLite cannot add a NOT NULL column to an existing table, so emulate the
// Postgres NOT NULL on the required File column.
const realInsert = Table.prototype.tryInsertRow;
Table.prototype.tryInsertRow = async function (v) {
if (this.name === "cu_docs" && !v.scan) {
return { error: "null value in column \"scan\" violates not-null constraint" };
}
return realInsert.apply(this, arguments);
};
try {
await applyLib.applyPlan(plan_id, { fileFetcher: async () => bytes });
} finally {
Table.prototype.tryInsertRow = realInsert;
}
const rs = await rowsOf("cu_docs");
ok("row inserted with its file", rs.length === 1 && !!rs[0].scan, JSON.stringify(rs));
});
await step("a failed file fetch keeps the existing file reference", async () => {
const a = await File.from_contents("cu_a.txt", "text/plain", Buffer.from("cu file A"), 1, 1, "/");
await mkTable("cu_att", [{ name: "doc", type: "File" }]);
await rowsLib.setDataMode("cu_att", "managed");
await Table.findOne({ name: "cu_att" }).insertRow({ doc: a.field_value });
const { manifest } = await rowsLib.buildRowManifest();
const shipped = JSON.parse(JSON.stringify({ cu_att: manifest.cu_att }));
shipped.cu_att.rows[0].doc__file.hash = "f".repeat(64);
const { plan_id } = await planFor(await current(), shipped);
await applyLib.applyPlan(plan_id, { fileFetcher: async () => { throw new Error("offline"); } });
ok("reference kept", (await rowsOf("cu_att"))[0].doc === a.field_value);
});
await step("a starter seed that is incomplete is not marked shipped", async () => {
await mkTable("cu_sp", [{ name: "v", type: "String", required: true }]);
await rowsLib.setDataMode("cu_sp", "starter");
const manifest = { cu_sp: { mode: "starter", rows: [{ __uuid: "cu-sp1", v: "ok" }, { __uuid: "cu-sp2", v: null }] } };
const { plan_id } = await planFor(await current(), manifest);
await applyLib.applyPlan(plan_id, {});
const mode = await db.selectMaybeOne("_ss_table_modes", { table_name: "cu_sp" });
ok("not marked shipped", !mode.starter_shipped_at);
});
await step("duplicate row identities are re-keyed", async () => {
const t = await mkTable("cu_dup", [{ name: "v", type: "String" }]);
await rowsLib.setDataMode("cu_dup", "managed");
await t.insertRow({ v: "a", _ss_row_uuid: "cu-same" });
await Table.findOne({ name: "cu_dup" }).insertRow({ v: "b", _ss_row_uuid: "cu-same" });
await rowId.ensureManagedSchema("cu_dup");
const uuids = new Set((await rowsOf("cu_dup")).map((r) => r._ss_row_uuid));
ok("two distinct identities", uuids.size === 2);
});
await step("identity column never appears in the schema diff", async () => {
await mkTable("cu_idcol", [{ name: "v", type: "String" }]);
await rowsLib.setDataMode("cu_idcol", "managed");
const desired = await current();
const spec = desired.tables.find((x) => x.name === "cu_idcol");
spec.fields = spec.fields.filter((f) => f.name !== "_ss_row_uuid");
const { plan } = await planFor(desired);
const u = plan.entities.tables.update.find((x) => x.name === "cu_idcol");
ok("no drop of _ss_row_uuid planned", !u || !u.fields.delete.some((f) => f.name === "_ss_row_uuid"));
});
await step("managed sync works for a table whose name needs sanitizing", async () => {
await mkTable("Cu Blog Posts", [{ name: "title", type: "String" }]);
const r = await outcome(() => rowsLib.setDataMode("Cu Blog Posts", "managed"));
ok("setDataMode succeeded", !r.error, r.error ? r.error.message : "");
await Table.findOne({ name: "Cu Blog Posts" }).insertRow({ title: "p" });
const m = await outcome(() => rowsLib.buildRowManifest());
ok("manifest built", !m.error && m.value.manifest["Cu Blog Posts"].rows.length === 1, m.error ? m.error.message : "");
});
await step("an upgraded install converges to exactly one uuid index", async () => {
await mkTable("Cu_Legacy", [{ name: "v", type: "String" }]);
await rowsLib.setDataMode("Cu_Legacy", "managed");
// the pre-0.0.2 statement: unquoted index name
await db.query(`CREATE INDEX IF NOT EXISTS Cu_Legacy_ss_row_uuid ON ${db.getTenantSchemaPrefix()}"Cu_Legacy" (_ss_row_uuid)`);
await rowId.ensureManagedSchema("Cu_Legacy");
const idx = await uuidIndexes("Cu_Legacy");
ok("one uuid index left", idx.length === 1, idx.join(","));
});
await step("setDataMode refuses infrastructure tables", async () => {
const r = await outcome(() => rowsLib.setDataMode("_ss_table_modes", "user"));
ok("refused", !!r.error);
});
await step("preview does not mutate the target", async () => {
await mkTable("cu_nomut", [{ name: "v", type: "String" }]);
await db.insert("_ss_table_modes", { table_name: "cu_nomut", data_mode: "managed", updated_at: new Date().toISOString() }, { noid: true });
await planFor(await current(), { cu_nomut: { mode: "managed", rows: [{ __uuid: "cu-nm1", v: "x" }] } });
ok("no identity column added by a preview", !Table.findOne({ name: "cu_nomut" }).getField("_ss_row_uuid"));
});
await step("a stale mode row is reported by the manifest builder", async () => {
const t = await mkTable("cu_gone", [{ name: "v", type: "String" }]);
await rowsLib.setDataMode("cu_gone", "managed");
await t.delete();
const { warnings } = await rowsLib.buildRowManifest();
ok("warned", warnings.some((w) => /cu_gone/.test(w)));
});
await step("renameModeRow carries the data mode to the new name", async () => {
await mkTable("cu_mode_a", [{ name: "v", type: "String" }]);
await rowsLib.setDataMode("cu_mode_a", "managed");
await rowsLib.renameModeRow("cu_mode_a", "cu_mode_b");
ok("mode moved", (await rowsLib.dataModeOf("cu_mode_b")) === "managed" && (await rowsLib.dataModeOf("cu_mode_a")) === "user");
});
};
const layoutChecks = async () => {
section("layout files");
await step("a fetched layout image keeps its page's visibility", async () => {
const f = await File.from_contents("cu_secret.png", "image/png", PNG_BYTES, 1, 1, "/");
await Page.create({ name: "cu_admin_page", title: "a", description: "", min_role: 1, fixed_states: {}, attributes: {},
layout: { above: [{ type: "image", fileid: f.field_value }] } });
const desired = await current();
await Page.findOne({ name: "cu_admin_page" }).delete();
await (await File.findOne(f.field_value)).delete();
const { plan_id } = await planFor(desired);
await applyLib.applyPlan(plan_id, { fileFetcher: async () => PNG_BYTES });
const fid = Page.findOne({ name: "cu_admin_page" }).layout.above[0].fileid;
const file = await File.findOne(fid);
ok("materialized file is admin-only like its page", !!file && file.min_role_read === 1, file ? `min_role_read=${file.min_role_read}` : "no file");
});
};
// Postgres multi-tenant only: identifiers must be sanitized AND schema-qualified.
// Tenant setup follows pgGate.js / upstream's tenant test.
const tenantChecks = async () => {
if (db.isSQLite) {
return;
}
section("tenant schema (Postgres)");
const tenant = "cutenant";
const { add_tenant } = require("@saltcorn/data/db/state");
const { create_tenant, switchToTenant, insertTenant } = require("@saltcorn/admin-models/models/tenant");
const Plugin = require("@saltcorn/data/models/plugin");
await step("managed sync of a sanitized-name table inside a tenant schema", async () => {
add_tenant(tenant);
await switchToTenant(await insertTenant(tenant, `admin@${tenant}.local`, "ssCore tenant check"), `http://${tenant}.example.com/`);
await create_tenant({ t: tenant, loadAndSaveNewPlugin() {}, plugin_loader() {} });
await db.runWithTenant(tenant, async () => {
await Plugin.loadAllPlugins();
await db.runWithTenant(db.connectObj.default_schema, () => getState().setConfig("tenants_unsafe_plugins", true));
await installPlugin();
ok("running in the tenant schema", db.getTenantSchema() === tenant);
await mkTable("Cu Tenant Posts", [{ name: "title", type: "String" }]);
await rowsLib.setDataMode("Cu Tenant Posts", "managed");
await Table.findOne({ name: "Cu Tenant Posts" }).insertRow({ title: "old" });
const { manifest } = await rowsLib.buildRowManifest();
const shipped = JSON.parse(JSON.stringify({ "Cu Tenant Posts": manifest["Cu Tenant Posts"] }));
shipped["Cu Tenant Posts"].rows[0].title = "new";
const { plan_id } = await planFor(await current(), shipped);
const res = await applyLib.applyPlan(plan_id, {});
ok("row converged in the tenant", (await rowsOf("Cu Tenant Posts"))[0].title === "new");
ok("exactly one row updated", res.rows.perTable["Cu Tenant Posts"].updated === 1, JSON.stringify(res.rows.perTable));
ok("plan claimed and marked applied in the tenant", (await planLib.loadPlan(plan_id)).status === "applied");
ok("tenant table has one uuid index", (await uuidIndexes("Cu Tenant Posts")).length === 1);
await mkTable("CuTenLegacy", [{ name: "v", type: "String" }]);
await rowsLib.setDataMode("CuTenLegacy", "managed");
await db.query(`CREATE INDEX IF NOT EXISTS CuTenLegacy_ss_row_uuid ON ${db.getTenantSchemaPrefix()}"CuTenLegacy" (_ss_row_uuid)`);
await rowId.ensureManagedSchema("CuTenLegacy");
const idx = await uuidIndexes("CuTenLegacy");
ok("legacy index dropped in the tenant schema", idx.length === 1, idx.join(","));
});
ok("nothing leaked into the root schema", !Table.findOne({ name: "Cu Tenant Posts" }));
});
};
// The recorded compatibility vectors (test/compatVectors.js) in a child process:
// they set their own (fake) session secret.
const compatChecks = async () => {
section("compatibility vectors");
await step("peer secrets and signatures from earlier versions still work", async () => {
const childProcess = require("child_process");
const r = childProcess.spawnSync(process.execPath, [path.join(__dirname, "compatVectors.js")], { encoding: "utf8" });
ok("test/compatVectors.js passes", r.status === 0, r.status === 0 ? "" : (r.stdout + r.stderr).trim());
});
};
const main = async () => {
const where = db.isSQLite ? `sqlite ${process.env.SQLITE_FILEPATH}` : `postgres ${db.connectObj.database} schema ${db.getTenantSchema()}`;
console.log(`DB = ${where} LIB = ${LIB}`);
await bootstrapPlugin();
await rollbackChecks();
await planApplyChecks();
await rowChecks();
await layoutChecks();
await tenantChecks();
await compatChecks();
process.exit(summary() === 0 ? 0 : 1);
};
main();

1598
test/e2e.js Normal file

File diff suppressed because it is too large Load diff

218
test/harness.js Normal file
View file

@ -0,0 +1,218 @@
// Shared helpers for site-sync's in-process test suites.
//
// Everything runs against ONE throwaway SQLite DB (set up by run.sh) with the
// plugin registered by location so its real onLoad runs. Peer HMAC auth is
// exercised with mock req/res objects: we sign a canonical with the plugin's own
// crypto and feed requirePeerAuth an async-iterable mock request, so the full
// inbound pipeline (skew, host-binding, signature, nonce replay, TLS gate) is
// covered without standing up a second HTTP server.
const path = require("path");
const db = require("@saltcorn/data/db");
const Plugin = require("@saltcorn/data/models/plugin");
const PLUGIN_DIR = path.resolve(__dirname, "..");
const { PLUGIN_NAME, PEER_CONTENT_TYPE, HEADERS } = require("../lib/constants");
const {
buildCanonical,
buildResponseCanonical,
normalizeHost,
sign,
verifySignature,
randomNonce
} = require("../lib/common/peerProtocol");
let failures = 0;
let passes = 0;
const ok = (label, cond, detail) => {
const tag = cond ? "PASS" : "FAIL";
if (cond) {
passes += 1;
} else {
failures += 1;
}
console.log(` [${tag}] ${label}${detail ? " -- " + detail : ""}`);
return cond;
};
const section = (title) => {
console.log(`\n# ${title}`);
};
const step = async (label, fn) => {
try {
await fn();
} catch (e) {
failures += 1;
console.log(` [FAIL] ${label} THREW -- ${e && (e.stack || e.message)}`);
}
};
// Print the tally and return the process exit code (never the raw count: exit
// codes wrap mod 256, so 256 failures would otherwise exit 0).
const summary = () => {
console.log(`\n${failures === 0 ? "ALL GREEN" : "FAILURES"}: ${passes} passed, ${failures} failed`);
return failures > 0 ? 1 : 0;
};
// Register site-sync by location so its real onLoad runs (createAllTables +
// initEnvIfMissing + ensureCsrfBypass) against the throwaway DB.
const installPlugin = async () => {
const plugin = new Plugin({ name: PLUGIN_NAME, source: "local", location: PLUGIN_DIR, configuration: {} });
await Plugin.loadAndSaveNewPlugin(plugin, true, false);
};
const bootstrapPlugin = async () => {
await Plugin.loadAllPlugins();
await installPlugin();
};
// Mock express response. A signed peer-API reply arrives via set() + end(Buffer)
// (lib/common/peerServer.js sendSigned): the raw bytes are kept (for signature
// checks) and a JSON body is parsed into body.
const mkRes = () => {
const r = { statusCode: 200, headers: {}, body: undefined };
r.status = (c) => { r.statusCode = c; return r; };
r.json = (o) => { r.body = o; r._json = o; return r; };
r.send = (s) => {
r._sent = s;
r.raw = Buffer.isBuffer(s) ? s : Buffer.from(String(s));
r.body = Buffer.isBuffer(s) && /json/.test(r._type || "") ? JSON.parse(s.toString("utf8")) : s;
return r;
};
r.type = (t) => { r._type = t; return r; };
r.set = (k, v) => {
const pairs = k && typeof k === "object" ? Object.entries(k) : [[k, v]];
for (const [hk, hv] of pairs) {
r.headers[String(hk).toLowerCase()] = hv;
}
return r;
};
r.get = (k) => r.headers[String(k).toLowerCase()];
r.redirect = (u) => { r._redirect = u; return r; };
r.sendWrap = (title, opts) => { r._wrapTitle = title; r._wrap = opts; return r; };
r.sendFile = (p, o, cb) => { r._file = p; if (cb) cb(); return r; };
r.end = (b) => {
r._ended = true;
if (b !== undefined) {
r.raw = Buffer.isBuffer(b) ? b : Buffer.from(String(b));
r.body = /json/.test(r.headers["content-type"] || "") ? JSON.parse(r.raw.toString("utf8")) : r.raw;
}
return r;
};
return r;
};
// Mock admin request. req.flash records flashed messages in req._flash.
const mkAdminReq = (over) => {
const req = Object.assign({
user: { role_id: 1, tenant: db.getTenantSchema(), email: "admin@test" },
params: {}, query: {}, body: {}, headers: {}, xhr: true,
path: "/admin/site-sync/", originalUrl: "/admin/site-sync/",
method: "GET",
get: () => undefined, csrfToken: () => "testcsrf",
// Saltcorn's markup (post_btn etc.) localizes via req.__; identity stub.
__: (s) => s,
_flash: [],
}, over);
req.flash = (type, msg) => { req._flash.push({ type, msg }); };
return req;
};
// Build a mock signed peer request that requirePeerAuth can consume. Signs with
// the given peer secret using the plugin's own canonical/sign so the verify side
// must agree. `override` lets a test corrupt individual fields (bad sig, stale ts,
// wrong host, reused nonce) to prove rejection.
const mkPeerReq = ({ method, path, host, envId, secret, body, secure, override }) => {
const bodyStr = body ? JSON.stringify(body) : "";
const timestamp = String((override && override.timestamp) || Date.now());
const nonce = (override && override.nonce) || randomNonce().toString("hex");
const targetHost = normalizeHost(host);
const canonical = buildCanonical({ timestamp, nonce, method, path, targetHost, body: bodyStr });
let signature = sign(secret, canonical);
if (override && override.badSignature) {
signature = "00".repeat(32);
}
const headers = {
host: host,
[HEADERS.ENV_ID.toLowerCase()]: envId,
[HEADERS.TIMESTAMP.toLowerCase()]: timestamp,
[HEADERS.NONCE.toLowerCase()]: nonce,
[HEADERS.SIGNATURE.toLowerCase()]: signature
};
if (bodyStr) {
headers["content-type"] = PEER_CONTENT_TYPE;
}
if (override && override.headers) {
Object.assign(headers, override.headers);
}
// req.host / req.protocol mimic Express with trust proxy OFF (Saltcorn's
// default): they come from the Host header and the socket, never from
// X-Forwarded-* headers a client can set.
const req = {
method: method,
url: path,
originalUrl: path,
headers: headers,
host: headers.host,
protocol: secure ? "https" : "http",
query: {},
params: {}
};
// readRawBody drains `for await (const chunk of req)`.
req[Symbol.asyncIterator] = async function* () {
if (bodyStr) {
yield Buffer.from(bodyStr, "utf8");
}
};
return req;
};
// Whether a mock response carries a valid response signature for `req`'s nonce.
const responseSigned = (req, res, secret) => {
const canonical = buildResponseCanonical({
nonce: req.headers[HEADERS.NONCE.toLowerCase()],
status: res.statusCode,
body: res.raw || Buffer.alloc(0)
});
return verifySignature(secret, canonical, res.headers[HEADERS.RESPONSE_SIGNATURE.toLowerCase()]);
};
// Route callback by method + url (GET and POST can share a url).
const routeOf = (routesArr, method, url) => {
const r = routesArr.find((x) => x.method === method && x.url === url);
if (!r) {
throw new Error(`no ${method} route ${url}`);
}
return r.callback;
};
module.exports = {
PLUGIN_DIR,
db,
ok,
section,
step,
summary,
installPlugin,
bootstrapPlugin,
mkRes,
mkAdminReq,
mkPeerReq,
responseSigned,
routeOf
};

176
test/livePull.js Normal file
View file

@ -0,0 +1,176 @@
// Live pull over REAL HTTP. Modes (each run against its own instance's DB, set by
// runLivePull.sh):
// init-target register the plugin (real onLoad) on a fresh TARGET DB; print env_id
// init-source register + seed distinctive design state on the SOURCE DB, and pair
// the target into it; print env_id (needs TARGET_ENV, SHARED)
// pair-target pair the source into the TARGET DB (needs SOURCE_ENV, SOURCE_URL, SHARED)
// drive (TARGET, in-process) pull the SOURCE over HTTP, plan, apply, verify
// (needs SOURCE_ENV, SOURCE_URL)
//
// The source runs as a real `saltcorn serve` process; the target is driven
// in-process. So the pull crosses a real network boundary: the target's outbound
// signed request -> HTTP -> the source's real inbound route + HMAC verify ->
// /api/state -> a signed response the target verifies before using it.
const Table = require("@saltcorn/data/models/table");
const Field = require("@saltcorn/data/models/field");
const File = require("@saltcorn/data/models/file");
const Page = require("@saltcorn/data/models/page");
const { getState } = require("@saltcorn/data/db/state");
const { peerBytes, API_PATHS } = require("../lib/transport");
const FILE_BYTES = Buffer.from("SOURCE FILE BYTES over HTTP");
// layout/logo images must be real images (content-sniffed); vary trailing bytes.
const PNG = Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==", "base64");
const LOGO_BYTES = Buffer.concat([PNG, Buffer.from("logo")]);
const HERO_BYTES = Buffer.concat([PNG, Buffer.from("hero")]);
const { bootstrapPlugin, ok, summary } = require("./harness");
const peers = require("../lib/peers");
const env = require("../lib/env");
const capture = require("../lib/capture");
const rows = require("../lib/rows");
const ops = require("../lib/operations");
const planLib = require("../lib/plan");
const applyLib = require("../lib/apply");
const SHARED = process.env.SHARED ? Buffer.from(process.env.SHARED, "hex") : null;
const initTarget = async () => {
await bootstrapPlugin();
const e = await env.getEnv();
console.log("ENV_ID=" + e.env_id);
};
const initSource = async () => {
await bootstrapPlugin();
// distinctive design state the target does NOT have
const planets = await Table.create("planets", { min_role_read: 1, min_role_write: 1 });
await Field.create({ table: planets, name: "pname", label: "pname", type: "String" });
await Field.create({ table: planets, name: "diameter", label: "diameter", type: "Integer" });
await getState().setConfig("site_name", "SourceSite");
// a managed reference table with rows (its manifest rides /api/state)
const status = await Table.create("ref_status", { min_role_read: 1, min_role_write: 1 });
await Field.create({ table: status, name: "label", label: "label", type: "String" });
const fresh = Table.findOne({ name: "ref_status" });
await fresh.insertRow({ label: "active" });
await fresh.insertRow({ label: "archived" });
await rows.setDataMode("ref_status", "managed");
// a managed table with a FILE field (its bytes ride GET /api/file/:hash)
const docs = await Table.create("docs", { min_role_read: 1, min_role_write: 1 });
await Field.create({ table: docs, name: "title", label: "title", type: "String" });
await Field.create({ table: docs, name: "attachment", label: "attachment", type: "File" });
const f = await File.from_contents("readme.txt", "text/plain", FILE_BYTES, 1, 1, "/");
await Table.findOne({ name: "docs" }).insertRow({ title: "doc1", attachment: f.field_value });
await rows.setDataMode("docs", "managed");
// a site logo + a page with a layout image (always-on file sync, not row data)
const logo = await File.from_contents("logo.png", "image/png", LOGO_BYTES, 1, 100, "/");
await getState().setConfig("site_logo_id", logo.field_value);
const hero = await File.from_contents("hero.png", "image/png", HERO_BYTES, 1, 100, "/");
await Page.create({ name: "landing", title: "Landing", description: "", min_role: 1,
layout: { above: [{ type: "image", fileid: hero.field_value, srcType: "File" }] }, fixed_states: {} });
// pair the target INTO the source so the source can verify the target's pulls
await peers.addPeer({ envId: process.env.TARGET_ENV, label: "target", baseUrl: "http://127.0.0.1:1/", existingSecret: SHARED });
const e = await env.getEnv();
console.log("ENV_ID=" + e.env_id);
};
const pairTarget = async () => {
await bootstrapPlugin();
await peers.addPeer({ envId: process.env.SOURCE_ENV, label: "source", baseUrl: process.env.SOURCE_URL, existingSecret: SHARED });
};
const drive = async () => {
await bootstrapPlugin();
const e = await env.getEnv();
const peerRow = await peers.findPeerByEnvId(process.env.SOURCE_ENV);
ok("source peer is paired", !!peerRow);
console.log("\n# real HTTP: plugin-parity health check");
const warnings = await ops.diffPluginsWithPeer(peerRow, e, await require("../lib/common/pluginParity").localPluginList());
ok("signed GET /api/health round-tripped over HTTP", Array.isArray(warnings) && !warnings.some((w) => /could not|returned/.test(w)), JSON.stringify(warnings));
console.log("\n# real HTTP: pull desired state");
const state = await ops.fetchPeerState(peerRow, e);
ok("signed GET /api/state returned a pack over HTTP", !!(state && state.pack && Array.isArray(state.pack.tables)));
ok("source's planets table present in pulled pack", !!state.pack.tables.find((t) => t.name === "planets"));
ok("source's managed ref rows rode /api/state", !!(state.rows && state.rows.ref_status && state.rows.ref_status.rows.length === 2));
console.log("\n# plan + apply (in-process) the pulled state");
const { plan_id, plan } = await planLib.buildPlan({
peerRow, env: e, desired: state.pack, desiredHash: state.hash,
sourceEnvId: state.env_id, direction: "pull", sourceRows: state.rows
});
ok("plan creates planets", plan.entities.tables.create.some((t) => t.name === "planets"));
ok("plan sets site_name config", plan.config.sets.site_name === "SourceSite");
const res = await applyLib.applyPlan(plan_id, { applyDeletes: true });
ok("apply succeeded", res.applied === true);
console.log("\n# verify the TARGET converged");
const p = Table.findOne({ name: "planets" });
ok("planets table now exists on target", !!p);
ok("planets has diameter field", !!(p && p.getField("diameter")));
ok("ref_status table created on target", !!Table.findOne({ name: "ref_status" }));
ok("site_name config converged", getState().getConfig("site_name") === "SourceSite", getState().getConfig("site_name"));
console.log("\n# always-on layout/logo file sync over HTTP (materialized during apply)");
const landing = Page.findOne({ name: "landing" });
ok("landing page synced", !!landing);
const heroFid = landing && landing.layout.above[0].fileid;
ok("hero layout image resolved to a local file", !!heroFid && !heroFid.startsWith("__ss_file_ref::"));
const heroBytes = heroFid && (await (await File.findOne(heroFid)).get_contents());
ok("hero image content matches over HTTP", !!heroBytes && Buffer.compare(heroBytes, HERO_BYTES) === 0);
const logoVal = getState().getConfig("site_logo_id");
ok("site logo resolved to a local file", !!logoVal && !logoVal.startsWith("__ss_file_ref::"));
const logoBytes = logoVal && (await (await File.findOne(logoVal)).get_contents());
ok("logo content matches over HTTP", !!logoBytes && Buffer.compare(logoBytes, LOGO_BYTES) === 0);
console.log("\n# real HTTP: fetch a file by content hash");
const docRow = state.rows.docs && state.rows.docs.rows[0];
const fileHash = docRow && docRow.attachment__file && docRow.attachment__file.hash;
ok("docs manifest carried a file placeholder", !!fileHash);
const fileBytes = await peerBytes(peerRow, e.env_id, API_PATHS.file(fileHash));
ok("signed GET /api/file/:hash returned the verified bytes over HTTP", Buffer.compare(fileBytes, FILE_BYTES) === 0);
console.log("\n# full file materialization over HTTP (target opts in, second pull)");
await rows.setDataMode("docs", "managed");
const state2 = await ops.fetchPeerState(peerRow, e);
const built = await planLib.buildPlan({ peerRow, env: e, desired: state2.pack, desiredHash: state2.hash, sourceEnvId: state2.env_id, direction: "pull", sourceRows: state2.rows });
await applyLib.applyPlan(built.plan_id, { applyDeletes: true });
const doc = (await Table.findOne({ name: "docs" }).getRows({}, {}))[0];
ok("doc row synced to target", !!doc);
const localFile = doc && (await File.findOne(doc.attachment));
const bytes = localFile && (await localFile.get_contents());
ok("attachment materialized over HTTP with correct bytes", !!bytes && Buffer.compare(bytes, FILE_BYTES) === 0);
};
const MODES = {
"init-target": initTarget,
"init-source": initSource,
"pair-target": pairTarget,
"drive": drive
};
const EXIT_USAGE = 2;
const main = async () => {
const run = MODES[process.argv[2]];
if (!run) {
console.error("unknown mode: " + process.argv[2]);
process.exit(EXIT_USAGE);
}
await run();
process.exit(summary());
};
main().catch((err) => {
console.error(err && err.stack ? err.stack : err);
process.exit(1);
});

575
test/mcpTools.js Normal file
View file

@ -0,0 +1,575 @@
// site-sync MCP tools suite. Every definition is checked against saltcorn-mcp's
// contract (its lib/schemaCheck.js) and every tool is driven through its handler
// with a ctx stub, as the MCP server calls it: the read tools (which must change
// nothing), settings, table modes, peers (a generated secret is returned once and
// never again), a pull from a SIGNED MOCK PEER over real HTTP, the trimmed and
// redacted plan views, the drift refusal, apply -> rollback, the per-site
// apply/rollback guard, plan retention, unpairing that keeps rollback possible,
// and errors reported through ctx.fail.
//
// ./test/run.sh mcpTools.js
// ./test/runPg.sh mcpTools.js
const fs = require("fs");
const http = require("http");
const path = require("path");
const { getState } = require("@saltcorn/data/db/state");
const Table = require("@saltcorn/data/models/table");
const Field = require("@saltcorn/data/models/field");
const Page = require("@saltcorn/data/models/page");
const Trigger = require("@saltcorn/data/models/trigger");
const { db, ok, section, step, summary, bootstrapPlugin } = require("./harness");
const protocol = require("../lib/common/peerProtocol");
const { localPluginList } = require("../lib/common/pluginParity");
const capture = require("../lib/capture");
const rowsLib = require("../lib/rows");
const planLib = require("../lib/plan");
const siteLock = require("../lib/siteLock");
const peers = require("../lib/peers");
const env = require("../lib/env");
const { PREFIX } = require("../lib/mcpTools");
const { API_PATHS } = require("../lib/transport");
const { randomUuid } = require("../lib/ids");
const { HEADERS, PLUGIN_NAME, PLUGIN_VERSION } = require("../lib/constants");
const MCP_LIB = path.resolve(__dirname, "..", "..", "mcp", "lib");
const HAS_MCP = fs.existsSync(path.join(MCP_LIB, "schemaCheck.js"));
// saltcorn-mcp's mask for a redacted value.
const REDACTED = "[redacted]";
// What the fallback redaction masks when the sibling mcp plugin is absent.
const SECRET_KEY = /pass(word)?|secret|token|api_?key|credential/i;
// Values that must never appear in a plan view.
const TRIGGER_SECRET = "hunter2-mcp-trigger-secret";
const SOURCE_ROW_SECRET = "tok-mcp-source-row-secret";
const CODE_MARK = "mcpCodeMark";
const MOCK_ONLY_PLUGIN = "mcp-mock-only-plugin";
// The scope each tool is meant to have.
const SCOPES = {
read: ["status", "list_peers", "peer_health", "list_tables", "list_plans", "get_plan", "local_state"],
write: ["plan_pull"],
admin: ["apply_plan", "rollback", "update_settings", "add_peer", "rotate_peer_secret", "delete_peer", "set_table_mode"]
};
const DESTRUCTIVE = ["apply_plan", "rollback", "rotate_peer_secret", "delete_peer", "set_table_mode"];
const OPEN_WORLD = ["peer_health", "plan_pull"];
// saltcorn-mcp's result redaction (its lib/audit.js) when present, else a local
// mask of credential-named keys.
const fallbackRedact = (value) => {
if (Array.isArray(value)) {
return value.map(fallbackRedact);
}
if (value && typeof value === "object") {
return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, SECRET_KEY.test(k) ? REDACTED : fallbackRedact(v)]));
}
return value;
};
const redact = HAS_MCP ? require(path.join(MCP_LIB, "audit")).redact : fallbackRedact;
class ToolFail extends Error {}
const defs = require("../index").mcp_tools();
const byName = Object.fromEntries(defs.map((d) => [d.name, d]));
// Every peer secret (hex) this suite has seen, and the tool results that carried one.
const knownSecrets = new Set();
const leaks = [];
const ctxFor = (scope) => ({
scope: scope,
user: { id: 1, role_id: 1, email: "admin@test" },
req: {},
tenant: db.getTenantSchema(),
isRoot: true,
fail: (m) => {
throw new ToolFail(m);
},
redact: redact
});
// Call a tool's handler as the MCP server does, with a token of the tool's own
// scope: { ok: true, value } or { ok: false, error } for a ctx.fail (anything else
// thrown is a bug and propagates). A result may carry a peer secret only as the
// shared_secret this very call generated; any other is recorded as a leak.
const call = async (name, args) => {
const def = byName[PREFIX + name];
let out;
try {
out = { ok: true, value: await def.handler(args || {}, ctxFor(def.scope)) };
} catch (e) {
if (!(e instanceof ToolFail)) {
throw e;
}
out = { ok: false, error: e.message };
}
const text = JSON.stringify(out);
for (const s of knownSecrets) {
if (text.includes(s)) {
leaks.push(`${name} returned a known secret`);
}
}
if (/peer_secret_/.test(text)) {
leaks.push(`${name} returned a sealed secret column`);
}
if (out.ok && out.value && out.value.shared_secret) {
knownSecrets.add(out.value.shared_secret);
}
return out;
};
// The value of a call that must succeed (a failure throws, failing the step).
const value = async (name, args) => {
const r = await call(name, args);
if (!r.ok) {
throw new Error(`${name} failed: ${r.error}`);
}
return r.value;
};
const mkTable = async (name, fieldNames) => {
const t = await Table.create(name, { min_role_read: 1, min_role_write: 1 });
for (const f of fieldNames) {
await Field.create({ table: t, name: f, label: f, type: "String" });
}
return Table.findOne({ name });
};
const columnOf = async (name, field) => {
return (await Table.findOne({ name }).getRows({}, {})).map((r) => r[field]).sort();
};
const planCount = async () => {
return Number((await db.query(`SELECT COUNT(*) AS c FROM ${db.getTenantSchemaPrefix()}_ss_plans`)).rows[0].c);
};
// A signed mock site-sync peer over real HTTP: it verifies every request's
// signature against mock.secret (the pairing secret as the peer knows it) and signs
// every reply with it, answering /api/health with mock.plugins and /api/state with
// mock.state ({ hash, pack, rows }).
const startMockPeer = async (mock) => {
const server = http.createServer((req, res) => {
const nonce = req.headers[HEADERS.NONCE.toLowerCase()];
const reply = (status, obj, signed) => {
const body = Buffer.from(JSON.stringify(obj));
res.statusCode = status;
res.setHeader("content-type", "application/json");
if (signed) {
res.setHeader(HEADERS.RESPONSE_SIGNATURE, protocol.signResponse(mock.secret, { nonce, status, body }));
}
res.end(body);
};
const canonical = protocol.buildCanonical({
timestamp: req.headers[HEADERS.TIMESTAMP.toLowerCase()],
nonce: nonce,
method: req.method,
path: req.url,
targetHost: protocol.normalizeHost(req.headers.host),
body: ""
});
if (!mock.secret || !protocol.verifySignature(mock.secret, canonical, req.headers[HEADERS.SIGNATURE.toLowerCase()])) {
reply(401, { error: "bad signature" }, false);
return;
}
mock.calls.push(req.url);
const who = { plugin: PLUGIN_NAME, version: PLUGIN_VERSION, env_id: mock.envId, label: "mock source" };
if (req.url === API_PATHS.HEALTH) {
reply(200, { ...who, plugins: mock.plugins }, true);
} else if (req.url === API_PATHS.STATE) {
reply(200, { ...who, ...mock.state }, true);
} else {
reply(404, { error: "not found" }, true);
}
});
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
mock.server = server;
mock.base = `http://127.0.0.1:${server.address().port}`;
};
const contract = async () => {
section("definitions follow saltcorn-mcp's contract");
await step("schemas, names, texts, scopes, flags", async () => {
const problems = HAS_MCP ? defs.flatMap((d) => require(path.join(MCP_LIB, "schemaCheck")).checkSchemaSubset(d.inputSchema, d.name)) : [];
ok(`schemas in MCP's supported subset${HAS_MCP ? "" : " (mcp absent: not checked)"}`, problems.length === 0, problems.join("; "));
ok("every tool prefixed sitesync_ with a valid name, a title and a description",
defs.every((d) => d.name.startsWith(PREFIX) && /^[a-z][a-z0-9_]{0,63}$/.test(d.name) && d.title && d.description && typeof d.handler === "function"));
ok("texts are ASCII", defs.every((d) => !/[^\x00-\x7f]/.test(JSON.stringify({ t: d.title, d: d.description, s: d.inputSchema }))));
const undescribed = (schema) => Object.values(schema.properties || {}).some((p) => !p.description || undescribed(p));
ok("every argument (nested ones too) is described", defs.every((d) => !undescribed(d.inputSchema)));
ok("object schemas refuse unknown arguments", defs.every((d) => d.inputSchema.type === "object" && d.inputSchema.additionalProperties === false));
const expected = Object.values(SCOPES).flat().map((n) => PREFIX + n).sort();
ok("exactly the planned tools", JSON.stringify(defs.map((d) => d.name).sort()) === JSON.stringify(expected), defs.map((d) => d.name).join(","));
ok("each tool has its scope", Object.entries(SCOPES).every(([scope, names]) => names.every((n) => byName[PREFIX + n].scope === scope)));
ok("destructive exactly on apply / rollback / rotate / delete peer / table mode", defs.every((d) => !!d.destructive === DESTRUCTIVE.includes(d.name.slice(PREFIX.length))));
ok("openWorld exactly on peer_health and plan_pull", defs.every((d) => !!d.openWorld === OPEN_WORLD.includes(d.name.slice(PREFIX.length))));
ok("no read tool is destructive", defs.filter((d) => d.scope === "read").every((d) => !d.destructive));
});
};
const readTools = async () => {
section("read tools on a fresh site");
await step("status, list_peers, list_tables, list_plans, local_state", async () => {
const me = await env.getEnv();
const st = await value("status");
ok("status: identity, default policies, version, no peers", st.env_id === me.env_id && st.sync_mode === "mirror" && st.on_delete === "confirm" && st.on_apply === "confirm" && st.version === PLUGIN_VERSION && st.peer_count === 0);
ok("status: session secret configured, no synced tables", st.session_secret_configured === true && st.synced_tables.managed === 0 && st.synced_tables.starter === 0, JSON.stringify(st.synced_tables));
const lp = await value("list_peers");
ok("list_peers: none, with this site's env_id", lp.peers.length === 0 && lp.this_env_id === me.env_id);
const users = (await value("list_tables")).tables.find((t) => t.name === "users");
ok("list_tables: users is locked to user mode", !!users && users.locked === true && users.mode === "user");
ok("list_plans: none yet", (await value("list_plans")).plans.length === 0);
const bad = await call("list_plans", { status: "bogus" });
ok("list_plans: an invalid status is a tool error", !bad.ok && /invalid plan status/.test(bad.error));
const ls = await value("local_state");
ok("local_state: the hash a peer would pull, counts per kind", ls.hash === (await capture.buildDesiredState()).hash && typeof ls.counts.tables === "number" && Array.isArray(ls.config_settings));
});
};
const settings = async () => {
section("update_settings (admin): partial, validated");
await step("patch, refuse, clear, restore", async () => {
const s1 = await value("update_settings", { sync_mode: "additive", env_label: " mcp-target " });
ok("a partial patch changes only what it names", s1.sync_mode === "additive" && s1.label === "mcp-target" && s1.on_delete === "confirm" && s1.on_apply === "confirm");
const none = await call("update_settings", {});
ok("an empty patch is refused", !none.ok && /no settings given/.test(none.error));
const bad = await call("update_settings", { on_delete: "bogus" });
ok("an invalid value is refused by the operation (not only the schema)", !bad.ok && /invalid delete policy/.test(bad.error) && (await env.getEnv()).on_delete === "confirm");
const s2 = await value("update_settings", { env_label: null, sync_mode: "mirror" });
ok("null clears the label; mirror restored", s2.label === null && s2.sync_mode === "mirror");
});
};
// Local tables of the scenario: mcp_ref (managed reference rows a / b),
// mcp_doomed (user data the source does not have), mcp_old (empty; the source
// has it as mcp_new).
const tableModes = async () => {
section("set_table_mode (admin) + the data-mode validation fix");
await step("managed opt-in, locked / infra / unknown / invalid refused", async () => {
const ref = await mkTable("mcp_ref", ["name", "api_token"]);
await ref.insertRow({ name: "a", api_token: "tok-a" });
await ref.insertRow({ name: "b", api_token: "tok-b" });
const doomed = await mkTable("mcp_doomed", ["note"]);
await doomed.insertRow({ note: "row 1" });
await doomed.insertRow({ note: "row 2" });
await mkTable("mcp_old", ["legacy_code"]);
const set = await value("set_table_mode", { table: "mcp_ref", mode: "managed" });
ok("mcp_ref managed (was user)", set.mode === "managed" && set.previous_mode === "user");
const listed = (await value("list_tables")).tables.find((t) => t.name === "mcp_ref");
ok("list_tables shows it managed with its rows", listed.mode === "managed" && listed.rows === 2);
ok("status counts it", (await value("status")).synced_tables.managed === 1);
const users = await call("set_table_mode", { table: "users", mode: "managed" });
ok("users cannot be managed", !users.ok && /locked/.test(users.error));
const infra = await call("set_table_mode", { table: "_ss_peers", mode: "managed" });
ok("an infrastructure table has no data mode", !infra.ok && /infrastructure/.test(infra.error));
const missing = await call("set_table_mode", { table: "mcp_no_such_table", mode: "starter" });
ok("an unknown table is refused", !missing.ok && /no such table/.test(missing.error));
const invalid = await call("set_table_mode", { table: "mcp_ref", mode: "bogus" });
ok("an invalid mode is refused by the operation", !invalid.ok && /invalid data mode/.test(invalid.error));
let direct = "";
try {
await rowsLib.setDataMode("mcp_ref", "bogus");
} catch (e) {
direct = e.message;
}
ok("rows.setDataMode itself refuses an invalid mode (was stored verbatim)", /invalid data mode/.test(direct) && (await rowsLib.dataModeOf("mcp_ref")) === "managed");
});
};
const peerTools = async (mock) => {
section("peers (admin): a generated secret is returned once, never again");
await step("add_peer generates; list never shows secrets; bad input refused", async () => {
const added = await value("add_peer", { env_id: mock.envId, base_url: mock.base, label: "mock" });
ok("generated shared secret returned once (64 hex) with this site's env_id", /^[0-9a-f]{64}$/.test(added.shared_secret || "") && added.this_env_id === (await env.getEnv()).env_id && added.peer.label === "mock");
ok("it is the sealed secret", (await peers.peerSecret(added.peer.peer_id)).toString("hex") === added.shared_secret);
mock.secret = Buffer.from(added.shared_secret, "hex");
mock.peerId = added.peer.peer_id;
const lp = await value("list_peers");
ok("list_peers lists it without any secret", lp.peers.length === 1 && lp.peers[0].env_id === mock.envId && !("shared_secret" in lp.peers[0]));
const dup = await call("add_peer", { env_id: mock.envId, base_url: mock.base });
ok("a duplicate env_id is refused", !dup.ok && /already exists/.test(dup.error));
const scheme = await call("add_peer", { env_id: randomUuid(), base_url: "file:///etc/passwd" });
ok("a non-http(s) base_url is refused", !scheme.ok && /http or https/.test(scheme.error));
const hex = await call("add_peer", { env_id: randomUuid(), base_url: mock.base, existing_secret: "zz" });
ok("a malformed secret is refused", !hex.ok && /64 hex/.test(hex.error));
});
await step("add_peer with an existing secret returns none; labels resolve, ambiguity refused", async () => {
const pasted = protocol.randomSecret().toString("hex");
knownSecrets.add(pasted);
const spare = await value("add_peer", { env_id: randomUuid(), base_url: "http://localhost:3999", label: "spare", existing_secret: pasted });
ok("no shared_secret when the secret was given", !("shared_secret" in spare) && (await peers.peerSecret(spare.peer.peer_id)).toString("hex") === pasted);
const twin = await value("add_peer", { env_id: randomUuid(), base_url: "http://localhost:3998", label: "spare" });
const amb = await call("peer_health", { peer: "spare" });
ok("an ambiguous label is refused", !amb.ok && /several peers/.test(amb.error));
const gone = await value("delete_peer", { peer: twin.peer.env_id });
ok("delete_peer by env_id", gone.deleted.env_id === twin.peer.env_id && gone.plans_kept_for_rollback === 0);
const unknown = await call("peer_health", { peer: "nobody" });
ok("an unknown peer is a tool error", !unknown.ok && /unknown peer/.test(unknown.error));
});
await step("peer_health over signed HTTP; rotate breaks the pairing, pasting the peer's secret restores it", async () => {
const h = await value("peer_health", { peer: "mock" });
ok("reachable: version, env_id, parity warning", h.reachable && h.version === PLUGIN_VERSION && h.env_id === mock.envId && h.parity_warnings.some((w) => w.includes(MOCK_ONLY_PLUGIN)), JSON.stringify(h));
ok("by id and by env_id too", (await value("peer_health", { peer: mock.peerId })).reachable && (await value("peer_health", { peer: mock.envId })).reachable);
const rotated = await value("rotate_peer_secret", { peer: "mock" });
ok("rotate generates a new secret, returned once", /^[0-9a-f]{64}$/.test(rotated.shared_secret || "") && rotated.shared_secret !== mock.secret.toString("hex"));
const broken = await value("peer_health", { peer: "mock" });
ok("the peer (old secret) now refuses: unreachable with the reason", broken.reachable === false && /401/.test(broken.error), broken.error);
const replaced = await value("rotate_peer_secret", { peer: mock.envId, secret: mock.secret.toString("hex") });
ok("installing the peer's secret returns no secret", !("shared_secret" in replaced));
ok("the pairing works again", (await value("peer_health", { peer: "mock" })).reachable);
});
};
// The source site: this site plus a config change, a trigger whose configuration
// holds a password, a page, a code page and the table mcp_new; minus mcp_doomed
// and mcp_old. Its managed rows: a changed (to a credential-looking value), b
// gone, c new. Built for real, captured, then reverted here.
const buildSource = async (mock) => {
const prevName = getState().getConfig("site_name") || "";
const prevCode = getState().getConfig("function_code_pages", {});
await getState().setConfig("site_name", "MCP Source");
await Trigger.create({ name: "mcp_hook", action: "run_js_code", when_trigger: "Never", description: "", configuration: { code: "return 1;", password: TRIGGER_SECRET } });
await Page.create({ name: "mcp_page", title: "MCP page", description: "", min_role: 1, layout: { type: "blank", contents: "hello" }, fixed_states: {} });
await getState().setConfig("function_code_pages", { ...prevCode, mcp_cp: `const ${CODE_MARK} = 1;` });
await mkTable("mcp_new", ["legacy_code"]);
const cap = await capture.buildDesiredState();
const pack = cap.pack;
pack.tables = pack.tables.filter((t) => t.name !== "mcp_doomed" && t.name !== "mcp_old");
await Table.findOne({ name: "mcp_new" }).delete();
await getState().setConfig("function_code_pages", prevCode);
await (await Page.findOne({ name: "mcp_page" })).delete();
await (await Trigger.findOne({ name: "mcp_hook" })).delete();
await getState().setConfig("site_name", prevName);
const { manifest } = await rowsLib.buildRowManifest();
const rows = manifest.mcp_ref.rows.filter((r) => r.name !== "b").map((r) => (r.name === "a" ? { ...r, api_token: SOURCE_ROW_SECRET } : r));
rows.push({ name: "c", api_token: "tok-c", __uuid: randomUuid() });
mock.state = { hash: capture.hashPack(pack), pack, rows: { ...manifest, mcp_ref: { ...manifest.mcp_ref, rows } } };
return prevName;
};
// The before-apply state of this site (what rollback must bring back).
const assertBefore = async (label, siteName) => {
ok(`${label}: config, trigger, page, code page as before`,
(getState().getConfig("site_name") || "") === siteName && !Trigger.findOne({ name: "mcp_hook" }) && !(await Page.findOne({ name: "mcp_page" })) &&
!("mcp_cp" in getState().getConfig("function_code_pages", {})));
ok(`${label}: mcp_doomed back with its rows, mcp_old back, mcp_new gone`,
JSON.stringify(await columnOf("mcp_doomed", "note")) === JSON.stringify(["row 1", "row 2"]) && !!Table.findOne({ name: "mcp_old" }) && !Table.findOne({ name: "mcp_new" }));
ok(`${label}: managed rows as before`, JSON.stringify(await columnOf("mcp_ref", "api_token")) === JSON.stringify(["tok-a", "tok-b"]), (await columnOf("mcp_ref", "api_token")).join(","));
};
const assertApplied = async (label) => {
ok(`${label}: config, trigger, page, code page installed`,
getState().getConfig("site_name") === "MCP Source" && !!Trigger.findOne({ name: "mcp_hook" }) && !!(await Page.findOne({ name: "mcp_page" })) &&
"mcp_cp" in getState().getConfig("function_code_pages", {}));
ok(`${label}: mcp_doomed dropped (named in prunable), mcp_old -> mcp_new`, !Table.findOne({ name: "mcp_doomed" }) && !Table.findOne({ name: "mcp_old" }) && !!Table.findOne({ name: "mcp_new" }));
ok(`${label}: managed rows mirrored (a changed, b removed, c added)`, JSON.stringify(await columnOf("mcp_ref", "api_token")) === JSON.stringify(["tok-c", SOURCE_ROW_SECRET].sort()), (await columnOf("mcp_ref", "api_token")).join(","));
};
const APPLY_GATES = { prunable: ["mcp_doomed"], confirm_data_drops: true, confirm_renames: ["mcp_old"] };
const pullReviewApplyRollback = async (mock) => {
let planId;
let siteName;
section("plan_pull (write) from the signed mock peer; trimmed + redacted views");
await step("pull records a plan and returns its trimmed view", async () => {
siteName = await buildSource(mock);
const n0 = await planCount();
mock.calls = [];
const v = await value("plan_pull", { peer: "mock" });
planId = v.plan_id;
ok("one plan recorded; health + state pulled over signed HTTP", (await planCount()) === n0 + 1 && mock.calls.includes(API_PATHS.HEALTH) && mock.calls.includes(API_PATHS.STATE));
ok("standing: pending, appliable, not stale, no restore point", v.status === "pending" && v.can_apply === true && v.stale === false && v.has_restore_point === false && v.can_rollback === false && v.in_sync === false);
ok("peer and direction", v.direction === "pull" && v.peer.label === "mock" && v.source_env_id === mock.envId);
ok("names per kind", v.changes.triggers.create.includes("mcp_hook") && v.changes.pages.create.includes("mcp_page") && v.changes.code_pages.create.includes("mcp_cp"), JSON.stringify(v.changes));
ok("the data-bearing drop and the gates it needs", v.changes.tables.delete.some((d) => d.name === "mcp_doomed" && d.row_count === 2) &&
JSON.stringify(v.data_drops.tables) === JSON.stringify(["mcp_doomed"]) && v.data_drops.confirm_data_drops_needed === true && v.data_drops.refused_by_policy === false &&
v.destructive.some((d) => d.data_bearing && /mcp_doomed/.test(d.text)));
ok("the rename candidate", v.rename_candidates.length === 1 && v.rename_candidates[0].from === "mcp_old" && v.rename_candidates[0].to === "mcp_new");
ok("config change, row counts", v.config_changes.includes("site_name") && JSON.stringify(v.rows.mcp_ref) === JSON.stringify({ mode: "managed", inserts: 1, updates: 1, deletes: 1 }), JSON.stringify(v.rows));
ok("warnings: code page (server-side JS) and plugin parity", v.warnings.some((w) => /code page/.test(w) && w.includes("mcp_cp")) && v.warnings.some((w) => w.includes(MOCK_ONLY_PLUGIN)));
const text = JSON.stringify(v);
ok("trimmed: no definitions, no source rows, no credentials", !text.includes(TRIGGER_SECRET) && !text.includes(SOURCE_ROW_SECRET) && !text.includes(CODE_MARK) && !/sourceRows|desired_pack|"desired"/.test(text));
});
await step("get_plan with entity: one redacted entity at a time", async () => {
const trig = await value("get_plan", { plan_id: planId, entity: { kind: "triggers", name: "mcp_hook" } });
ok("a trigger create: the source's definition, password masked", trig.action === "create" && trig.to.name === "mcp_hook" && trig.current === null &&
trig.to.configuration.password === REDACTED && !JSON.stringify(trig).includes(TRIGGER_SECRET), JSON.stringify(trig.to.configuration));
const cfg = await value("get_plan", { plan_id: planId, entity: { kind: "config", name: "site_name" } });
const here = (await capture.buildDesiredState()).pack.config;
ok("a config setting: to and current", cfg.to === "MCP Source" && cfg.current === (here.site_name === undefined ? null : here.site_name), JSON.stringify(cfg));
const drop = await value("get_plan", { plan_id: planId, entity: { kind: "tables", name: "mcp_doomed" } });
ok("a table drop: row count and the current table", drop.action === "delete" && drop.row_count === 2 && drop.current.name === "mcp_doomed" && drop.to === null);
const ren = await value("get_plan", { plan_id: planId, entity: { kind: "tables", name: "mcp_old" } });
ok("a rename: from, and the source's table", ren.action === "rename" && ren.from === "mcp_old" && ren.to.name === "mcp_new");
const cp = await value("get_plan", { plan_id: planId, entity: { kind: "code_pages", name: "mcp_cp" } });
ok("a code page: its code, for review", cp.action === "create" && cp.to.code.includes(CODE_MARK));
const nope = await call("get_plan", { plan_id: planId, entity: { kind: "views", name: "no_such_view" } });
ok("an entity the plan does not change is a tool error", !nope.ok && /does not change views/.test(nope.error));
const unknown = await call("get_plan", { plan_id: "no-such-plan" });
ok("an unknown plan is a tool error", !unknown.ok && /unknown plan/.test(unknown.error));
const listed = (await value("list_plans")).plans[0];
ok("list_plans: newest first, with peer and standing", listed.plan_id === planId && listed.status === "pending" && listed.peer === "mock" && listed.can_apply && !listed.has_restore_point);
});
await step("read tools changed nothing", async () => {
const ref = Table.findOne({ name: "mcp_ref" });
const id = await ref.insertRow({ name: "d", api_token: "tok-d" });
const h0 = (await capture.buildDesiredState()).hash;
const n0 = await planCount();
for (const [name, args] of [["status"], ["list_peers"], ["list_tables"], ["list_plans"], ["get_plan", { plan_id: planId }], ["local_state"]]) {
await value(name, args);
}
const rowD = (await ref.getRows({ id }, {}))[0];
ok("same desired state, same plans", (await capture.buildDesiredState()).hash === h0 && (await planCount()) === n0);
ok("local_state did not backfill a row identity (read-only)", !rowD._ss_row_uuid);
await ref.deleteRows({ id });
});
section("one apply or rollback per site at a time");
await step("an apply / a rollback while another holds the site lock is refused, then allowed", async () => {
const lease = await siteLock.acquire({ operation: "apply", planId: "held-elsewhere" });
let held = true;
try {
ok("status: apply_lock running, naming the holder", (await value("status")).apply_lock.holder.plan_id === "held-elsewhere");
const second = await call("apply_plan", { plan_id: planId });
const rb = await call("rollback", { plan_id: planId });
ok("a concurrent apply is refused, naming what runs", !second.ok && /another apply is running on this site \(plan held-elsewhere,/.test(second.error), second.error);
ok("without pointing at a rollback (nothing changed)", !second.ok && !/sitesync_rollback/.test(second.error));
ok("a concurrent rollback is refused", !rb.ok && /another apply is running on this site/.test(rb.error), rb.error);
await lease.release();
held = false;
ok("status: apply_lock idle again", (await value("status")).apply_lock.state === "idle");
const free = await call("apply_plan", { plan_id: "no-such-plan" });
ok("free again afterwards (the next refusal is about the plan, not the lock)", !free.ok && /unknown plan/.test(free.error), free.error);
} finally {
if (held) {
await lease.release();
}
}
});
section("drift: a plan whose target changed is stale and refused");
await step("stale flag + apply refusal, then fresh again", async () => {
await getState().setConfig("site_name", "drifted meanwhile");
const v = await value("get_plan", { plan_id: planId });
ok("get_plan: stale, not appliable", v.stale === true && v.can_apply === false);
const refused = await call("apply_plan", { plan_id: planId, ...APPLY_GATES });
ok("apply refused by the drift check, no restore point offered", !refused.ok && /changed since this plan was previewed/.test(refused.error) && !/restore point/.test(refused.error), refused.error);
await getState().setConfig("site_name", siteName);
ok("fresh again once the drift is undone", (await value("get_plan", { plan_id: planId })).stale === false);
});
section("apply_plan (admin) -> rollback (admin)");
await step("apply with the typed gates", async () => {
const r = await value("apply_plan", { plan_id: planId, ...APPLY_GATES });
ok("applied; rollback offered; no credentials in the result", r.applied && r.can_rollback && r.deletes_skipped.length === 0 && !JSON.stringify(r).includes(SOURCE_ROW_SECRET) && !JSON.stringify(r).includes(TRIGGER_SECRET), JSON.stringify(r.deletes_skipped));
await assertApplied("after apply");
const listed = (await value("list_plans", { status: "applied" })).plans.find((p) => p.plan_id === planId);
ok("list_plans: applied, restore point, can roll back", !!listed && listed.has_restore_point && listed.can_rollback && !listed.can_apply && listed.stale === null);
const again = await call("apply_plan", { plan_id: planId });
ok("applying it again is refused, pointing at the rollback", !again.ok && /cannot be applied: it is applied/.test(again.error) && /sitesync_rollback/.test(again.error));
const repull = await value("plan_pull", { peer: "mock" });
ok("a fresh pull is in sync (converged)", repull.in_sync === true, JSON.stringify(repull.summary));
});
await step("rollback restores the pre-apply state, once", async () => {
const r = await value("rollback", { plan_id: planId });
ok("rolled back", r.rolled_back === true && r.plan_id === planId);
await assertBefore("after rollback", siteName);
const again = await call("rollback", { plan_id: planId });
ok("a second rollback is refused", !again.ok && /cannot be rolled back/.test(again.error));
ok("list_plans: rolled_back", (await value("list_plans", { status: "rolled_back" })).plans.some((p) => p.plan_id === planId));
});
return { firstPlanId: planId, siteName };
};
// Retention keeps the newest PLAN_RETENTION plans -- but never one that still has a
// restore point (a burst of pulls must not cost a recent apply its rollback).
const retention = async (firstPlanId) => {
section("plan retention keeps plans that have a restore point");
await step("a burst of newer plans prunes the oldest, not a plan with a restore point", async () => {
const schema = db.getTenantSchemaPrefix();
const since = Date.parse((await planLib.loadPlan(firstPlanId)).created_at);
const span = Date.now() - since;
const burst = planLib.PLAN_RETENTION + 5;
for (let i = 1; i <= burst; i++) {
await db.query(
`INSERT INTO ${schema}_ss_plans (plan_id, peer_id, source_env_id, direction, desired_pack, plan_json, precondition_hash, source_hash, status, created_at) VALUES ($1,NULL,'burst','pull','{}','{}','h','h','pending',$2)`,
[`burst-${i}`, new Date(since + Math.floor((span * i) / (burst + 1))).toISOString()]
);
}
await value("plan_pull", { peer: "mock" });
ok("the oldest burst plans were pruned", !(await planLib.loadPlan("burst-1")) && !!(await planLib.loadPlan(`burst-${burst}`)));
ok("the older plan with a restore point was kept", !!(await planLib.loadPlan(firstPlanId)));
await db.query(`DELETE FROM ${schema}_ss_plans WHERE source_env_id = 'burst'`);
});
};
const unpairKeepsRollback = async (mock, siteName) => {
section("delete_peer (admin) keeps applied plans rollbackable");
await step("apply, unpair, roll back without the peer", async () => {
const applied = await value("plan_pull", { peer: "mock" });
await value("apply_plan", { plan_id: applied.plan_id, ...APPLY_GATES });
await assertApplied("second apply");
const pending = await value("plan_pull", { peer: "mock" });
const del = await value("delete_peer", { peer: "mock" });
ok("one applied plan kept for rollback", del.deleted.env_id === mock.envId && del.plans_kept_for_rollback === 1);
ok("the peer's other plans went with it", !(await planLib.loadPlan(pending.plan_id)));
const kept = (await value("list_plans")).plans.find((p) => p.plan_id === applied.plan_id);
ok("the kept plan belongs to no peer and can still roll back", !!kept && kept.peer === null && kept.can_rollback);
ok("no peer of this pairing is left", !(await value("list_peers")).peers.some((p) => p.env_id === mock.envId));
ok("rollback works after unpairing", (await value("rollback", { plan_id: applied.plan_id })).rolled_back === true);
await assertBefore("after unpaired rollback", siteName);
});
};
const main = async () => {
console.log(`DB = ${db.isSQLite ? process.env.SQLITE_FILEPATH : `postgres ${db.connectObj.database}`}${HAS_MCP ? "" : " (saltcorn-mcp absent: fallback schema/redaction checks)"}`);
await bootstrapPlugin();
const mock = { envId: randomUuid(), secret: null, calls: [], state: null, plugins: (await localPluginList()).concat([{ name: MOCK_ONLY_PLUGIN, source: "npm", version: "1.0.0" }]) };
await startMockPeer(mock);
try {
await contract();
await readTools();
await settings();
await tableModes();
await peerTools(mock);
const { firstPlanId, siteName } = await pullReviewApplyRollback(mock);
await retention(firstPlanId);
await unpairKeepsRollback(mock, siteName);
section("secrets");
ok("no tool result carried a peer secret except the one it generated", leaks.length === 0, leaks.join("; "));
} finally {
mock.server.close();
}
process.exit(summary());
};
main().catch((err) => {
console.error(err && err.stack ? err.stack : err);
process.exit(1);
});

177
test/pgGate.js Normal file
View file

@ -0,0 +1,177 @@
// Multi-tenant ISOLATION gate (Postgres only). Creates two real Saltcorn tenants in
// the throwaway database runPg.sh provisions, installs site-sync in each, and proves
// nothing crosses the tenant boundary: identity, peers, plans, nonces, captured
// design state, the file index / file store, the admin gate, and HMAC host binding.
//
// ./test/runPg.sh pgGate.js
//
// Tenant setup follows upstream's own tenant test (saltcorn-admin-models
// tests/tenant.test.ts): add_tenant -> insertTenant -> switchToTenant ->
// create_tenant, then db.runWithTenant for every tenant-scoped call. Multi-tenant
// mode itself comes from SALTCORN_MULTI_TENANT=true (set by runPg.sh).
const db = require("@saltcorn/data/db");
const Plugin = require("@saltcorn/data/models/plugin");
const Table = require("@saltcorn/data/models/table");
const Field = require("@saltcorn/data/models/field");
const File = require("@saltcorn/data/models/file");
const { add_tenant, getState } = require("@saltcorn/data/db/state");
const { create_tenant, switchToTenant, insertTenant } = require("@saltcorn/admin-models/models/tenant");
const {
ok, section, step, summary,
installPlugin, bootstrapPlugin, mkRes, mkAdminReq, mkPeerReq, routeOf
} = require("./harness");
const protocol = require("../lib/common/peerProtocol");
const peers = require("../lib/peers");
const env = require("../lib/env");
const routes = require("../lib/routes");
const capture = require("../lib/capture");
const planLib = require("../lib/plan");
const rowsLib = require("../lib/rows");
const files = require("../lib/common/fileStore");
const { requirePeerAuth } = require("../lib/peerAuth");
const { API_PATHS } = require("../lib/transport");
const { sha256Hex } = require("../lib/common/hash");
const { randomUuid } = require("../lib/ids");
const T1 = "sst1";
const T2 = "sst2";
const HOST = (t) => `${t}.example.com`;
const inT = (t, fn) => db.runWithTenant(t, fn);
const rawTableIn = async (schema, name) => {
const rs = await db.query(`SELECT 1 FROM information_schema.tables WHERE table_schema=$1 AND table_name=$2`, [schema, name]);
return rs.rows.length === 1;
};
const createTenant = async (t) => {
add_tenant(t);
await switchToTenant(await insertTenant(t, `admin@${t}.local`, "site-sync isolation gate"), `http://${HOST(t)}/`);
await create_tenant({ t, loadAndSaveNewPlugin() {}, plugin_loader() {} });
};
// Install site-sync into a tenant. It is installed by location (source "local"),
// which Saltcorn loads on a tenant only when the ROOT allows non-store plugins; and a
// tenant's loadAllPlugins reloads the root state's config cache, so the root flag is
// (re-)asserted in root context right before the install.
const bootstrapTenant = (t) => inT(t, async () => {
await Plugin.loadAllPlugins();
await inT(db.connectObj.default_schema, () => getState().setConfig("tenants_unsafe_plugins", true));
await installPlugin();
});
const main = async () => {
if (db.isSQLite || !db.is_it_multi_tenant()) {
console.log("pgGate needs Postgres in multi-tenant mode (SALTCORN_MULTI_TENANT=true); run it via ./test/runPg.sh pgGate.js");
process.exit(1);
}
await bootstrapPlugin();
await createTenant(T1);
await createTenant(T2);
await bootstrapTenant(T1);
await bootstrapTenant(T2);
section("identity + raw tables are per tenant");
const e1 = await inT(T1, () => env.getEnv());
const e2 = await inT(T2, () => env.getEnv());
ok("each tenant has its own env_id", !!e1 && !!e2 && e1.env_id !== e2.env_id);
for (const t of [T1, T2]) {
for (const name of ["_ss_seen_nonces", "_ss_plans", "_ss_rollback", "_ss_peers", "_ss_table_modes"]) {
ok(`${t}: ${name} lives in schema ${t}`, await rawTableIn(t, name));
}
}
await inT(T1, () => env.setEnvFields({ env_label: "only-t1" }));
ok("settings saved in t1 do not touch t2", (await inT(T2, () => env.getEnv())).env_label !== "only-t1");
section("peers, plans and captured state never cross tenants");
const sharedEnv = randomUuid();
const sharedSecret = protocol.randomSecret();
await inT(T1, () => peers.addPeer({ envId: sharedEnv, label: "shared", baseUrl: "http://src.example.com", existingSecret: sharedSecret }));
ok("a peer paired in t1 is invisible in t2", (await inT(T2, () => peers.findPeerByEnvId(sharedEnv))) === null);
await inT(T2, async () => {
const t = await Table.create("only_in_t2", { min_role_read: 1, min_role_write: 1 });
await Field.create({ table: t, name: "v", label: "v", type: "String" });
});
const cap1 = await inT(T1, () => capture.buildDesiredState());
const cap2 = await inT(T2, () => capture.buildDesiredState());
ok("t1's desired state does not contain t2's table", !cap1.pack.tables.some((x) => x.name === "only_in_t2"));
ok("t2's desired state does contain it", cap2.pack.tables.some((x) => x.name === "only_in_t2"));
const built = await inT(T1, async () => planLib.buildPlan({ peerRow: null, env: await env.getEnv(), desired: cap1.pack, desiredHash: cap1.hash, sourceEnvId: "src" }));
ok("a plan built in t1 loads in t1", !!(await inT(T1, () => planLib.loadPlan(built.plan_id))));
ok("a plan built in t1 is unknown in t2", (await inT(T2, () => planLib.loadPlan(built.plan_id))) === null);
section("admin gate: a session admin of one tenant is not admin of another");
await step("t1's admin session is rejected by t2's site-sync admin routes", async () => {
const t1Admin = { role_id: 1, tenant: T1, email: `admin@${T1}.local` };
let res = mkRes();
await inT(T1, () => routeOf(routes.routes, "get", "/admin/site-sync/")(mkAdminReq({ user: t1Admin }), res));
ok("t1 admin on t1 -> dashboard rendered", res.statusCode === 200 && !!res._wrap);
res = mkRes();
await inT(T2, () => routeOf(routes.routes, "get", "/admin/site-sync/")(mkAdminReq({ user: t1Admin }), res));
ok("t1 admin on t2 -> 403", res.statusCode === 403 && !res._wrap, `status=${res.statusCode}`);
});
section("HMAC host binding + per-tenant replay store");
await step("a request signed for t1 cannot be replayed at t2, even with a spoofed X-Forwarded-Host", async () => {
// Worst case: the operator re-used ONE env_id + secret for both tenants.
await inT(T2, () => peers.addPeer({ envId: sharedEnv, label: "shared", baseUrl: "http://src.example.com", existingSecret: sharedSecret }));
const nonce = protocol.randomNonce().toString("hex");
const ts = String(Date.now());
const signed = () => mkPeerReq({ method: "GET", path: API_PATHS.HEALTH, host: HOST(T1), envId: sharedEnv, secret: sharedSecret,
override: { nonce, timestamp: ts } });
let res = mkRes();
ok("accepted at t1 (its own host)", !!(await inT(T1, () => requirePeerAuth(signed(), res))), `status=${res.statusCode}`);
const atT2 = signed();
atT2.headers.host = HOST(T2);
atT2.host = HOST(T2);
res = mkRes();
ok("replayed at t2 (Host t2) -> 401", (await inT(T2, () => requirePeerAuth(atT2, res))) === null && res.statusCode === 401);
const spoof = signed();
spoof.headers.host = HOST(T2);
spoof.host = HOST(T2);
spoof.headers["x-forwarded-host"] = HOST(T1);
res = mkRes();
ok("replayed at t2 with XFH=t1 -> 401", (await inT(T2, () => requirePeerAuth(spoof, res))) === null && res.statusCode === 401);
const seenIn = async (t) => (await db.query(`SELECT 1 FROM "${t}"._ss_seen_nonces WHERE nonce = $1`, [nonce])).rows.length;
ok("the nonce is recorded in t1's store only", (await seenIn(T1)) === 1 && (await seenIn(T2)) === 0);
});
section("file index + file store are per tenant");
await step("t1's synced file is not resolvable, nor reachable by path, from t2", async () => {
const bytes = Buffer.from(`t1 private file ${randomUuid()}`);
const hash = sha256Hex(bytes);
const fileValue = await inT(T1, async () => {
const t = await Table.create("t1docs", { min_role_read: 1, min_role_write: 1 });
await Field.create({ table: t, name: "f", label: "f", type: "File" });
const f = await File.from_contents("secret.txt", "text/plain", bytes, 1, 1, "/");
await Table.findOne({ name: "t1docs" }).insertRow({ f: f.field_value });
await rowsLib.setDataMode("t1docs", "managed");
return f.field_value;
});
ok("t1 resolves its own file by hash", (await inT(T1, () => rowsLib.resolveFileByHash(hash))) === fileValue);
ok("t2 cannot resolve t1's hash", (await inT(T2, () => rowsLib.resolveFileByHash(hash))) === null);
let refused = false;
await inT(T2, async () => {
try {
files.toAbsolutePath(db, `../${T1}/${fileValue}`);
} catch (e) {
refused = true;
}
});
ok("t2 refuses a path into t1's file store", refused);
});
process.exit(summary());
};
main().catch((err) => {
console.error(err && err.stack ? err.stack : err);
process.exit(1);
});

272
test/registeredTableTest.js Normal file
View file

@ -0,0 +1,272 @@
// Regression suite for lib/common/registeredTable.js against REAL Saltcorn
// (@saltcorn/data from the upstream checkout) on a THROWAWAY database: a temp
// SQLite file by default, or with RT_TEST_PG=1 a Postgres database rt_<pid>
// (createdb/dropdb over the local socket) -- never a shared instance's database.
//
// node test/registeredTableTest.js
// RT_TEST_PG=1 node test/registeredTableTest.js
//
// Covers: an unregistered orphan table (empty -> replaced; with rows ->
// adopted, rows and ids kept), an already-registered table (no-op, also with a
// stale cache), healing a damaged registration, merging a renamed-aside legacy
// copy by key, row transforms, rollback of a failed adoption, site-sync's real
// binding (createAllTables adopting _ss_table_modes), and WORKERS processes
// racing to register the same table. Exit code is 0 only if every check passed.
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const Module = require("node:module");
const { execFileSync, spawn } = require("node:child_process");
// The project root: the upstream Saltcorn checkout is ROOT/saltcorn.
const ROOT = path.resolve(__dirname, "..", "..");
const COMMON_DIR = path.resolve(__dirname, "..", "lib", "common");
const WORKER_FLAG = "--worker";
const IS_WORKER = process.argv.includes(WORKER_FLAG);
const USE_PG = process.env.RT_TEST_PG === "1";
const PG_HOST = "/var/run/postgresql";
const WORKERS = 3;
const OWNER = "registeredTableTest";
const RACE_TABLE = "_rt_race";
const RACE_ROWS = 3;
// Fields + the auto id primary key.
const FIELDS = [
{ name: "key", type: "String", is_unique: true, required: true },
{ name: "label", type: "String" },
{ name: "n", type: "Integer" }
];
const FIELD_COUNT_WITH_ID = FIELDS.length + 1;
// The parent creates the throwaway database and passes it to the workers.
if (!IS_WORKER) {
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "rt-unit-"));
process.env.RT_STATE_DIR = stateDir;
if (USE_PG) {
delete process.env.SQLITE_FILEPATH;
delete process.env.FORCE_SQLITE;
delete process.env.SALTCORN_MULTI_TENANT;
process.env.PGHOST = PG_HOST;
process.env.PGUSER = process.env.PGUSER || os.userInfo().username;
process.env.PGPASSWORD = process.env.PGPASSWORD || "unused-peer-auth";
process.env.PGDATABASE = `rt_${process.pid}`;
execFileSync("createdb", ["-h", PG_HOST, process.env.PGDATABASE]);
} else {
process.env.FORCE_SQLITE = "true";
process.env.SQLITE_FILEPATH = path.join(stateDir, "rt.sqlite");
}
process.env.SALTCORN_FILE_STORE = path.join(stateDir, "files");
process.env.SALTCORN_SESSION_SECRET = "registered-table-test-FAKE-secret";
}
process.env.NODE_PATH = [path.join(ROOT, "saltcorn", "node_modules"), process.env.NODE_PATH].filter(Boolean).join(path.delimiter);
Module._initPaths();
const { ensureRegisteredTable, physicalTableExists, tableRef } = require(path.join(COMMON_DIR, "registeredTable.js"));
let passed = 0;
let failed = 0;
const check = async (name, fn) => {
try {
await fn();
passed++;
console.log(` PASS ${name}`);
} catch (e) {
failed++;
console.log(` FAIL ${name}\n ${e && e.message ? e.message.split("\n")[0] : e}`);
}
};
const bootState = async () => {
const { getState } = require("@saltcorn/data/db/state");
getState().registerPlugin("base", require("@saltcorn/data/base-plugin"));
await getState().refresh();
};
const spec = (name, extra) => ({ owner: OWNER, name: name, fields: FIELDS, ...(extra || {}) });
// A raw (unregistered) table, the shape an orphan / legacy table has.
const createRaw = async (name, columnsSql) => {
const db = require("@saltcorn/data/db");
const idCol = db.isSQLite ? "id INTEGER PRIMARY KEY" : "id SERIAL PRIMARY KEY";
await db.query(`CREATE TABLE ${tableRef(name)} (${columnsSql.replace("$ID", idCol)})`);
};
const registeredRow = async (name) => {
return await require("@saltcorn/data/db").selectMaybeOne("_sc_tables", { name: name });
};
const fieldCount = async (name) => {
const db = require("@saltcorn/data/db");
const row = await registeredRow(name);
return row ? (await db.select("_sc_fields", { table_id: row.id })).length : 0;
};
const rowsOf = async (name) => {
const db = require("@saltcorn/data/db");
return (await db.query(`SELECT * FROM ${tableRef(name)} ORDER BY id`)).rows;
};
// Child: boot Saltcorn state and race to register RACE_TABLE; exit 0 on success.
const worker = async () => {
await bootState();
let code = 0;
try {
await ensureRegisteredTable(spec(RACE_TABLE, { keyField: "key" }));
} catch (e) {
console.log(` worker ${process.pid} ERROR ${e.message}`);
code = 1;
}
await require("@saltcorn/data/db").close();
process.exit(code);
};
const spawnWorker = () => {
return new Promise((resolve) => {
const p = spawn(process.execPath, [__filename, WORKER_FLAG], { env: process.env, stdio: ["ignore", "inherit", "inherit"] });
p.on("exit", (code) => resolve(code));
});
};
const main = async () => {
const db = require("@saltcorn/data/db");
console.log(`registeredTable suite -- ${USE_PG ? `postgres ${process.env.PGDATABASE}` : `sqlite ${process.env.SQLITE_FILEPATH}`}`);
await require("@saltcorn/data/db/reset_schema")(true);
await bootState();
const Table = require("@saltcorn/data/models/table");
const { getState } = require("@saltcorn/data/db/state");
console.log("\n[unregistered orphan tables]");
await check("control: plain Table.create throws on an orphan (the live bug)", async () => {
await createRaw("_rt_control", "$ID");
await assert.rejects(() => Table.create("_rt_control"), /exists/);
});
await check("an empty orphan is replaced by the registered table", async () => {
await createRaw("_rt_empty", "$ID, key TEXT");
assert.equal(await ensureRegisteredTable(spec("_rt_empty")), true);
assert.ok(await registeredRow("_rt_empty"), "not registered");
assert.equal(await fieldCount("_rt_empty"), FIELD_COUNT_WITH_ID);
assert.equal((await rowsOf("_rt_empty")).length, 0);
});
await check("an orphan with rows is adopted: rows (by column name) and ids preserved", async () => {
await createRaw("_rt_rows", "$ID, key TEXT, label TEXT, extra TEXT");
await db.query(`INSERT INTO ${tableRef("_rt_rows")} (id, key, label, extra) VALUES (5, 'a', 'A', 'x'), (7, 'b', NULL, 'y')`);
assert.equal(await ensureRegisteredTable(spec("_rt_rows")), true);
const rows = await rowsOf("_rt_rows");
assert.deepEqual(rows.map((r) => [r.id, r.key, r.label]), [[5, "a", "A"], [7, "b", null]]);
const fresh = await db.insert("_rt_rows", { key: "later" });
assert.ok(fresh > 7, `new id ${fresh} collides with an adopted one`);
});
console.log("\n[registered tables]");
await check("an already-registered table is a no-op (rows and registration untouched)", async () => {
const before = await registeredRow("_rt_rows");
const rows = await rowsOf("_rt_rows");
assert.equal(await ensureRegisteredTable(spec("_rt_rows")), false);
assert.equal((await registeredRow("_rt_rows")).id, before.id);
assert.deepEqual(await rowsOf("_rt_rows"), rows);
});
await check("... also when this process's table cache is stale", async () => {
getState().tables = getState().tables.filter((t) => t.name !== "_rt_rows");
const rows = await rowsOf("_rt_rows");
assert.equal(await ensureRegisteredTable(spec("_rt_rows")), false);
assert.deepEqual(await rowsOf("_rt_rows"), rows);
await getState().refresh_tables(true);
});
await check("a damaged registration is healed (lost column re-added, new field registered)", async () => {
await db.query(`ALTER TABLE ${tableRef("_rt_rows")} DROP COLUMN "label"`);
const more = FIELDS.concat([{ name: "added", type: "String" }]);
assert.equal(await ensureRegisteredTable({ ...spec("_rt_rows"), fields: more }), false);
const cols = Object.keys((await rowsOf("_rt_rows"))[0]);
assert.ok(cols.includes("label") && cols.includes("added"), `columns now: ${cols}`);
assert.equal(await fieldCount("_rt_rows"), FIELD_COUNT_WITH_ID + 1);
});
console.log("\n[legacy copies, transforms, failures]");
await check("a renamed-aside legacy copy is merged by keyField, then dropped", async () => {
await db.query(`CREATE TABLE ${tableRef("_rt_rows__legacy")} (key TEXT, label TEXT)`);
await db.query(`INSERT INTO ${tableRef("_rt_rows__legacy")} (key, label) VALUES ('a', 'dup'), ('c', 'C')`);
assert.equal(await ensureRegisteredTable(spec("_rt_rows", { keyField: "key", legacyTables: ["_rt_rows__legacy"] })), false);
const rows = await rowsOf("_rt_rows");
const aRows = rows.filter((r) => r.key === "a");
assert.equal(aRows.length, 1, "a row present in both was duplicated");
assert.notEqual(aRows[0].label, "dup", "an existing row was overwritten");
assert.equal(rows.find((r) => r.key === "c").label, "C", "the legacy-only row was not adopted");
assert.ok(!(await physicalTableExists("_rt_rows__legacy")), "legacy copy left behind");
});
await check("rowTransform reshapes adopted rows (null drops one)", async () => {
await createRaw("_rt_xform", "$ID, key TEXT, legacy_n INTEGER");
await db.query(`INSERT INTO ${tableRef("_rt_xform")} (key, legacy_n) VALUES ('keep', 4), ('drop', 9)`);
const rowTransform = async (r) => (r.key === "drop" ? null : { key: r.key, n: r.legacy_n * 10 });
await ensureRegisteredTable(spec("_rt_xform", { rowTransform: rowTransform }));
assert.deepEqual((await rowsOf("_rt_xform")).map((r) => [r.key, r.n]), [["keep", 40]]);
});
await check("an adoption that cannot complete rolls back: orphan intact, nothing registered", async () => {
await createRaw("_rt_fail", "$ID, key TEXT");
await db.query(`INSERT INTO ${tableRef("_rt_fail")} (key) VALUES ('d'), ('d')`);
await assert.rejects(() => ensureRegisteredTable(spec("_rt_fail")), /could not register table _rt_fail/);
assert.ok(!(await registeredRow("_rt_fail")), "registered after a failed adoption");
assert.equal((await rowsOf("_rt_fail")).length, 2, "orphan rows lost");
});
const siteSyncSchema = path.join(__dirname, "..", "lib", "schema.js");
console.log("\n[site-sync binding]");
await check("createAllTables adopts an unregistered _ss_table_modes with its row", async () => {
await createRaw("_ss_table_modes", "$ID, table_name TEXT, data_mode TEXT, updated_at TEXT");
await db.query(`INSERT INTO ${tableRef("_ss_table_modes")} (id, table_name, data_mode, updated_at) VALUES (4, 'refdata', 'managed', 'x')`);
await require(siteSyncSchema).createAllTables();
assert.ok(await registeredRow("_ss_table_modes"), "not registered");
assert.deepEqual((await rowsOf("_ss_table_modes")).map((r) => [r.id, r.table_name, r.data_mode]), [[4, "refdata", "managed"]]);
await require(siteSyncSchema).createAllTables();
assert.equal((await rowsOf("_ss_table_modes")).length, 1, "a re-run duplicated the row");
});
console.log(`\n[${WORKERS} processes racing to register an orphan with rows]`);
await check(`${WORKERS} racing workers all succeed; registered once, rows adopted once`, async () => {
await createRaw(RACE_TABLE, "$ID, key TEXT, label TEXT");
for (let i = 1; i <= RACE_ROWS; i++) {
await db.query(`INSERT INTO ${tableRef(RACE_TABLE)} (id, key, label) VALUES ($1, $2, 'r')`, [i * 10, `k${i}`]);
}
const codes = await Promise.all(Array.from({ length: WORKERS }, () => spawnWorker()));
assert.ok(codes.every((c) => c === 0), `exit codes ${codes.join(",")}`);
const registrations = (await db.query("SELECT COUNT(*) AS c FROM _sc_tables WHERE name = $1", [RACE_TABLE])).rows[0].c;
assert.equal(Number(registrations), 1, "registered more than once");
assert.equal(await fieldCount(RACE_TABLE), FIELD_COUNT_WITH_ID);
assert.deepEqual((await rowsOf(RACE_TABLE)).map((r) => r.id), [10, 20, 30]);
});
};
const cleanup = async () => {
await require("@saltcorn/data/db").close();
fs.rmSync(process.env.RT_STATE_DIR, { recursive: true, force: true });
if (USE_PG) {
execFileSync("dropdb", ["-h", PG_HOST, "--if-exists", process.env.PGDATABASE]);
}
};
if (IS_WORKER) {
worker();
} else {
main().then(async () => {
console.log(`\n${passed} passed, ${failed} failed (${USE_PG ? "postgres" : "sqlite"})`);
await cleanup();
process.exit(failed === 0 ? 0 : 1);
}).catch(async (e) => {
console.error("suite crashed:", e);
await cleanup();
process.exit(1);
});
}

34
test/run.sh Executable file
View file

@ -0,0 +1,34 @@
#!/usr/bin/env bash
# Reproducible, SELF-CONTAINED in-process test for site-sync.
# Spins up a throwaway SQLite DB in a mktemp state dir OUTSIDE the plugin tree, runs
# Saltcorn migrations, then drives the real plugin modules + route handlers via
# test/e2e.js. Touches no other instance and no shared plugins folder (see
# isolate_saltcorn_dirs in testEnv.sh).
#
# ./test/run.sh # runs test/e2e.js
# ./test/run.sh <file.js> # runs test/<file.js>
#
# @saltcorn/* are host-provided (this plugin declares no deps, per the
# dev-deploy/idp/theme-builder convention), so the harness gets them via NODE_PATH
# pointing at the upstream checkout's node_modules.
set -euo pipefail
HERE="$(cd "$(dirname "$0")" && pwd)"
PLUGIN="$(dirname "$HERE")"
REPO="$(dirname "$PLUGIN")"
SC_BIN="$REPO/saltcorn/packages/saltcorn-cli/bin/saltcorn"
TESTFILE="${1:-e2e.js}"
# shellcheck source=testEnv.sh
. "$HERE/testEnv.sh"
STATE="$(make_state_dir site-sync-test)"
trap 'rm -rf "$STATE"' EXIT
isolate_saltcorn_dirs "$STATE"
mkdir -p "$STATE/files"
export SQLITE_FILEPATH="$STATE/test.sqlite"
export SALTCORN_FILE_STORE="$STATE/files"
export SALTCORN_SESSION_SECRET="site-sync-integration-test-secret"
export SALTCORN_NWORKERS=1
export NODE_PATH="$REPO/saltcorn/node_modules"
node "$SC_BIN" reset-schema --force >/dev/null
node "$HERE/$TESTFILE"

97
test/runLivePull.sh Executable file
View file

@ -0,0 +1,97 @@
#!/usr/bin/env bash
# Live end-to-end PULL over real HTTP between two THROWAWAY SQLite instances.
# The SOURCE runs as a real `saltcorn serve` process; the TARGET is driven
# in-process and pulls the source's design state over the wire. Never touches
# Scott's instances or the shared plugins folder: all state, and the plugins folder
# the by-location install copies into, live in one mktemp dir (see testEnv.sh).
set -euo pipefail
HERE="$(cd "$(dirname "$0")" && pwd)"
PLUGIN="$(dirname "$HERE")"
REPO="$(dirname "$PLUGIN")"
SC_BIN="$REPO/saltcorn/packages/saltcorn-cli/bin/saltcorn"
# shellcheck source=testEnv.sh
. "$HERE/testEnv.sh"
# SRC_PORT may be overridden; it must be free (never an existing instance's port).
SRC_PORT="${SRC_PORT:-3016}"
# One private state root per invocation, outside the plugin tree (see testEnv.sh).
STATE="$(make_state_dir site-sync-live)"
SRC_STATE="$STATE/src"
TGT_STATE="$STATE/tgt"
SRC_DB="$SRC_STATE/db.sqlite"
TGT_DB="$TGT_STATE/db.sqlite"
SHARED="0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
NP="$REPO/saltcorn/node_modules"
UP_WAIT_SECONDS=60
ROUTE_GRACE_SECONDS=2
ENV_ID_RE='^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'
SRC_PID=""
cleanup() {
if [ -n "$SRC_PID" ]; then
kill "$SRC_PID" 2>/dev/null || true
wait "$SRC_PID" 2>/dev/null || true
fi
rm -rf "$STATE"
}
trap cleanup EXIT
isolate_saltcorn_dirs "$STATE"
if (echo >"/dev/tcp/127.0.0.1/$SRC_PORT") 2>/dev/null; then
echo "FAILED: port $SRC_PORT is already in use (set SRC_PORT to a free port)"
exit 1
fi
mkdir -p "$SRC_STATE/files" "$TGT_STATE/files"
srcRun() { env SQLITE_FILEPATH="$SRC_DB" SALTCORN_FILE_STORE="$SRC_STATE/files" SALTCORN_SESSION_SECRET="live-src-secret" SALTCORN_NWORKERS=1 NODE_PATH="$NP" "$@"; }
tgtRun() { env SQLITE_FILEPATH="$TGT_DB" SALTCORN_FILE_STORE="$TGT_STATE/files" SALTCORN_SESSION_SECRET="live-tgt-secret" SALTCORN_NWORKERS=1 NODE_PATH="$NP" "$@"; }
# grep the ENV_ID marker so the plugin's own onLoad stdout log never pollutes it.
extractEnv() { grep -oE 'ENV_ID=[0-9a-f-]+' | tail -1 | cut -d= -f2; }
requireEnvId() {
if ! [[ "$2" =~ $ENV_ID_RE ]]; then
echo "FAILED: could not read the $1 env_id (got '$2')"
exit 1
fi
}
echo "# init schemas"
srcRun node "$SC_BIN" reset-schema --force >/dev/null
tgtRun node "$SC_BIN" reset-schema --force >/dev/null
echo "# bootstrap target + source, pair"
TGT_ENV="$(tgtRun node "$HERE/livePull.js" init-target | extractEnv)"
requireEnvId target "$TGT_ENV"
SRC_ENV="$(srcRun env TARGET_ENV="$TGT_ENV" SHARED="$SHARED" node "$HERE/livePull.js" init-source | extractEnv)"
requireEnvId source "$SRC_ENV"
tgtRun env SOURCE_ENV="$SRC_ENV" SOURCE_URL="http://127.0.0.1:$SRC_PORT" SHARED="$SHARED" node "$HERE/livePull.js" pair-target
echo " source env_id=$SRC_ENV"
echo " target env_id=$TGT_ENV"
echo "# start SOURCE server on :$SRC_PORT"
( cd "$SRC_STATE" && exec env SQLITE_FILEPATH="$SRC_DB" SALTCORN_FILE_STORE="$SRC_STATE/files" SALTCORN_SESSION_SECRET="live-src-secret" SALTCORN_NWORKERS=1 NODE_PATH="$NP" node "$SC_BIN" serve -p "$SRC_PORT" ) >"$SRC_STATE/serve.log" 2>&1 &
SRC_PID=$!
echo -n " waiting for :$SRC_PORT "
UP=0
for _ in $(seq 1 "$UP_WAIT_SECONDS"); do
if (echo >"/dev/tcp/127.0.0.1/$SRC_PORT") 2>/dev/null; then
UP=1
echo "up"
break
fi
echo -n "."
sleep 1
done
if [ "$UP" != "1" ]; then
echo " FAILED to come up; serve.log tail:"
tail -20 "$SRC_STATE/serve.log"
exit 1
fi
# small grace period for plugin route mounting
sleep "$ROUTE_GRACE_SECONDS"
echo "# DRIVE pull (target in-process -> source over HTTP)"
tgtRun env SOURCE_ENV="$SRC_ENV" SOURCE_URL="http://127.0.0.1:$SRC_PORT" node "$HERE/livePull.js" drive

53
test/runPg.sh Executable file
View file

@ -0,0 +1,53 @@
#!/usr/bin/env bash
# Run a site-sync test file against a THROWAWAY Postgres database (site_sync_test_<pid>),
# proving the plugin's DDL + capture/diff/apply/row-sync work on Postgres, not just
# SQLite. The DB is dropped+recreated each run; it never touches Scott's other PG
# databases (e.g. saltcorn_idp).
#
# ./test/runPg.sh # runs test/e2e.js on PG
# ./test/runPg.sh pgGate.js # runs the multi-tenant isolation gate
#
# If Postgres is unreachable it prints SKIPPED and exits 77 (the conventional
# "skipped" code) -- never 0, so a missing database can't pass as green.
set -euo pipefail
EXIT_SKIPPED=77
HERE="$(cd "$(dirname "$0")" && pwd)"
PLUGIN="$(dirname "$HERE")"
REPO="$(dirname "$PLUGIN")"
SC_BIN="$REPO/saltcorn/packages/saltcorn-cli/bin/saltcorn"
TESTFILE="${1:-e2e.js}"
# shellcheck source=testEnv.sh
. "$HERE/testEnv.sh"
export PGHOST="/var/run/postgresql"
export PGUSER="scott"
export PGPASSWORD="peer"
ADMINDB="saltcorn_idp"
# Per-invocation DB name (PID-suffixed) so concurrent runs never collide.
TESTDB="site_sync_test_$$"
if ! PGDATABASE="$ADMINDB" psql -tAc 'SELECT 1' >/dev/null 2>&1; then
echo "SKIPPED (NOT green): Postgres not reachable (PGHOST=$PGHOST db=$ADMINDB); $TESTFILE did not run"
exit "$EXIT_SKIPPED"
fi
STATE="$(make_state_dir site-sync-pg)"
trap 'PGDATABASE="$ADMINDB" psql -c "DROP DATABASE IF EXISTS $TESTDB" >/dev/null 2>&1; rm -rf "$STATE"' EXIT
isolate_saltcorn_dirs "$STATE"
mkdir -p "$STATE/files"
export NODE_PATH="$REPO/saltcorn/node_modules"
export SALTCORN_FILE_STORE="$STATE/files"
export SALTCORN_SESSION_SECRET="site-sync-pg-test-secret"
export SALTCORN_NWORKERS=1
# Multi-tenant mode, like the real PG instances (and required by pgGate.js): Saltcorn
# reads it once at connect time (db.enable_multi_tenant() is a no-op upstream).
export SALTCORN_MULTI_TENANT=true
# Force the Postgres backend: presence of SQLITE_FILEPATH would pick SQLite.
unset SQLITE_FILEPATH
PGDATABASE="$ADMINDB" psql -v ON_ERROR_STOP=1 -c "DROP DATABASE IF EXISTS $TESTDB" >/dev/null
PGDATABASE="$ADMINDB" psql -v ON_ERROR_STOP=1 -c "CREATE DATABASE $TESTDB" >/dev/null
export PGDATABASE="$TESTDB"
node "$SC_BIN" reset-schema --force >/dev/null
node "$HERE/$TESTFILE"

24
test/testEnv.sh Normal file
View file

@ -0,0 +1,24 @@
# Shared helpers for the site-sync test runners (sourced by run.sh, runPg.sh and
# runLivePull.sh; not executable on its own).
#
# Runner state must live OUTSIDE the plugin tree: installing site-sync by location
# copies the whole plugin directory into Saltcorn's plugins_folder (and every server
# boot re-copies it, merging), so state kept under the plugin dir leaks test
# databases, sessions and secrets into the installed plugin.
# make_state_dir <prefix>: print a fresh private directory for one runner invocation.
make_state_dir() {
mktemp -d "${TMPDIR:-/tmp}/$1.$$.XXXXXX"
}
# isolate_saltcorn_dirs <state>: point Saltcorn's plugins folder (env-paths:
# $XDG_DATA_HOME/saltcorn-plugins/plugins_folder) and its install temp dir ($TMPDIR)
# into <state>, so the by-location install copies the plugin there -- never into the
# shared ~/.local/share/saltcorn-plugins that the real instances load from.
isolate_saltcorn_dirs() {
export XDG_DATA_HOME="$1/xdg-data"
export TMPDIR="$1/tmp"
mkdir -p "$XDG_DATA_HOME" "$TMPDIR"
}