// ============================================================================ // W1 runtime body — Customer provision + STATUS LIFECYCLE (Step 3). // Rebuilt by `node n8n/gen_w1.js` (single source of truth = shared/effective-grants.js). // // Single self-contained Code node (plus webhook + id extract). Replaces the // old multi-node chain with the SAME provision behaviour PLUS status lifecycle. // // LIFECYCLE MATRIX (account-level state, 2026-08-23): // // status active (no user yet) -> provision (create role + user), recompute // effective grants, enable user. // status active (has user) -> re-enable + RECOMPUTE effective grants // (never restore stale scopes), apply. // // status pending/suspended/expired/deleted: // - has user/role -> disable AList user + clear role scopes (soft; keep // user + role + CustomerProducts + history). // - no user yet -> no-op (pure NocoDB label; do NOT create AList user). // // All resume transitions (pending/suspended/expired/deleted -> active) recompute // from current CustomerProducts + Product Resources + expiry; never restore old // scopes, and never mutate CustomerProducts automatically. // // 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. // ============================================================================ // __SHARED_INLINE__ (replaced by shared/effective-grants.js source at build) // ============================================================================ 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; const alreadyProvisioned = !!(alistUserId && alistRoleId); // 2. admin login const login = await alistReq(ctx, 'POST', '/api/auth/login', { username: $env.ALIST_ADMIN_USERNAME, password: $env.ALIST_ADMIN_PASSWORD, }, null); const token = login?.data?.token; if (!token) throw new Error('AList login failed'); // 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; // After-INSERT: there is a new row but no previous row. This is a fresh // customer creation -> treat as a business change (provision / deactivate). if (newRow && !oldRow) 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; } // 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; } // Both rows missing (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) => { 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: [Object.assign({ Id: customerId }, changes)], json: true, }); }; if (status !== 'active') { // pending / suspended / expired / deleted / unknown -> deactivate. // - has user/role: disable AList user + clear role scopes (soft). // - no user yet: no-op (pure label; do NOT create AList user). if (!alistUserId && !alistRoleId) { return { json: { customerId, username, status, lifecycle: 'noop_not_provisioned', actions: [] } }; } const actions = []; if (alistUserId) { await alistReq(ctx, 'POST', '/api/admin/user/update', { id: alistUserId, disabled: true }, token); actions.push('disable_user'); } if (alistRoleId) { const role = await alistReq(ctx, 'GET', `/api/admin/role/get?id=${alistRoleId}`, null, token); await alistReq(ctx, 'POST', '/api/admin/role/update', { id: alistRoleId, name: role?.data?.name, description: role?.data?.description, permission_scopes: [], }, token); actions.push('clear_scopes'); } // value-change guarded write-back; echo re-entry sees businessChanged()===false. await writeBack({ sync_status: 'synced', sync_required: false, last_sync_error: null }); return { json: { customerId, username, status, lifecycle: 'deactivate', actions } }; } // status === 'active' ------------------------------------------------------- const actions = []; let finalUserId = alistUserId; let finalRoleId = alistRoleId; // 4a. ensure role exists if (!finalRoleId) { await alistReq(ctx, 'POST', '/api/admin/role/create', { name: 'customer-' + username, description: 'Auto role for ' + username, default: false, permission_scopes: [], }, token); const roles = await alistReq(ctx, 'GET', '/api/admin/role/list?page=1&per_page=100', null, token); const r = (roles?.data?.content || []).find(x => x.name === 'customer-' + username); if (!r) throw new Error('Created role not found: customer-' + username); finalRoleId = r.id; actions.push('create_role'); } // 4b. ensure user exists / update; always re-enable on active if (!finalUserId) { await alistReq(ctx, 'POST', '/api/admin/user/create', { username, password: cust.password, base_path: '/', role: finalRoleId ? [finalRoleId] : [], permission: 16384, }, token); const users = await alistReq(ctx, 'GET', '/api/admin/user/list?page=1&per_page=100', null, token); const u = (users?.data?.content || []).find(x => x.username === username); if (!u) throw new Error('Created user not found: ' + username); finalUserId = u.id; actions.push('create_user'); } else { await alistReq(ctx, 'POST', '/api/admin/user/update', { id: finalUserId, username, password: cust.password, base_path: '/', role: finalRoleId ? [finalRoleId] : [], permission: 16384, disabled: false, }, token); actions.push('update_user'); } // 4c. RECOMPUTE desired scopes (never restore stale) and write them const g = await calculateEffectiveGrants(ctx, customerId); await alistReq(ctx, 'POST', '/api/admin/role/update', { id: finalRoleId, name: 'customer-' + username, description: 'Auto role for ' + username, permission_scopes: g.newScopes, }, token); actions.push('apply_grants'); // 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', alistUserId: finalUserId, alistRoleId: finalRoleId, desiredPaths: g.desiredPaths, actions, } };