// ============================================================================ // 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. // // status suspended/deleted -> disable AList user + clear role scopes (soft // delete: user + NocoDB history preserved). // status active -> provision (create/update user+role) then // RECOMPUTE desired scopes via shared // calculateEffectiveGrants and write them, then // 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. // // 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; 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 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: 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; } } catch (e) { /* user/get may 404 for some AList versions; assume enabled */ } if (currentlyEnabled) { return { json: { customerId, username, lifecycle: 'skip_echo', actions: [] } }; } } // 4. STATUS BRANCH --------------------------------------------------------- const writeBack = async (fields) => { // PATCH id fields back to NocoDB. Guard: only when they differ. const body = Object.assign({ Id: customerId }, fields); 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], json: true, }); }; if (status === 'suspended' || status === 'deleted') { 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'); } // 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 }); 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. return { json: { customerId, username, status, lifecycle: 'noop', 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 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 }); } return { json: { customerId, username, status, lifecycle: 'active_provision', alistUserId: finalUserId, alistRoleId: finalRoleId, desiredPaths: g.desiredPaths, actions, } };