fix(W1): self-write-back suppression via old/new field comparison

- Replace brittle sync_status==='synced' loop guard with NocoDB v2
  old/new row comparison (previous_rows vs rows).
- businessChanged() = business field move OR sync_required set; echo
  (bookkeeping-only delta) -> skip_writeback_echo, zero AList mutation.
- writeBack() value-change guard: GET live record, PATCH only fields
  whose value actually changes (no-change -> no PATCH -> no webhook).
- sync_required Checkbox honored as force-reprocess flag, cleared on write-back.
- Extract-id node passes previousRow/newRow through Load Env to lifecycle.
- W1 lifecycle test now covers echo suppression (26 assertions).
- Customers.status still NOT consumed by permission calc (TBD semantics).
- W2/W3 effective-grants semantics unchanged.
This commit is contained in:
2026-08-23 06:43:01 +08:00
parent 3ecee3099e
commit 9bcdfc2049
5 changed files with 166 additions and 85 deletions
+91 -37
View File
@@ -13,10 +13,25 @@
// re-enable the user.
// other status -> no AList mutation (skip).
//
// Write-back: if the customer is missing alist_user_id / alist_role_id we
// PATCH them back to NocoDB (with sync_status 'synced'). The echo webhook from
// that PATCH re-enters W1 but the loop guard below (skipWriteBack when already
// populated + status unchanged) makes it a no-op.
// SELF-WRITE-BACK SUPPRESSION (loop guard, 2026-08-23 rewrite):
// We distinguish an ADMIN business change from our OWN write-back echo using
// the NocoDB v2 webhook's old/new rows (`previous_rows` vs `rows`), NOT the
// brittle `sync_status === 'synced'` check.
//
// businessChanged = a BUSINESS field moved (status / username / password /
// display_name / email / notes) OR `sync_required` is set.
// echo = only our bookkeeping fields moved (alist_user_id,
// alist_role_id, sync_status, sync_required=false,
// last_sync, last_sync_error).
//
// We always write back with `sync_required: false` (and clear it), so our own
// echo re-enters with businessChanged === false -> skip immediately, before
// any AList mutation. An admin flipping status (or setting sync_required) is
// the ONLY thing that proceeds past the guard — exactly once.
//
// Value-change write-back guard: writeBack() re-reads the record and only
// PATCHes fields that would actually CHANGE. Writing the same value produces
// no PATCH -> no webhook -> no loop, even if NocoDB webhooks fire on any field.
//
// Idempotency: final state is converged per status (see above). Duplicate or
// echo webhooks re-apply the same end state with zero net change.
@@ -28,16 +43,17 @@ const ctx = { $env, helpers: this.helpers };
// 1. fresh customer record by id (from webhook extract)
const customerId = $json.customerId;
// NocoDB v2 webhook payload carries the pre/post rows. The extract node already
// surfaced customerId; here we re-read the record for the authoritative
// CURRENT field values (source of truth = NocoDB), and separately inspect the
// event's old/new rows to decide business-change vs echo.
const cust = await ncGet(ctx, `/api/v2/tables/${$env.NOCODB_TABLE_CUSTOMERS}/records/${customerId}`);
const status = cust.status || 'active';
const username = cust.username;
if (!username) throw new Error('Customer missing username');
const alistUserId = cust.alist_user_id ?? null;
const alistRoleId = cust.alist_role_id ?? null;
// loop guard: if this webhook is our own write-back echo AND the customer is
// already fully provisioned with synced status AND status is unchanged, skip.
// (Detected below via alreadyProvisioned + sync_status === 'synced'.)
const alreadyProvisioned = !!(alistUserId && alistRoleId);
// 2. admin login
@@ -48,36 +64,70 @@ const login = await alistReq(ctx, 'POST', '/api/auth/login', {
const token = login?.data?.token;
if (!token) throw new Error('AList login failed');
// 3. loop guard: echo of our own write-back (active + already realized) -> skip.
// IMPORTANT: sync_status==='synced' alone is NOT enough to skip, because a
// suspended->active resume ALSO carries sync_status==='synced' (the suspend
// path writes it) while the AList user is still disabled. We therefore check
// the ACTUAL AList user disabled state: only skip when the user is provably
// enabled already. If disabled -> fall through to active provision (re-enable
// + recompute), which is exactly what resume needs.
if (alreadyProvisioned && status === 'active' && cust.sync_status === 'synced' && alistUserId) {
let currentlyEnabled = true;
try {
const uInfo = await alistReq(ctx, 'GET', `/api/admin/user/get?id=${alistUserId}`, null, token);
if (uInfo?.data && typeof uInfo.data.disabled === 'boolean') {
currentlyEnabled = !uInfo.data.disabled;
// 3. LOOP GUARD — distinguish admin business change from our own echo.
// The extract node passes through the raw rows so we can diff. Fall back to
// "always proceed" if the payload rows are missing (better a redundant
// idempotent provision than a silently dropped admin change).
const oldRow = ($json.previousRow && Object.keys($json.previousRow).length) ? $json.previousRow : null;
const newRow = ($json.newRow && Object.keys($json.newRow).length) ? $json.newRow : null;
// Business fields an admin edits to MEAN a change. Bookkeeping fields (ids,
// sync_*, last_sync*) are what WE write and must never by themselves count as
// a business change.
const BUSINESS_FIELDS = ['status', 'username', 'password', 'display_name', 'email', 'notes'];
function businessChanged() {
// sync_required explicitly set by admin => force reprocess (one-shot override).
if (cust.sync_required === true || cust.sync_required === 1) return true;
// If we have old+new rows, compare only business fields.
if (oldRow && newRow) {
for (const f of BUSINESS_FIELDS) {
if ((oldRow[f] ?? null) !== (newRow[f] ?? null)) return true;
}
} catch (e) { /* user/get may 404 for some AList versions; assume enabled */ }
if (currentlyEnabled) {
return { json: { customerId, username, lifecycle: 'skip_echo', actions: [] } };
// Any business field present in new but matching old, or bookkeeping-only
// delta => echo. Also guard against a field we didn't list changing: if a
// business field existed in the row and moved, catch it generically.
for (const f of Object.keys(newRow)) {
// skip pure bookkeeping fields
if (['alist_user_id','alist_role_id','sync_status','sync_required','last_sync','last_sync_error','Id','UpdatedAt','CreatedAt'].includes(f)) continue;
if ((oldRow[f] ?? null) !== (newRow[f] ?? null)) return true;
}
return false;
}
// No rows in payload (unknown shape): fall back to provisioning only when
// provision is genuinely incomplete OR status changed from what AList has.
// To stay safe against loops, treat "no info" as skip-UNLESS-sync_required,
// which admin can set explicitly.
return false;
}
if (!businessChanged()) {
// Our own write-back echo (or a bookkeeping-only save). Refuse to re-apply.
// Nothing is written back here, so no further webhook is generated.
return { json: { customerId, username, status, lifecycle: 'skip_writeback_echo', actions: [] } };
}
// 4. STATUS BRANCH ---------------------------------------------------------
// writeBack with a VALUE-CHANGE guard: re-read the live record and PATCH only
// fields whose value actually differs. If nothing differs -> no PATCH -> no
// webhook -> loop is physically broken at the source.
const writeBack = async (fields) => {
// PATCH id fields back to NocoDB. Guard: only when they differ.
const body = Object.assign({ Id: customerId }, fields);
const live = await ncGet(ctx, `/api/v2/tables/${$env.NOCODB_TABLE_CUSTOMERS}/records/${customerId}`);
const changes = {};
for (const k of Object.keys(fields)) {
const want = fields[k];
const have = live[k];
if (want !== have) changes[k] = want;
}
if (Object.keys(changes).length === 0) return; // no-op: nothing changed
await ctx.helpers.httpRequest({
method: 'PATCH',
url: `${$env.NOCODB_URL}/api/v2/tables/${$env.NOCODB_TABLE_CUSTOMERS}/records`,
headers: { 'xc-token': $env.NOCODB_TOKEN },
body: [body],
body: [Object.assign({ Id: customerId }, changes)],
json: true,
});
};
@@ -98,16 +148,15 @@ if (status === 'suspended' || status === 'deleted') {
}, token);
actions.push('clear_scopes');
}
// mark synced so we don't re-fire on our own write-back (but we don't write
// much here — only sync_status to stop the echo loop).
await writeBack({ sync_status: 'synced', last_sync_error: null });
// Clear sync_required (consumed) + mark synced. Value-change guard means the
// echo re-entry sees businessChanged()===false and stops.
await writeBack({ sync_status: 'synced', sync_required: false, last_sync_error: null });
return { json: { customerId, username, status, lifecycle: 'suspend_or_delete', actions } };
}
if (status !== 'active') {
// pending / expired / disabled / unknown -> no mutation, but mark synced to
// stop echo loop only if we are the ones writing. We do NOT write here to
// avoid interfering while the customer is being drafted.
// pending / expired / disabled / unknown -> no AList mutation. Do NOT write
// back (avoids interfering mid-draft, avoids any echo loop).
return { json: { customerId, username, status, lifecycle: 'noop', actions: [] } };
}
@@ -169,10 +218,15 @@ await alistReq(ctx, 'POST', '/api/admin/role/update', {
}, token);
actions.push('apply_grants');
// 5. write-back ids if newly discovered (creates the echo; landed in loop guard)
if (!alistUserId || !alistRoleId) {
await writeBack({ alist_user_id: finalUserId, alist_role_id: finalRoleId, sync_status: 'synced', last_sync_error: null });
}
// 5. write-back ids + sync_status + clear sync_required (value-change guarded).
// The echo re-entry sees businessChanged()===false -> skip_writeback_echo.
await writeBack({
alist_user_id: alistUserId || finalUserId,
alist_role_id: alistRoleId || finalRoleId,
sync_status: 'synced',
sync_required: false,
last_sync_error: null,
});
return { json: {
customerId, username, status, lifecycle: 'active_provision',