From 21e3a3b45f24cc8e41c28aa4406b3a75aa8cd5f0 Mon Sep 17 00:00:00 2001 From: hoelee Date: Sun, 23 Aug 2026 06:55:04 +0800 Subject: [PATCH] fix(shared): link-field where query must use (customer.Id,eq,N) not (customer,eq,N) - calculateEffectiveGrants queried CustomerProducts/AccessGrants with (customer,eq,N), which matches the link field's DISPLAY NAME and always returns 0 rows -> desiredPaths always [] -> W1/W2/W3 write empty scopes. - Regression from the shared-lib refactor (3ecee30): W2 had already fixed this in 36a6b3b but the shared extract reverted to the old syntax. - Correct syntax (customer.Id,eq,N) verified against live NocoDB for customer 6 -> /Class/Health, /Class/Other, /Template/Music. - Updated all three test mocks to match the corrected where clause. --- n8n/W1-customer-provision.json | 2 +- n8n/W2-customerproduct-sync.json | 2 +- n8n/W3-expiry-sync.json | 2 +- n8n/shared/effective-grants.js | 7 +++++-- n8n/test_effective_grants.js | 4 ++-- n8n/test_w1_lifecycle.js | 4 ++-- n8n/test_w3_refactored.js | 4 ++-- 7 files changed, 14 insertions(+), 11 deletions(-) diff --git a/n8n/W1-customer-provision.json b/n8n/W1-customer-provision.json index cbaafec..30ee6e8 100644 --- a/n8n/W1-customer-provision.json +++ b/n8n/W1-customer-provision.json @@ -46,7 +46,7 @@ }, { "parameters": { - "jsCode": "// ============================================================================\n// W1 runtime body — Customer provision + STATUS LIFECYCLE (Step 3).\n// Rebuilt by `node n8n/gen_w1.js` (single source of truth = shared/effective-grants.js).\n//\n// Single self-contained Code node (plus webhook + id extract). Replaces the\n// old multi-node chain with the SAME provision behaviour PLUS status lifecycle.\n//\n// status suspended/deleted -> disable AList user + clear role scopes (soft\n// delete: user + NocoDB history preserved).\n// status active -> provision (create/update user+role) then\n// RECOMPUTE desired scopes via shared\n// calculateEffectiveGrants and write them, then\n// re-enable the user.\n// other status -> no AList mutation (skip).\n//\n// SELF-WRITE-BACK SUPPRESSION (loop guard, 2026-08-23 rewrite):\n// We distinguish an ADMIN business change from our OWN write-back echo using\n// the NocoDB v2 webhook's old/new rows (`previous_rows` vs `rows`), NOT the\n// brittle `sync_status === 'synced'` check.\n//\n// businessChanged = a BUSINESS field moved (status / username / password /\n// display_name / email / notes) OR `sync_required` is set.\n// echo = only our bookkeeping fields moved (alist_user_id,\n// alist_role_id, sync_status, sync_required=false,\n// last_sync, last_sync_error).\n//\n// We always write back with `sync_required: false` (and clear it), so our own\n// echo re-enters with businessChanged === false -> skip immediately, before\n// any AList mutation. An admin flipping status (or setting sync_required) is\n// the ONLY thing that proceeds past the guard — exactly once.\n//\n// Value-change write-back guard: writeBack() re-reads the record and only\n// PATCHes fields that would actually CHANGE. Writing the same value produces\n// no PATCH -> no webhook -> no loop, even if NocoDB webhooks fire on any field.\n//\n// Idempotency: final state is converged per status (see above). Duplicate or\n// echo webhooks re-apply the same end state with zero net change.\n// ============================================================================\n// ============================================================================\n// SHARED: calculateEffectiveGrants — single source of truth for \"what scopes\n// should customer X have right now\", reused by W1 (active resume), W2\n// (CustomerProduct sync) and W3 (expiry sweep).\n//\n// This file is INLINED into each workflow's Code node at build time (see\n// gen_w1.js / gen_w2.js / gen_w3.js). It must therefore be self-contained:\n// no require/import, no process.exit, no top-level return outside functions.\n//\n// Config is read via $env (the ONLY reliable channel in n8n Code nodes for\n// custom env vars — process.env is unreliable there). The caller passes a\n// `ctx` with { $env, helpers } so this file is also unit-testable outside n8n.\n//\n// ============================================================================\n\n// --- helpers (depend on ctx, so they are functions not top-level consts) ---\n\nfunction ncUrl(ctx, path) {\n return ctx.$env.NOCODB_URL + path;\n}\n\nasync function ncGet(ctx, path) {\n return await ctx.helpers.httpRequest({\n method: 'GET',\n url: ncUrl(ctx, path),\n headers: { 'xc-token': ctx.$env.NOCODB_TOKEN },\n json: true,\n });\n}\n\nasync function alistReq(ctx, method, path, body, token) {\n // NOTE: AList expects the RAW token in Authorization (no \"Bearer \" prefix).\n return await ctx.helpers.httpRequest({\n method,\n url: ctx.$env.ALIST_URL + path,\n headers: token ? { Authorization: token } : {},\n body,\n json: true,\n });\n}\n\n// NocoDB LinkToAnotherRecord values are objects/arrays/numbers depending on\n// whether the API was asked to expand them. Normalise to a single id or null.\nfunction linkId(v) {\n if (v == null) return null;\n if (typeof v === 'object' && !Array.isArray(v)) return v.Id ?? v.id ?? null;\n if (Array.isArray(v)) return v[0]?.Id ?? v[0]?.id ?? null;\n return v;\n}\n\n// --- core algorithm --------------------------------------------------------\n//\n// desired scopes for a customer = union of:\n// (a) every RESOURCE reachable from an ACTIVE, NOT-YET-EXPIRED CustomerProduct\n// (b) every RESOURCE granted directly via an ACTIVE, NOT-YET-EXPIRED AccessGrant\n//\n// For (a) the SAME resource coming from MULTIPLE products keeps the MAX\n// expires_at, exactly matching the \"two products share a resource\" semantics\n// the user tested. Expiry boundary is `<= today` (expiring today is expired).\n\nasync function calculateEffectiveGrants(ctx, customerId) {\n const T_CUST = ctx.$env.NOCODB_TABLE_CUSTOMERS;\n const T_CUSTPROD = ctx.$env.NOCODB_TABLE_CUSTOMERPRODUCTS;\n const T_PROD = ctx.$env.NOCODB_TABLE_PRODUCTS;\n const T_RES = ctx.$env.NOCODB_TABLE_RESOURCES;\n const T_GRANT = ctx.$env.NOCODB_TABLE_ACCESSGRANTS;\n const LNK_PROD_RES = ctx.$env.NOCODB_LINK_PROD_RESOURCES; // c4ko4x8kwi16swg\n const today = new Date().toISOString().slice(0, 10); // 'YYYY-MM-DD'\n\n // index all Resources by Id -> alist_path (cheap, one query)\n const allRes = (await ncGet(ctx, `/api/v2/tables/${T_RES}/records?limit=10000`)).list || [];\n const resPath = {};\n allRes.forEach(r => { resPath[r.Id] = r.alist_path; });\n\n const desired = {}; // path -> { expires, permission }\n\n // (a) CustomerProducts -> Resources (via product M2M link), MAX expiry on\n // the same path.\n const cps = (await ncGet(ctx, `/api/v2/tables/${T_CUSTPROD}/records?where=(customer,eq,${customerId})&limit=1000`)).list || [];\n for (const c of cps) {\n if (c.status && c.status !== 'active') continue;\n if (c.expires_at && String(c.expires_at) <= today) continue; // expired (incl. today)\n const pid = linkId(c.product);\n if (!pid) continue;\n const res = (await ncGet(ctx, `/api/v2/tables/${T_PROD}/links/${LNK_PROD_RES}/records/${pid}`)).list || [];\n const exp = c.expires_at; // 'YYYY-MM-DD' (may be null = no expiry)\n for (const r of res) {\n const p = resPath[r.Id];\n if (!p) continue;\n const cur = desired[p];\n if (!cur || !cur.expires || (exp && exp > cur.expires)) {\n desired[p] = { expires: exp || null, permission: cur?.permission ?? 0 };\n }\n }\n }\n\n // (b) AccessGrants -> Resources (manual / materialised direct grants)\n const grants = (await ncGet(ctx, `/api/v2/tables/${T_GRANT}/records?where=(customer,eq,${customerId})&limit=1000`)).list || [];\n for (const g of grants) {\n if (g.status && g.status !== 'active') continue;\n if (g.expires_at && String(g.expires_at) <= today) continue;\n const p = resPath[linkId(g.resource)];\n if (!p) continue;\n const exp = g.expires_at;\n const cur = desired[p];\n if (!cur || !cur.expires || (exp && exp > cur.expires)) {\n desired[p] = { expires: exp || null, permission: g.permission === 'readwrite' ? 1 : 0 };\n }\n }\n\n const desiredPaths = Object.keys(desired).sort();\n return {\n customerId,\n desired, // { path: {expires, permission} }\n desiredPaths, // sorted array of paths\n newScopes: desiredPaths.map(p => ({ path: p, permission: desired[p].permission })),\n };\n}\n\n// --- export shim: kept only when unit-tested, stripped when inlined -------\n// ============================================================================\n\nconst ctx = { $env, helpers: this.helpers };\n\n// 1. fresh customer record by id (from webhook extract)\nconst customerId = $json.customerId;\n\n// NocoDB v2 webhook payload carries the pre/post rows. The extract node already\n// surfaced customerId; here we re-read the record for the authoritative\n// CURRENT field values (source of truth = NocoDB), and separately inspect the\n// event's old/new rows to decide business-change vs echo.\nconst cust = await ncGet(ctx, `/api/v2/tables/${$env.NOCODB_TABLE_CUSTOMERS}/records/${customerId}`);\nconst status = cust.status || 'active';\nconst username = cust.username;\nif (!username) throw new Error('Customer missing username');\nconst alistUserId = cust.alist_user_id ?? null;\nconst alistRoleId = cust.alist_role_id ?? null;\nconst alreadyProvisioned = !!(alistUserId && alistRoleId);\n\n// 2. admin login\nconst login = await alistReq(ctx, 'POST', '/api/auth/login', {\n username: $env.ALIST_ADMIN_USERNAME,\n password: $env.ALIST_ADMIN_PASSWORD,\n}, null);\nconst token = login?.data?.token;\nif (!token) throw new Error('AList login failed');\n\n// 3. LOOP GUARD — distinguish admin business change from our own echo.\n// The extract node passes through the raw rows so we can diff. Fall back to\n// \"always proceed\" if the payload rows are missing (better a redundant\n// idempotent provision than a silently dropped admin change).\nconst oldRow = ($json.previousRow && Object.keys($json.previousRow).length) ? $json.previousRow : null;\nconst newRow = ($json.newRow && Object.keys($json.newRow).length) ? $json.newRow : null;\n\n// Business fields an admin edits to MEAN a change. Bookkeeping fields (ids,\n// sync_*, last_sync*) are what WE write and must never by themselves count as\n// a business change.\nconst BUSINESS_FIELDS = ['status', 'username', 'password', 'display_name', 'email', 'notes'];\n\nfunction businessChanged() {\n // sync_required explicitly set by admin => force reprocess (one-shot override).\n if (cust.sync_required === true || cust.sync_required === 1) return true;\n\n // If we have old+new rows, compare only business fields.\n if (oldRow && newRow) {\n for (const f of BUSINESS_FIELDS) {\n if ((oldRow[f] ?? null) !== (newRow[f] ?? null)) return true;\n }\n // Any business field present in new but matching old, or bookkeeping-only\n // delta => echo. Also guard against a field we didn't list changing: if a\n // business field existed in the row and moved, catch it generically.\n for (const f of Object.keys(newRow)) {\n // skip pure bookkeeping fields\n if (['alist_user_id','alist_role_id','sync_status','sync_required','last_sync','last_sync_error','Id','UpdatedAt','CreatedAt'].includes(f)) continue;\n if ((oldRow[f] ?? null) !== (newRow[f] ?? null)) return true;\n }\n return false;\n }\n\n // No rows in payload (unknown shape): fall back to provisioning only when\n // provision is genuinely incomplete OR status changed from what AList has.\n // To stay safe against loops, treat \"no info\" as skip-UNLESS-sync_required,\n // which admin can set explicitly.\n return false;\n}\n\nif (!businessChanged()) {\n // Our own write-back echo (or a bookkeeping-only save). Refuse to re-apply.\n // Nothing is written back here, so no further webhook is generated.\n return { json: { customerId, username, status, lifecycle: 'skip_writeback_echo', actions: [] } };\n}\n\n// 4. STATUS BRANCH ---------------------------------------------------------\n\n// writeBack with a VALUE-CHANGE guard: re-read the live record and PATCH only\n// fields whose value actually differs. If nothing differs -> no PATCH -> no\n// webhook -> loop is physically broken at the source.\nconst writeBack = async (fields) => {\n const live = await ncGet(ctx, `/api/v2/tables/${$env.NOCODB_TABLE_CUSTOMERS}/records/${customerId}`);\n const changes = {};\n for (const k of Object.keys(fields)) {\n const want = fields[k];\n const have = live[k];\n if (want !== have) changes[k] = want;\n }\n if (Object.keys(changes).length === 0) return; // no-op: nothing changed\n await ctx.helpers.httpRequest({\n method: 'PATCH',\n url: `${$env.NOCODB_URL}/api/v2/tables/${$env.NOCODB_TABLE_CUSTOMERS}/records`,\n headers: { 'xc-token': $env.NOCODB_TOKEN },\n body: [Object.assign({ Id: customerId }, changes)],\n json: true,\n });\n};\n\nif (status === 'suspended' || status === 'deleted') {\n const actions = [];\n if (alistUserId) {\n await alistReq(ctx, 'POST', '/api/admin/user/update', { id: alistUserId, disabled: true }, token);\n actions.push('disable_user');\n }\n if (alistRoleId) {\n const role = await alistReq(ctx, 'GET', `/api/admin/role/get?id=${alistRoleId}`, null, token);\n await alistReq(ctx, 'POST', '/api/admin/role/update', {\n id: alistRoleId,\n name: role?.data?.name,\n description: role?.data?.description,\n permission_scopes: [],\n }, token);\n actions.push('clear_scopes');\n }\n // Clear sync_required (consumed) + mark synced. Value-change guard means the\n // echo re-entry sees businessChanged()===false and stops.\n await writeBack({ sync_status: 'synced', sync_required: false, last_sync_error: null });\n return { json: { customerId, username, status, lifecycle: 'suspend_or_delete', actions } };\n}\n\nif (status !== 'active') {\n // pending / expired / disabled / unknown -> no AList mutation. Do NOT write\n // back (avoids interfering mid-draft, avoids any echo loop).\n return { json: { customerId, username, status, lifecycle: 'noop', actions: [] } };\n}\n\n// status === 'active' -------------------------------------------------------\n\nconst actions = [];\nlet finalUserId = alistUserId;\nlet finalRoleId = alistRoleId;\n\n// 4a. ensure role exists\nif (!finalRoleId) {\n await alistReq(ctx, 'POST', '/api/admin/role/create', {\n name: 'customer-' + username,\n description: 'Auto role for ' + username,\n default: false,\n permission_scopes: [],\n }, token);\n const roles = await alistReq(ctx, 'GET', '/api/admin/role/list?page=1&per_page=100', null, token);\n const r = (roles?.data?.content || []).find(x => x.name === 'customer-' + username);\n if (!r) throw new Error('Created role not found: customer-' + username);\n finalRoleId = r.id;\n actions.push('create_role');\n}\n\n// 4b. ensure user exists / update; always re-enable on active\nif (!finalUserId) {\n await alistReq(ctx, 'POST', '/api/admin/user/create', {\n username,\n password: cust.password,\n base_path: '/',\n role: finalRoleId ? [finalRoleId] : [],\n permission: 16384,\n }, token);\n const users = await alistReq(ctx, 'GET', '/api/admin/user/list?page=1&per_page=100', null, token);\n const u = (users?.data?.content || []).find(x => x.username === username);\n if (!u) throw new Error('Created user not found: ' + username);\n finalUserId = u.id;\n actions.push('create_user');\n} else {\n await alistReq(ctx, 'POST', '/api/admin/user/update', {\n id: finalUserId,\n username,\n password: cust.password,\n base_path: '/',\n role: finalRoleId ? [finalRoleId] : [],\n permission: 16384,\n disabled: false,\n }, token);\n actions.push('update_user');\n}\n\n// 4c. RECOMPUTE desired scopes (never restore stale) and write them\nconst g = await calculateEffectiveGrants(ctx, customerId);\nawait alistReq(ctx, 'POST', '/api/admin/role/update', {\n id: finalRoleId,\n name: 'customer-' + username,\n description: 'Auto role for ' + username,\n permission_scopes: g.newScopes,\n}, token);\nactions.push('apply_grants');\n\n// 5. write-back ids + sync_status + clear sync_required (value-change guarded).\n// The echo re-entry sees businessChanged()===false -> skip_writeback_echo.\nawait writeBack({\n alist_user_id: alistUserId || finalUserId,\n alist_role_id: alistRoleId || finalRoleId,\n sync_status: 'synced',\n sync_required: false,\n last_sync_error: null,\n});\n\nreturn { json: {\n customerId, username, status, lifecycle: 'active_provision',\n alistUserId: finalUserId, alistRoleId: finalRoleId,\n desiredPaths: g.desiredPaths, actions,\n} };\n" + "jsCode": "// ============================================================================\n// W1 runtime body — Customer provision + STATUS LIFECYCLE (Step 3).\n// Rebuilt by `node n8n/gen_w1.js` (single source of truth = shared/effective-grants.js).\n//\n// Single self-contained Code node (plus webhook + id extract). Replaces the\n// old multi-node chain with the SAME provision behaviour PLUS status lifecycle.\n//\n// status suspended/deleted -> disable AList user + clear role scopes (soft\n// delete: user + NocoDB history preserved).\n// status active -> provision (create/update user+role) then\n// RECOMPUTE desired scopes via shared\n// calculateEffectiveGrants and write them, then\n// re-enable the user.\n// other status -> no AList mutation (skip).\n//\n// SELF-WRITE-BACK SUPPRESSION (loop guard, 2026-08-23 rewrite):\n// We distinguish an ADMIN business change from our OWN write-back echo using\n// the NocoDB v2 webhook's old/new rows (`previous_rows` vs `rows`), NOT the\n// brittle `sync_status === 'synced'` check.\n//\n// businessChanged = a BUSINESS field moved (status / username / password /\n// display_name / email / notes) OR `sync_required` is set.\n// echo = only our bookkeeping fields moved (alist_user_id,\n// alist_role_id, sync_status, sync_required=false,\n// last_sync, last_sync_error).\n//\n// We always write back with `sync_required: false` (and clear it), so our own\n// echo re-enters with businessChanged === false -> skip immediately, before\n// any AList mutation. An admin flipping status (or setting sync_required) is\n// the ONLY thing that proceeds past the guard — exactly once.\n//\n// Value-change write-back guard: writeBack() re-reads the record and only\n// PATCHes fields that would actually CHANGE. Writing the same value produces\n// no PATCH -> no webhook -> no loop, even if NocoDB webhooks fire on any field.\n//\n// Idempotency: final state is converged per status (see above). Duplicate or\n// echo webhooks re-apply the same end state with zero net change.\n// ============================================================================\n// ============================================================================\n// SHARED: calculateEffectiveGrants — single source of truth for \"what scopes\n// should customer X have right now\", reused by W1 (active resume), W2\n// (CustomerProduct sync) and W3 (expiry sweep).\n//\n// This file is INLINED into each workflow's Code node at build time (see\n// gen_w1.js / gen_w2.js / gen_w3.js). It must therefore be self-contained:\n// no require/import, no process.exit, no top-level return outside functions.\n//\n// Config is read via $env (the ONLY reliable channel in n8n Code nodes for\n// custom env vars — process.env is unreliable there). The caller passes a\n// `ctx` with { $env, helpers } so this file is also unit-testable outside n8n.\n//\n// ============================================================================\n\n// --- helpers (depend on ctx, so they are functions not top-level consts) ---\n\nfunction ncUrl(ctx, path) {\n return ctx.$env.NOCODB_URL + path;\n}\n\nasync function ncGet(ctx, path) {\n return await ctx.helpers.httpRequest({\n method: 'GET',\n url: ncUrl(ctx, path),\n headers: { 'xc-token': ctx.$env.NOCODB_TOKEN },\n json: true,\n });\n}\n\nasync function alistReq(ctx, method, path, body, token) {\n // NOTE: AList expects the RAW token in Authorization (no \"Bearer \" prefix).\n return await ctx.helpers.httpRequest({\n method,\n url: ctx.$env.ALIST_URL + path,\n headers: token ? { Authorization: token } : {},\n body,\n json: true,\n });\n}\n\n// NocoDB LinkToAnotherRecord values are objects/arrays/numbers depending on\n// whether the API was asked to expand them. Normalise to a single id or null.\nfunction linkId(v) {\n if (v == null) return null;\n if (typeof v === 'object' && !Array.isArray(v)) return v.Id ?? v.id ?? null;\n if (Array.isArray(v)) return v[0]?.Id ?? v[0]?.id ?? null;\n return v;\n}\n\n// --- core algorithm --------------------------------------------------------\n//\n// desired scopes for a customer = union of:\n// (a) every RESOURCE reachable from an ACTIVE, NOT-YET-EXPIRED CustomerProduct\n// (b) every RESOURCE granted directly via an ACTIVE, NOT-YET-EXPIRED AccessGrant\n//\n// For (a) the SAME resource coming from MULTIPLE products keeps the MAX\n// expires_at, exactly matching the \"two products share a resource\" semantics\n// the user tested. Expiry boundary is `<= today` (expiring today is expired).\n\nasync function calculateEffectiveGrants(ctx, customerId) {\n const T_CUST = ctx.$env.NOCODB_TABLE_CUSTOMERS;\n const T_CUSTPROD = ctx.$env.NOCODB_TABLE_CUSTOMERPRODUCTS;\n const T_PROD = ctx.$env.NOCODB_TABLE_PRODUCTS;\n const T_RES = ctx.$env.NOCODB_TABLE_RESOURCES;\n const T_GRANT = ctx.$env.NOCODB_TABLE_ACCESSGRANTS;\n const LNK_PROD_RES = ctx.$env.NOCODB_LINK_PROD_RESOURCES; // c4ko4x8kwi16swg\n const today = new Date().toISOString().slice(0, 10); // 'YYYY-MM-DD'\n\n // index all Resources by Id -> alist_path (cheap, one query)\n const allRes = (await ncGet(ctx, `/api/v2/tables/${T_RES}/records?limit=10000`)).list || [];\n const resPath = {};\n allRes.forEach(r => { resPath[r.Id] = r.alist_path; });\n\n const desired = {}; // path -> { expires, permission }\n\n // (a) CustomerProducts -> Resources (via product M2M link), MAX expiry on\n // the same path.\n // NOTE: `(customer,eq,N)` matches the link field's DISPLAY NAME and always\n // returns 0 rows. Must use the `customer.Id` sub-field syntax (or the\n // foreign-key column `nc_jitu___Customers_id`).\n const cps = (await ncGet(ctx, `/api/v2/tables/${T_CUSTPROD}/records?where=(customer.Id,eq,${customerId})&limit=1000`)).list || [];\n for (const c of cps) {\n if (c.status && c.status !== 'active') continue;\n if (c.expires_at && String(c.expires_at) <= today) continue; // expired (incl. today)\n const pid = linkId(c.product);\n if (!pid) continue;\n const res = (await ncGet(ctx, `/api/v2/tables/${T_PROD}/links/${LNK_PROD_RES}/records/${pid}`)).list || [];\n const exp = c.expires_at; // 'YYYY-MM-DD' (may be null = no expiry)\n for (const r of res) {\n const p = resPath[r.Id];\n if (!p) continue;\n const cur = desired[p];\n if (!cur || !cur.expires || (exp && exp > cur.expires)) {\n desired[p] = { expires: exp || null, permission: cur?.permission ?? 0 };\n }\n }\n }\n\n // (b) AccessGrants -> Resources (manual / materialised direct grants)\n const grants = (await ncGet(ctx, `/api/v2/tables/${T_GRANT}/records?where=(customer.Id,eq,${customerId})&limit=1000`)).list || [];\n for (const g of grants) {\n if (g.status && g.status !== 'active') continue;\n if (g.expires_at && String(g.expires_at) <= today) continue;\n const p = resPath[linkId(g.resource)];\n if (!p) continue;\n const exp = g.expires_at;\n const cur = desired[p];\n if (!cur || !cur.expires || (exp && exp > cur.expires)) {\n desired[p] = { expires: exp || null, permission: g.permission === 'readwrite' ? 1 : 0 };\n }\n }\n\n const desiredPaths = Object.keys(desired).sort();\n return {\n customerId,\n desired, // { path: {expires, permission} }\n desiredPaths, // sorted array of paths\n newScopes: desiredPaths.map(p => ({ path: p, permission: desired[p].permission })),\n };\n}\n\n// --- export shim: kept only when unit-tested, stripped when inlined -------\n// ============================================================================\n\nconst ctx = { $env, helpers: this.helpers };\n\n// 1. fresh customer record by id (from webhook extract)\nconst customerId = $json.customerId;\n\n// NocoDB v2 webhook payload carries the pre/post rows. The extract node already\n// surfaced customerId; here we re-read the record for the authoritative\n// CURRENT field values (source of truth = NocoDB), and separately inspect the\n// event's old/new rows to decide business-change vs echo.\nconst cust = await ncGet(ctx, `/api/v2/tables/${$env.NOCODB_TABLE_CUSTOMERS}/records/${customerId}`);\nconst status = cust.status || 'active';\nconst username = cust.username;\nif (!username) throw new Error('Customer missing username');\nconst alistUserId = cust.alist_user_id ?? null;\nconst alistRoleId = cust.alist_role_id ?? null;\nconst alreadyProvisioned = !!(alistUserId && alistRoleId);\n\n// 2. admin login\nconst login = await alistReq(ctx, 'POST', '/api/auth/login', {\n username: $env.ALIST_ADMIN_USERNAME,\n password: $env.ALIST_ADMIN_PASSWORD,\n}, null);\nconst token = login?.data?.token;\nif (!token) throw new Error('AList login failed');\n\n// 3. LOOP GUARD — distinguish admin business change from our own echo.\n// The extract node passes through the raw rows so we can diff. Fall back to\n// \"always proceed\" if the payload rows are missing (better a redundant\n// idempotent provision than a silently dropped admin change).\nconst oldRow = ($json.previousRow && Object.keys($json.previousRow).length) ? $json.previousRow : null;\nconst newRow = ($json.newRow && Object.keys($json.newRow).length) ? $json.newRow : null;\n\n// Business fields an admin edits to MEAN a change. Bookkeeping fields (ids,\n// sync_*, last_sync*) are what WE write and must never by themselves count as\n// a business change.\nconst BUSINESS_FIELDS = ['status', 'username', 'password', 'display_name', 'email', 'notes'];\n\nfunction businessChanged() {\n // sync_required explicitly set by admin => force reprocess (one-shot override).\n if (cust.sync_required === true || cust.sync_required === 1) return true;\n\n // If we have old+new rows, compare only business fields.\n if (oldRow && newRow) {\n for (const f of BUSINESS_FIELDS) {\n if ((oldRow[f] ?? null) !== (newRow[f] ?? null)) return true;\n }\n // Any business field present in new but matching old, or bookkeeping-only\n // delta => echo. Also guard against a field we didn't list changing: if a\n // business field existed in the row and moved, catch it generically.\n for (const f of Object.keys(newRow)) {\n // skip pure bookkeeping fields\n if (['alist_user_id','alist_role_id','sync_status','sync_required','last_sync','last_sync_error','Id','UpdatedAt','CreatedAt'].includes(f)) continue;\n if ((oldRow[f] ?? null) !== (newRow[f] ?? null)) return true;\n }\n return false;\n }\n\n // No rows in payload (unknown shape): fall back to provisioning only when\n // provision is genuinely incomplete OR status changed from what AList has.\n // To stay safe against loops, treat \"no info\" as skip-UNLESS-sync_required,\n // which admin can set explicitly.\n return false;\n}\n\nif (!businessChanged()) {\n // Our own write-back echo (or a bookkeeping-only save). Refuse to re-apply.\n // Nothing is written back here, so no further webhook is generated.\n return { json: { customerId, username, status, lifecycle: 'skip_writeback_echo', actions: [] } };\n}\n\n// 4. STATUS BRANCH ---------------------------------------------------------\n\n// writeBack with a VALUE-CHANGE guard: re-read the live record and PATCH only\n// fields whose value actually differs. If nothing differs -> no PATCH -> no\n// webhook -> loop is physically broken at the source.\nconst writeBack = async (fields) => {\n const live = await ncGet(ctx, `/api/v2/tables/${$env.NOCODB_TABLE_CUSTOMERS}/records/${customerId}`);\n const changes = {};\n for (const k of Object.keys(fields)) {\n const want = fields[k];\n const have = live[k];\n if (want !== have) changes[k] = want;\n }\n if (Object.keys(changes).length === 0) return; // no-op: nothing changed\n await ctx.helpers.httpRequest({\n method: 'PATCH',\n url: `${$env.NOCODB_URL}/api/v2/tables/${$env.NOCODB_TABLE_CUSTOMERS}/records`,\n headers: { 'xc-token': $env.NOCODB_TOKEN },\n body: [Object.assign({ Id: customerId }, changes)],\n json: true,\n });\n};\n\nif (status === 'suspended' || status === 'deleted') {\n const actions = [];\n if (alistUserId) {\n await alistReq(ctx, 'POST', '/api/admin/user/update', { id: alistUserId, disabled: true }, token);\n actions.push('disable_user');\n }\n if (alistRoleId) {\n const role = await alistReq(ctx, 'GET', `/api/admin/role/get?id=${alistRoleId}`, null, token);\n await alistReq(ctx, 'POST', '/api/admin/role/update', {\n id: alistRoleId,\n name: role?.data?.name,\n description: role?.data?.description,\n permission_scopes: [],\n }, token);\n actions.push('clear_scopes');\n }\n // Clear sync_required (consumed) + mark synced. Value-change guard means the\n // echo re-entry sees businessChanged()===false and stops.\n await writeBack({ sync_status: 'synced', sync_required: false, last_sync_error: null });\n return { json: { customerId, username, status, lifecycle: 'suspend_or_delete', actions } };\n}\n\nif (status !== 'active') {\n // pending / expired / disabled / unknown -> no AList mutation. Do NOT write\n // back (avoids interfering mid-draft, avoids any echo loop).\n return { json: { customerId, username, status, lifecycle: 'noop', actions: [] } };\n}\n\n// status === 'active' -------------------------------------------------------\n\nconst actions = [];\nlet finalUserId = alistUserId;\nlet finalRoleId = alistRoleId;\n\n// 4a. ensure role exists\nif (!finalRoleId) {\n await alistReq(ctx, 'POST', '/api/admin/role/create', {\n name: 'customer-' + username,\n description: 'Auto role for ' + username,\n default: false,\n permission_scopes: [],\n }, token);\n const roles = await alistReq(ctx, 'GET', '/api/admin/role/list?page=1&per_page=100', null, token);\n const r = (roles?.data?.content || []).find(x => x.name === 'customer-' + username);\n if (!r) throw new Error('Created role not found: customer-' + username);\n finalRoleId = r.id;\n actions.push('create_role');\n}\n\n// 4b. ensure user exists / update; always re-enable on active\nif (!finalUserId) {\n await alistReq(ctx, 'POST', '/api/admin/user/create', {\n username,\n password: cust.password,\n base_path: '/',\n role: finalRoleId ? [finalRoleId] : [],\n permission: 16384,\n }, token);\n const users = await alistReq(ctx, 'GET', '/api/admin/user/list?page=1&per_page=100', null, token);\n const u = (users?.data?.content || []).find(x => x.username === username);\n if (!u) throw new Error('Created user not found: ' + username);\n finalUserId = u.id;\n actions.push('create_user');\n} else {\n await alistReq(ctx, 'POST', '/api/admin/user/update', {\n id: finalUserId,\n username,\n password: cust.password,\n base_path: '/',\n role: finalRoleId ? [finalRoleId] : [],\n permission: 16384,\n disabled: false,\n }, token);\n actions.push('update_user');\n}\n\n// 4c. RECOMPUTE desired scopes (never restore stale) and write them\nconst g = await calculateEffectiveGrants(ctx, customerId);\nawait alistReq(ctx, 'POST', '/api/admin/role/update', {\n id: finalRoleId,\n name: 'customer-' + username,\n description: 'Auto role for ' + username,\n permission_scopes: g.newScopes,\n}, token);\nactions.push('apply_grants');\n\n// 5. write-back ids + sync_status + clear sync_required (value-change guarded).\n// The echo re-entry sees businessChanged()===false -> skip_writeback_echo.\nawait writeBack({\n alist_user_id: alistUserId || finalUserId,\n alist_role_id: alistRoleId || finalRoleId,\n sync_status: 'synced',\n sync_required: false,\n last_sync_error: null,\n});\n\nreturn { json: {\n customerId, username, status, lifecycle: 'active_provision',\n alistUserId: finalUserId, alistRoleId: finalRoleId,\n desiredPaths: g.desiredPaths, actions,\n} };\n" }, "id": "lifecycle", "name": "Provision & Apply Lifecycle", diff --git a/n8n/W2-customerproduct-sync.json b/n8n/W2-customerproduct-sync.json index fb89739..ce52bf9 100644 --- a/n8n/W2-customerproduct-sync.json +++ b/n8n/W2-customerproduct-sync.json @@ -46,7 +46,7 @@ }, { "parameters": { - "jsCode": "// ============================================================================\n// W2 runtime body — enforced CustomerProduct -> AList role scopes sync.\n// Rebuilt by `node n8n/gen_w2.js` (single source of truth = shared/effective-grants.js).\n// ============================================================================\n// ============================================================================\n// SHARED: calculateEffectiveGrants — single source of truth for \"what scopes\n// should customer X have right now\", reused by W1 (active resume), W2\n// (CustomerProduct sync) and W3 (expiry sweep).\n//\n// This file is INLINED into each workflow's Code node at build time (see\n// gen_w1.js / gen_w2.js / gen_w3.js). It must therefore be self-contained:\n// no require/import, no process.exit, no top-level return outside functions.\n//\n// Config is read via $env (the ONLY reliable channel in n8n Code nodes for\n// custom env vars — process.env is unreliable there). The caller passes a\n// `ctx` with { $env, helpers } so this file is also unit-testable outside n8n.\n//\n// ============================================================================\n\n// --- helpers (depend on ctx, so they are functions not top-level consts) ---\n\nfunction ncUrl(ctx, path) {\n return ctx.$env.NOCODB_URL + path;\n}\n\nasync function ncGet(ctx, path) {\n return await ctx.helpers.httpRequest({\n method: 'GET',\n url: ncUrl(ctx, path),\n headers: { 'xc-token': ctx.$env.NOCODB_TOKEN },\n json: true,\n });\n}\n\nasync function alistReq(ctx, method, path, body, token) {\n // NOTE: AList expects the RAW token in Authorization (no \"Bearer \" prefix).\n return await ctx.helpers.httpRequest({\n method,\n url: ctx.$env.ALIST_URL + path,\n headers: token ? { Authorization: token } : {},\n body,\n json: true,\n });\n}\n\n// NocoDB LinkToAnotherRecord values are objects/arrays/numbers depending on\n// whether the API was asked to expand them. Normalise to a single id or null.\nfunction linkId(v) {\n if (v == null) return null;\n if (typeof v === 'object' && !Array.isArray(v)) return v.Id ?? v.id ?? null;\n if (Array.isArray(v)) return v[0]?.Id ?? v[0]?.id ?? null;\n return v;\n}\n\n// --- core algorithm --------------------------------------------------------\n//\n// desired scopes for a customer = union of:\n// (a) every RESOURCE reachable from an ACTIVE, NOT-YET-EXPIRED CustomerProduct\n// (b) every RESOURCE granted directly via an ACTIVE, NOT-YET-EXPIRED AccessGrant\n//\n// For (a) the SAME resource coming from MULTIPLE products keeps the MAX\n// expires_at, exactly matching the \"two products share a resource\" semantics\n// the user tested. Expiry boundary is `<= today` (expiring today is expired).\n\nasync function calculateEffectiveGrants(ctx, customerId) {\n const T_CUST = ctx.$env.NOCODB_TABLE_CUSTOMERS;\n const T_CUSTPROD = ctx.$env.NOCODB_TABLE_CUSTOMERPRODUCTS;\n const T_PROD = ctx.$env.NOCODB_TABLE_PRODUCTS;\n const T_RES = ctx.$env.NOCODB_TABLE_RESOURCES;\n const T_GRANT = ctx.$env.NOCODB_TABLE_ACCESSGRANTS;\n const LNK_PROD_RES = ctx.$env.NOCODB_LINK_PROD_RESOURCES; // c4ko4x8kwi16swg\n const today = new Date().toISOString().slice(0, 10); // 'YYYY-MM-DD'\n\n // index all Resources by Id -> alist_path (cheap, one query)\n const allRes = (await ncGet(ctx, `/api/v2/tables/${T_RES}/records?limit=10000`)).list || [];\n const resPath = {};\n allRes.forEach(r => { resPath[r.Id] = r.alist_path; });\n\n const desired = {}; // path -> { expires, permission }\n\n // (a) CustomerProducts -> Resources (via product M2M link), MAX expiry on\n // the same path.\n const cps = (await ncGet(ctx, `/api/v2/tables/${T_CUSTPROD}/records?where=(customer,eq,${customerId})&limit=1000`)).list || [];\n for (const c of cps) {\n if (c.status && c.status !== 'active') continue;\n if (c.expires_at && String(c.expires_at) <= today) continue; // expired (incl. today)\n const pid = linkId(c.product);\n if (!pid) continue;\n const res = (await ncGet(ctx, `/api/v2/tables/${T_PROD}/links/${LNK_PROD_RES}/records/${pid}`)).list || [];\n const exp = c.expires_at; // 'YYYY-MM-DD' (may be null = no expiry)\n for (const r of res) {\n const p = resPath[r.Id];\n if (!p) continue;\n const cur = desired[p];\n if (!cur || !cur.expires || (exp && exp > cur.expires)) {\n desired[p] = { expires: exp || null, permission: cur?.permission ?? 0 };\n }\n }\n }\n\n // (b) AccessGrants -> Resources (manual / materialised direct grants)\n const grants = (await ncGet(ctx, `/api/v2/tables/${T_GRANT}/records?where=(customer,eq,${customerId})&limit=1000`)).list || [];\n for (const g of grants) {\n if (g.status && g.status !== 'active') continue;\n if (g.expires_at && String(g.expires_at) <= today) continue;\n const p = resPath[linkId(g.resource)];\n if (!p) continue;\n const exp = g.expires_at;\n const cur = desired[p];\n if (!cur || !cur.expires || (exp && exp > cur.expires)) {\n desired[p] = { expires: exp || null, permission: g.permission === 'readwrite' ? 1 : 0 };\n }\n }\n\n const desiredPaths = Object.keys(desired).sort();\n return {\n customerId,\n desired, // { path: {expires, permission} }\n desiredPaths, // sorted array of paths\n newScopes: desiredPaths.map(p => ({ path: p, permission: desired[p].permission })),\n };\n}\n\n// --- export shim: kept only when unit-tested, stripped when inlined -------\n// ============================================================================\n\n// n8n Code node context: ctx = { $env, helpers } — but helpers is `this.helpers`\n// inside the node. Build ctx to hand to the shared library.\nconst ctx = {\n $env,\n helpers: this.helpers,\n};\n\nconst custProdId = $json.customerProductId;\n\n// 1. re-fetch this CP record to find its customer link (may be null mid-draft)\nconst CP = await ncGet(ctx, `/api/v2/tables/${$env.NOCODB_TABLE_CUSTOMERPRODUCTS}/records/${custProdId}`);\nconst customerId = linkId(CP.customer);\nif (!customerId) {\n // Customer link not yet filled (record being drafted). Skip gracefully; the\n // webhook re-fires when customer is set later.\n return { json: { skipWriteBack: true, customerProductId: custProdId, reason: 'no customer link yet' } };\n}\n\n// 2. compute what the customer SHOULD have right now (shared algorithm)\nconst grants = await calculateEffectiveGrants(ctx, customerId);\n\n// 3. fetch customer admin facts (role id, alist user id)\nconst cust = await ncGet(ctx, `/api/v2/tables/${$env.NOCODB_TABLE_CUSTOMERS}/records/${customerId}`);\nconst roleId = cust.alist_role_id;\nconst userId = cust.alist_user_id;\n\nif (!roleId) {\n return { json: { skipWriteBack: true, customerId, reason: 'customer not provisioned (no role id)' } };\n}\n\n// 4. admin login\nconst login = await alistReq(ctx, 'POST', '/api/auth/login', {\n username: $env.ALIST_ADMIN_USERNAME,\n password: $env.ALIST_ADMIN_PASSWORD,\n}, null);\nconst token = login?.data?.token;\nif (!token) throw new Error('AList login failed');\n\n// 5. current scopes vs desired scopes -> diff\nconst role = await alistReq(ctx, 'GET', `/api/admin/role/get?id=${roleId}`, null, token);\nconst currentScopes = role?.data?.permission_scopes ?? [];\nconst currentPaths = currentScopes.map(s => s.path);\nconst toAdd = grants.desiredPaths.filter(p => !currentPaths.includes(p));\nconst toRemove = currentPaths.filter(p => !grants.desiredPaths.includes(p));\nconst changed = toAdd.length > 0 || toRemove.length > 0;\n\n// 6. apply if changed\nif (changed) {\n await alistReq(ctx, 'POST', '/api/admin/role/update', {\n id: roleId,\n name: role?.data?.name,\n description: role?.data?.description,\n permission_scopes: grants.newScopes,\n }, token);\n}\n\nreturn {\n json: {\n customerId,\n customerProductId: custProdId,\n roleId,\n roleName: role?.data?.name,\n effective: grants.desired,\n desiredPaths: grants.desiredPaths,\n currentScopes,\n diff: { toAdd, toRemove },\n newScopes: grants.newScopes,\n skipWriteBack: changed ? false : true,\n },\n};\n" + "jsCode": "// ============================================================================\n// W2 runtime body — enforced CustomerProduct -> AList role scopes sync.\n// Rebuilt by `node n8n/gen_w2.js` (single source of truth = shared/effective-grants.js).\n// ============================================================================\n// ============================================================================\n// SHARED: calculateEffectiveGrants — single source of truth for \"what scopes\n// should customer X have right now\", reused by W1 (active resume), W2\n// (CustomerProduct sync) and W3 (expiry sweep).\n//\n// This file is INLINED into each workflow's Code node at build time (see\n// gen_w1.js / gen_w2.js / gen_w3.js). It must therefore be self-contained:\n// no require/import, no process.exit, no top-level return outside functions.\n//\n// Config is read via $env (the ONLY reliable channel in n8n Code nodes for\n// custom env vars — process.env is unreliable there). The caller passes a\n// `ctx` with { $env, helpers } so this file is also unit-testable outside n8n.\n//\n// ============================================================================\n\n// --- helpers (depend on ctx, so they are functions not top-level consts) ---\n\nfunction ncUrl(ctx, path) {\n return ctx.$env.NOCODB_URL + path;\n}\n\nasync function ncGet(ctx, path) {\n return await ctx.helpers.httpRequest({\n method: 'GET',\n url: ncUrl(ctx, path),\n headers: { 'xc-token': ctx.$env.NOCODB_TOKEN },\n json: true,\n });\n}\n\nasync function alistReq(ctx, method, path, body, token) {\n // NOTE: AList expects the RAW token in Authorization (no \"Bearer \" prefix).\n return await ctx.helpers.httpRequest({\n method,\n url: ctx.$env.ALIST_URL + path,\n headers: token ? { Authorization: token } : {},\n body,\n json: true,\n });\n}\n\n// NocoDB LinkToAnotherRecord values are objects/arrays/numbers depending on\n// whether the API was asked to expand them. Normalise to a single id or null.\nfunction linkId(v) {\n if (v == null) return null;\n if (typeof v === 'object' && !Array.isArray(v)) return v.Id ?? v.id ?? null;\n if (Array.isArray(v)) return v[0]?.Id ?? v[0]?.id ?? null;\n return v;\n}\n\n// --- core algorithm --------------------------------------------------------\n//\n// desired scopes for a customer = union of:\n// (a) every RESOURCE reachable from an ACTIVE, NOT-YET-EXPIRED CustomerProduct\n// (b) every RESOURCE granted directly via an ACTIVE, NOT-YET-EXPIRED AccessGrant\n//\n// For (a) the SAME resource coming from MULTIPLE products keeps the MAX\n// expires_at, exactly matching the \"two products share a resource\" semantics\n// the user tested. Expiry boundary is `<= today` (expiring today is expired).\n\nasync function calculateEffectiveGrants(ctx, customerId) {\n const T_CUST = ctx.$env.NOCODB_TABLE_CUSTOMERS;\n const T_CUSTPROD = ctx.$env.NOCODB_TABLE_CUSTOMERPRODUCTS;\n const T_PROD = ctx.$env.NOCODB_TABLE_PRODUCTS;\n const T_RES = ctx.$env.NOCODB_TABLE_RESOURCES;\n const T_GRANT = ctx.$env.NOCODB_TABLE_ACCESSGRANTS;\n const LNK_PROD_RES = ctx.$env.NOCODB_LINK_PROD_RESOURCES; // c4ko4x8kwi16swg\n const today = new Date().toISOString().slice(0, 10); // 'YYYY-MM-DD'\n\n // index all Resources by Id -> alist_path (cheap, one query)\n const allRes = (await ncGet(ctx, `/api/v2/tables/${T_RES}/records?limit=10000`)).list || [];\n const resPath = {};\n allRes.forEach(r => { resPath[r.Id] = r.alist_path; });\n\n const desired = {}; // path -> { expires, permission }\n\n // (a) CustomerProducts -> Resources (via product M2M link), MAX expiry on\n // the same path.\n // NOTE: `(customer,eq,N)` matches the link field's DISPLAY NAME and always\n // returns 0 rows. Must use the `customer.Id` sub-field syntax (or the\n // foreign-key column `nc_jitu___Customers_id`).\n const cps = (await ncGet(ctx, `/api/v2/tables/${T_CUSTPROD}/records?where=(customer.Id,eq,${customerId})&limit=1000`)).list || [];\n for (const c of cps) {\n if (c.status && c.status !== 'active') continue;\n if (c.expires_at && String(c.expires_at) <= today) continue; // expired (incl. today)\n const pid = linkId(c.product);\n if (!pid) continue;\n const res = (await ncGet(ctx, `/api/v2/tables/${T_PROD}/links/${LNK_PROD_RES}/records/${pid}`)).list || [];\n const exp = c.expires_at; // 'YYYY-MM-DD' (may be null = no expiry)\n for (const r of res) {\n const p = resPath[r.Id];\n if (!p) continue;\n const cur = desired[p];\n if (!cur || !cur.expires || (exp && exp > cur.expires)) {\n desired[p] = { expires: exp || null, permission: cur?.permission ?? 0 };\n }\n }\n }\n\n // (b) AccessGrants -> Resources (manual / materialised direct grants)\n const grants = (await ncGet(ctx, `/api/v2/tables/${T_GRANT}/records?where=(customer.Id,eq,${customerId})&limit=1000`)).list || [];\n for (const g of grants) {\n if (g.status && g.status !== 'active') continue;\n if (g.expires_at && String(g.expires_at) <= today) continue;\n const p = resPath[linkId(g.resource)];\n if (!p) continue;\n const exp = g.expires_at;\n const cur = desired[p];\n if (!cur || !cur.expires || (exp && exp > cur.expires)) {\n desired[p] = { expires: exp || null, permission: g.permission === 'readwrite' ? 1 : 0 };\n }\n }\n\n const desiredPaths = Object.keys(desired).sort();\n return {\n customerId,\n desired, // { path: {expires, permission} }\n desiredPaths, // sorted array of paths\n newScopes: desiredPaths.map(p => ({ path: p, permission: desired[p].permission })),\n };\n}\n\n// --- export shim: kept only when unit-tested, stripped when inlined -------\n// ============================================================================\n\n// n8n Code node context: ctx = { $env, helpers } — but helpers is `this.helpers`\n// inside the node. Build ctx to hand to the shared library.\nconst ctx = {\n $env,\n helpers: this.helpers,\n};\n\nconst custProdId = $json.customerProductId;\n\n// 1. re-fetch this CP record to find its customer link (may be null mid-draft)\nconst CP = await ncGet(ctx, `/api/v2/tables/${$env.NOCODB_TABLE_CUSTOMERPRODUCTS}/records/${custProdId}`);\nconst customerId = linkId(CP.customer);\nif (!customerId) {\n // Customer link not yet filled (record being drafted). Skip gracefully; the\n // webhook re-fires when customer is set later.\n return { json: { skipWriteBack: true, customerProductId: custProdId, reason: 'no customer link yet' } };\n}\n\n// 2. compute what the customer SHOULD have right now (shared algorithm)\nconst grants = await calculateEffectiveGrants(ctx, customerId);\n\n// 3. fetch customer admin facts (role id, alist user id)\nconst cust = await ncGet(ctx, `/api/v2/tables/${$env.NOCODB_TABLE_CUSTOMERS}/records/${customerId}`);\nconst roleId = cust.alist_role_id;\nconst userId = cust.alist_user_id;\n\nif (!roleId) {\n return { json: { skipWriteBack: true, customerId, reason: 'customer not provisioned (no role id)' } };\n}\n\n// 4. admin login\nconst login = await alistReq(ctx, 'POST', '/api/auth/login', {\n username: $env.ALIST_ADMIN_USERNAME,\n password: $env.ALIST_ADMIN_PASSWORD,\n}, null);\nconst token = login?.data?.token;\nif (!token) throw new Error('AList login failed');\n\n// 5. current scopes vs desired scopes -> diff\nconst role = await alistReq(ctx, 'GET', `/api/admin/role/get?id=${roleId}`, null, token);\nconst currentScopes = role?.data?.permission_scopes ?? [];\nconst currentPaths = currentScopes.map(s => s.path);\nconst toAdd = grants.desiredPaths.filter(p => !currentPaths.includes(p));\nconst toRemove = currentPaths.filter(p => !grants.desiredPaths.includes(p));\nconst changed = toAdd.length > 0 || toRemove.length > 0;\n\n// 6. apply if changed\nif (changed) {\n await alistReq(ctx, 'POST', '/api/admin/role/update', {\n id: roleId,\n name: role?.data?.name,\n description: role?.data?.description,\n permission_scopes: grants.newScopes,\n }, token);\n}\n\nreturn {\n json: {\n customerId,\n customerProductId: custProdId,\n roleId,\n roleName: role?.data?.name,\n effective: grants.desired,\n desiredPaths: grants.desiredPaths,\n currentScopes,\n diff: { toAdd, toRemove },\n newScopes: grants.newScopes,\n skipWriteBack: changed ? false : true,\n },\n};\n" }, "id": "compute-apply", "name": "Compute Effective Grants & Apply", diff --git a/n8n/W3-expiry-sync.json b/n8n/W3-expiry-sync.json index f8bc630..90d4dc4 100644 --- a/n8n/W3-expiry-sync.json +++ b/n8n/W3-expiry-sync.json @@ -23,7 +23,7 @@ }, { "parameters": { - "jsCode": "// ============================================================================\n// W3 runtime body — 5-min expiry sweep / full reconciliation.\n// Rebuilt by `node n8n/gen_w3.js` (single source of truth = shared/effective-grants.js).\n// dryRun: W3_DRY_RUN=true -> only print the plan (no writes).\n// autoDisable: ALIST_AUTO_DISABLE_ON_EXPIRE=true -> disable AList user when a\n// previously-scoped customer's desired set becomes empty. Default off.\n// ============================================================================\n// ============================================================================\n// SHARED: calculateEffectiveGrants — single source of truth for \"what scopes\n// should customer X have right now\", reused by W1 (active resume), W2\n// (CustomerProduct sync) and W3 (expiry sweep).\n//\n// This file is INLINED into each workflow's Code node at build time (see\n// gen_w1.js / gen_w2.js / gen_w3.js). It must therefore be self-contained:\n// no require/import, no process.exit, no top-level return outside functions.\n//\n// Config is read via $env (the ONLY reliable channel in n8n Code nodes for\n// custom env vars — process.env is unreliable there). The caller passes a\n// `ctx` with { $env, helpers } so this file is also unit-testable outside n8n.\n//\n// ============================================================================\n\n// --- helpers (depend on ctx, so they are functions not top-level consts) ---\n\nfunction ncUrl(ctx, path) {\n return ctx.$env.NOCODB_URL + path;\n}\n\nasync function ncGet(ctx, path) {\n return await ctx.helpers.httpRequest({\n method: 'GET',\n url: ncUrl(ctx, path),\n headers: { 'xc-token': ctx.$env.NOCODB_TOKEN },\n json: true,\n });\n}\n\nasync function alistReq(ctx, method, path, body, token) {\n // NOTE: AList expects the RAW token in Authorization (no \"Bearer \" prefix).\n return await ctx.helpers.httpRequest({\n method,\n url: ctx.$env.ALIST_URL + path,\n headers: token ? { Authorization: token } : {},\n body,\n json: true,\n });\n}\n\n// NocoDB LinkToAnotherRecord values are objects/arrays/numbers depending on\n// whether the API was asked to expand them. Normalise to a single id or null.\nfunction linkId(v) {\n if (v == null) return null;\n if (typeof v === 'object' && !Array.isArray(v)) return v.Id ?? v.id ?? null;\n if (Array.isArray(v)) return v[0]?.Id ?? v[0]?.id ?? null;\n return v;\n}\n\n// --- core algorithm --------------------------------------------------------\n//\n// desired scopes for a customer = union of:\n// (a) every RESOURCE reachable from an ACTIVE, NOT-YET-EXPIRED CustomerProduct\n// (b) every RESOURCE granted directly via an ACTIVE, NOT-YET-EXPIRED AccessGrant\n//\n// For (a) the SAME resource coming from MULTIPLE products keeps the MAX\n// expires_at, exactly matching the \"two products share a resource\" semantics\n// the user tested. Expiry boundary is `<= today` (expiring today is expired).\n\nasync function calculateEffectiveGrants(ctx, customerId) {\n const T_CUST = ctx.$env.NOCODB_TABLE_CUSTOMERS;\n const T_CUSTPROD = ctx.$env.NOCODB_TABLE_CUSTOMERPRODUCTS;\n const T_PROD = ctx.$env.NOCODB_TABLE_PRODUCTS;\n const T_RES = ctx.$env.NOCODB_TABLE_RESOURCES;\n const T_GRANT = ctx.$env.NOCODB_TABLE_ACCESSGRANTS;\n const LNK_PROD_RES = ctx.$env.NOCODB_LINK_PROD_RESOURCES; // c4ko4x8kwi16swg\n const today = new Date().toISOString().slice(0, 10); // 'YYYY-MM-DD'\n\n // index all Resources by Id -> alist_path (cheap, one query)\n const allRes = (await ncGet(ctx, `/api/v2/tables/${T_RES}/records?limit=10000`)).list || [];\n const resPath = {};\n allRes.forEach(r => { resPath[r.Id] = r.alist_path; });\n\n const desired = {}; // path -> { expires, permission }\n\n // (a) CustomerProducts -> Resources (via product M2M link), MAX expiry on\n // the same path.\n const cps = (await ncGet(ctx, `/api/v2/tables/${T_CUSTPROD}/records?where=(customer,eq,${customerId})&limit=1000`)).list || [];\n for (const c of cps) {\n if (c.status && c.status !== 'active') continue;\n if (c.expires_at && String(c.expires_at) <= today) continue; // expired (incl. today)\n const pid = linkId(c.product);\n if (!pid) continue;\n const res = (await ncGet(ctx, `/api/v2/tables/${T_PROD}/links/${LNK_PROD_RES}/records/${pid}`)).list || [];\n const exp = c.expires_at; // 'YYYY-MM-DD' (may be null = no expiry)\n for (const r of res) {\n const p = resPath[r.Id];\n if (!p) continue;\n const cur = desired[p];\n if (!cur || !cur.expires || (exp && exp > cur.expires)) {\n desired[p] = { expires: exp || null, permission: cur?.permission ?? 0 };\n }\n }\n }\n\n // (b) AccessGrants -> Resources (manual / materialised direct grants)\n const grants = (await ncGet(ctx, `/api/v2/tables/${T_GRANT}/records?where=(customer,eq,${customerId})&limit=1000`)).list || [];\n for (const g of grants) {\n if (g.status && g.status !== 'active') continue;\n if (g.expires_at && String(g.expires_at) <= today) continue;\n const p = resPath[linkId(g.resource)];\n if (!p) continue;\n const exp = g.expires_at;\n const cur = desired[p];\n if (!cur || !cur.expires || (exp && exp > cur.expires)) {\n desired[p] = { expires: exp || null, permission: g.permission === 'readwrite' ? 1 : 0 };\n }\n }\n\n const desiredPaths = Object.keys(desired).sort();\n return {\n customerId,\n desired, // { path: {expires, permission} }\n desiredPaths, // sorted array of paths\n newScopes: desiredPaths.map(p => ({ path: p, permission: desired[p].permission })),\n };\n}\n\n// --- export shim: kept only when unit-tested, stripped when inlined -------\n// ============================================================================\n\nconst ctx = { $env, helpers: this.helpers };\nconst DRY_RUN = ($env.W3_DRY_RUN || '').toLowerCase() === 'true';\nconst AUTO_DISABLE = ($env.ALIST_AUTO_DISABLE_ON_EXPIRE || '').toLowerCase() === 'true';\nconst T_CUST = $env.NOCODB_TABLE_CUSTOMERS;\n\n// admin login once for the whole sweep\nconst login = await alistReq(ctx, 'POST', '/api/auth/login', {\n username: $env.ALIST_ADMIN_USERNAME,\n password: $env.ALIST_ADMIN_PASSWORD,\n}, null);\nconst token = login?.data?.token;\nif (!token) throw new Error('AList login failed');\n\nconst customers = (await ncGet(ctx, `/api/v2/tables/${T_CUST}/records?limit=10000`)).list || [];\nconst changed = [], errors = [];\n\nfor (const cust of customers) {\n try {\n if (!cust.alist_role_id) continue; // not provisioned yet\n const cid = cust.Id;\n\n // desired scopes via shared algorithm\n const g = await calculateEffectiveGrants(ctx, cid);\n const desiredPaths = g.desiredPaths;\n\n // current scopes from AList\n const role = await alistReq(ctx, 'GET', `/api/admin/role/get?id=${cust.alist_role_id}`, null, token);\n const currentPaths = (role?.data?.permission_scopes || []).map(s => s.path);\n const toAdd = desiredPaths.filter(p => !currentPaths.includes(p));\n const toRemove = currentPaths.filter(p => !desiredPaths.includes(p));\n const scopesChanged = toAdd.length > 0 || toRemove.length > 0;\n const nowEmpty = desiredPaths.length === 0;\n\n // only disable when a customer that HAD scopes now has none (guard\n // currentPaths>0 avoids killing a fresh customer with an empty role).\n const shouldDisable = AUTO_DISABLE && nowEmpty && cust.status === 'active' && currentPaths.length > 0;\n\n const applied = [];\n if (scopesChanged) {\n if (!DRY_RUN) await alistReq(ctx, 'POST', '/api/admin/role/update', {\n id: cust.alist_role_id,\n name: role?.data?.name,\n description: role?.data?.description,\n permission_scopes: g.newScopes,\n }, token);\n applied.push('role_scopes');\n }\n if (shouldDisable) {\n if (!DRY_RUN && cust.alist_user_id)\n await alistReq(ctx, 'POST', '/api/admin/user/update', { id: cust.alist_user_id, disabled: true }, token);\n applied.push('disable');\n }\n if (applied.length) {\n changed.push({\n customerId: cid, username: cust.username, roleId: cust.alist_role_id,\n toAdd, toRemove, desiredPaths, applied,\n });\n }\n } catch (e) {\n errors.push({ customerId: cust.Id, username: cust.username, error: e.message });\n }\n}\n\nreturn { json: {\n dryRun: DRY_RUN, autoDisable: AUTO_DISABLE, at: new Date().toISOString(),\n customersScanned: customers.length, customersChanged: changed.length,\n changed, errors,\n} };\n" + "jsCode": "// ============================================================================\n// W3 runtime body — 5-min expiry sweep / full reconciliation.\n// Rebuilt by `node n8n/gen_w3.js` (single source of truth = shared/effective-grants.js).\n// dryRun: W3_DRY_RUN=true -> only print the plan (no writes).\n// autoDisable: ALIST_AUTO_DISABLE_ON_EXPIRE=true -> disable AList user when a\n// previously-scoped customer's desired set becomes empty. Default off.\n// ============================================================================\n// ============================================================================\n// SHARED: calculateEffectiveGrants — single source of truth for \"what scopes\n// should customer X have right now\", reused by W1 (active resume), W2\n// (CustomerProduct sync) and W3 (expiry sweep).\n//\n// This file is INLINED into each workflow's Code node at build time (see\n// gen_w1.js / gen_w2.js / gen_w3.js). It must therefore be self-contained:\n// no require/import, no process.exit, no top-level return outside functions.\n//\n// Config is read via $env (the ONLY reliable channel in n8n Code nodes for\n// custom env vars — process.env is unreliable there). The caller passes a\n// `ctx` with { $env, helpers } so this file is also unit-testable outside n8n.\n//\n// ============================================================================\n\n// --- helpers (depend on ctx, so they are functions not top-level consts) ---\n\nfunction ncUrl(ctx, path) {\n return ctx.$env.NOCODB_URL + path;\n}\n\nasync function ncGet(ctx, path) {\n return await ctx.helpers.httpRequest({\n method: 'GET',\n url: ncUrl(ctx, path),\n headers: { 'xc-token': ctx.$env.NOCODB_TOKEN },\n json: true,\n });\n}\n\nasync function alistReq(ctx, method, path, body, token) {\n // NOTE: AList expects the RAW token in Authorization (no \"Bearer \" prefix).\n return await ctx.helpers.httpRequest({\n method,\n url: ctx.$env.ALIST_URL + path,\n headers: token ? { Authorization: token } : {},\n body,\n json: true,\n });\n}\n\n// NocoDB LinkToAnotherRecord values are objects/arrays/numbers depending on\n// whether the API was asked to expand them. Normalise to a single id or null.\nfunction linkId(v) {\n if (v == null) return null;\n if (typeof v === 'object' && !Array.isArray(v)) return v.Id ?? v.id ?? null;\n if (Array.isArray(v)) return v[0]?.Id ?? v[0]?.id ?? null;\n return v;\n}\n\n// --- core algorithm --------------------------------------------------------\n//\n// desired scopes for a customer = union of:\n// (a) every RESOURCE reachable from an ACTIVE, NOT-YET-EXPIRED CustomerProduct\n// (b) every RESOURCE granted directly via an ACTIVE, NOT-YET-EXPIRED AccessGrant\n//\n// For (a) the SAME resource coming from MULTIPLE products keeps the MAX\n// expires_at, exactly matching the \"two products share a resource\" semantics\n// the user tested. Expiry boundary is `<= today` (expiring today is expired).\n\nasync function calculateEffectiveGrants(ctx, customerId) {\n const T_CUST = ctx.$env.NOCODB_TABLE_CUSTOMERS;\n const T_CUSTPROD = ctx.$env.NOCODB_TABLE_CUSTOMERPRODUCTS;\n const T_PROD = ctx.$env.NOCODB_TABLE_PRODUCTS;\n const T_RES = ctx.$env.NOCODB_TABLE_RESOURCES;\n const T_GRANT = ctx.$env.NOCODB_TABLE_ACCESSGRANTS;\n const LNK_PROD_RES = ctx.$env.NOCODB_LINK_PROD_RESOURCES; // c4ko4x8kwi16swg\n const today = new Date().toISOString().slice(0, 10); // 'YYYY-MM-DD'\n\n // index all Resources by Id -> alist_path (cheap, one query)\n const allRes = (await ncGet(ctx, `/api/v2/tables/${T_RES}/records?limit=10000`)).list || [];\n const resPath = {};\n allRes.forEach(r => { resPath[r.Id] = r.alist_path; });\n\n const desired = {}; // path -> { expires, permission }\n\n // (a) CustomerProducts -> Resources (via product M2M link), MAX expiry on\n // the same path.\n // NOTE: `(customer,eq,N)` matches the link field's DISPLAY NAME and always\n // returns 0 rows. Must use the `customer.Id` sub-field syntax (or the\n // foreign-key column `nc_jitu___Customers_id`).\n const cps = (await ncGet(ctx, `/api/v2/tables/${T_CUSTPROD}/records?where=(customer.Id,eq,${customerId})&limit=1000`)).list || [];\n for (const c of cps) {\n if (c.status && c.status !== 'active') continue;\n if (c.expires_at && String(c.expires_at) <= today) continue; // expired (incl. today)\n const pid = linkId(c.product);\n if (!pid) continue;\n const res = (await ncGet(ctx, `/api/v2/tables/${T_PROD}/links/${LNK_PROD_RES}/records/${pid}`)).list || [];\n const exp = c.expires_at; // 'YYYY-MM-DD' (may be null = no expiry)\n for (const r of res) {\n const p = resPath[r.Id];\n if (!p) continue;\n const cur = desired[p];\n if (!cur || !cur.expires || (exp && exp > cur.expires)) {\n desired[p] = { expires: exp || null, permission: cur?.permission ?? 0 };\n }\n }\n }\n\n // (b) AccessGrants -> Resources (manual / materialised direct grants)\n const grants = (await ncGet(ctx, `/api/v2/tables/${T_GRANT}/records?where=(customer.Id,eq,${customerId})&limit=1000`)).list || [];\n for (const g of grants) {\n if (g.status && g.status !== 'active') continue;\n if (g.expires_at && String(g.expires_at) <= today) continue;\n const p = resPath[linkId(g.resource)];\n if (!p) continue;\n const exp = g.expires_at;\n const cur = desired[p];\n if (!cur || !cur.expires || (exp && exp > cur.expires)) {\n desired[p] = { expires: exp || null, permission: g.permission === 'readwrite' ? 1 : 0 };\n }\n }\n\n const desiredPaths = Object.keys(desired).sort();\n return {\n customerId,\n desired, // { path: {expires, permission} }\n desiredPaths, // sorted array of paths\n newScopes: desiredPaths.map(p => ({ path: p, permission: desired[p].permission })),\n };\n}\n\n// --- export shim: kept only when unit-tested, stripped when inlined -------\n// ============================================================================\n\nconst ctx = { $env, helpers: this.helpers };\nconst DRY_RUN = ($env.W3_DRY_RUN || '').toLowerCase() === 'true';\nconst AUTO_DISABLE = ($env.ALIST_AUTO_DISABLE_ON_EXPIRE || '').toLowerCase() === 'true';\nconst T_CUST = $env.NOCODB_TABLE_CUSTOMERS;\n\n// admin login once for the whole sweep\nconst login = await alistReq(ctx, 'POST', '/api/auth/login', {\n username: $env.ALIST_ADMIN_USERNAME,\n password: $env.ALIST_ADMIN_PASSWORD,\n}, null);\nconst token = login?.data?.token;\nif (!token) throw new Error('AList login failed');\n\nconst customers = (await ncGet(ctx, `/api/v2/tables/${T_CUST}/records?limit=10000`)).list || [];\nconst changed = [], errors = [];\n\nfor (const cust of customers) {\n try {\n if (!cust.alist_role_id) continue; // not provisioned yet\n const cid = cust.Id;\n\n // desired scopes via shared algorithm\n const g = await calculateEffectiveGrants(ctx, cid);\n const desiredPaths = g.desiredPaths;\n\n // current scopes from AList\n const role = await alistReq(ctx, 'GET', `/api/admin/role/get?id=${cust.alist_role_id}`, null, token);\n const currentPaths = (role?.data?.permission_scopes || []).map(s => s.path);\n const toAdd = desiredPaths.filter(p => !currentPaths.includes(p));\n const toRemove = currentPaths.filter(p => !desiredPaths.includes(p));\n const scopesChanged = toAdd.length > 0 || toRemove.length > 0;\n const nowEmpty = desiredPaths.length === 0;\n\n // only disable when a customer that HAD scopes now has none (guard\n // currentPaths>0 avoids killing a fresh customer with an empty role).\n const shouldDisable = AUTO_DISABLE && nowEmpty && cust.status === 'active' && currentPaths.length > 0;\n\n const applied = [];\n if (scopesChanged) {\n if (!DRY_RUN) await alistReq(ctx, 'POST', '/api/admin/role/update', {\n id: cust.alist_role_id,\n name: role?.data?.name,\n description: role?.data?.description,\n permission_scopes: g.newScopes,\n }, token);\n applied.push('role_scopes');\n }\n if (shouldDisable) {\n if (!DRY_RUN && cust.alist_user_id)\n await alistReq(ctx, 'POST', '/api/admin/user/update', { id: cust.alist_user_id, disabled: true }, token);\n applied.push('disable');\n }\n if (applied.length) {\n changed.push({\n customerId: cid, username: cust.username, roleId: cust.alist_role_id,\n toAdd, toRemove, desiredPaths, applied,\n });\n }\n } catch (e) {\n errors.push({ customerId: cust.Id, username: cust.username, error: e.message });\n }\n}\n\nreturn { json: {\n dryRun: DRY_RUN, autoDisable: AUTO_DISABLE, at: new Date().toISOString(),\n customersScanned: customers.length, customersChanged: changed.length,\n changed, errors,\n} };\n" }, "id": "reconcile", "name": "Reconcile & Apply (Expiry Sweep)", diff --git a/n8n/shared/effective-grants.js b/n8n/shared/effective-grants.js index ff908b4..9154cc5 100644 --- a/n8n/shared/effective-grants.js +++ b/n8n/shared/effective-grants.js @@ -76,7 +76,10 @@ async function calculateEffectiveGrants(ctx, customerId) { // (a) CustomerProducts -> Resources (via product M2M link), MAX expiry on // the same path. - const cps = (await ncGet(ctx, `/api/v2/tables/${T_CUSTPROD}/records?where=(customer,eq,${customerId})&limit=1000`)).list || []; + // NOTE: `(customer,eq,N)` matches the link field's DISPLAY NAME and always + // returns 0 rows. Must use the `customer.Id` sub-field syntax (or the + // foreign-key column `nc_jitu___Customers_id`). + const cps = (await ncGet(ctx, `/api/v2/tables/${T_CUSTPROD}/records?where=(customer.Id,eq,${customerId})&limit=1000`)).list || []; for (const c of cps) { if (c.status && c.status !== 'active') continue; if (c.expires_at && String(c.expires_at) <= today) continue; // expired (incl. today) @@ -95,7 +98,7 @@ async function calculateEffectiveGrants(ctx, customerId) { } // (b) AccessGrants -> Resources (manual / materialised direct grants) - const grants = (await ncGet(ctx, `/api/v2/tables/${T_GRANT}/records?where=(customer,eq,${customerId})&limit=1000`)).list || []; + const grants = (await ncGet(ctx, `/api/v2/tables/${T_GRANT}/records?where=(customer.Id,eq,${customerId})&limit=1000`)).list || []; for (const g of grants) { if (g.status && g.status !== 'active') continue; if (g.expires_at && String(g.expires_at) <= today) continue; diff --git a/n8n/test_effective_grants.js b/n8n/test_effective_grants.js index 56c2591..cd1c1db 100644 --- a/n8n/test_effective_grants.js +++ b/n8n/test_effective_grants.js @@ -64,11 +64,11 @@ function makeCtx() { } if (u.includes('/tr/records?limit=10000')) return { list: RESOURCES }; // CustomerProducts where customer eq - if (u.includes('/tcpc/records?where=(customer,eq,')) { + if (u.includes('/tcpc/records?where=(customer.Id,eq,')) { return { list: CUSTOMERPRODUCTS }; } // AccessGrants where customer eq - if (u.includes('/tag/records?where=(customer,eq,')) { + if (u.includes('/tag/records?where=(customer.Id,eq,')) { return { list: ACCESSGRANTS }; } // Product M2M link diff --git a/n8n/test_w1_lifecycle.js b/n8n/test_w1_lifecycle.js index 5b7f0e6..99e67a5 100644 --- a/n8n/test_w1_lifecycle.js +++ b/n8n/test_w1_lifecycle.js @@ -64,11 +64,11 @@ function mockHttp(opts) { if (u.includes('/tr/records?limit=10000')) return { list: state.resources }; // CP where customer eq - const cpm = u.match(/tcpc\/records\?where=\(customer,eq,(\d+)\)/); + const cpm = u.match(/tcpc\/records\?where=\(customer\.Id,eq,(\d+)\)/); if (cpm) return { list: state.cps.filter(c => c.customer === Number(cpm[1])) }; // grants where customer eq - const gm = u.match(/tag\/records\?where=\(customer,eq,(\d+)\)/); + const gm = u.match(/tag\/records\?where=\(customer\.Id,eq,(\d+)\)/); if (gm) return { list: state.grants.filter(g => g.customer === Number(gm[1])) }; // product M2M diff --git a/n8n/test_w3_refactored.js b/n8n/test_w3_refactored.js index 67895b3..9da10f7 100644 --- a/n8n/test_w3_refactored.js +++ b/n8n/test_w3_refactored.js @@ -54,9 +54,9 @@ sandbox.helpers = { if (u.endsWith('/api/auth/login')) return { data: { token: 'ADMIN_TOKEN' } }; if (u.includes('/tr/records?limit=10000')) return { list: RESOURCES }; if (u.includes('/tc/records?limit=10000')) return { list: CUSTOMERS }; - const cpm = u.match(/tcpc\/records\?where=\(customer,eq,(\d+)\)/); + const cpm = u.match(/tcpc\/records\?where=\(customer\.Id,eq,(\d+)\)/); if (cpm) return { list: CPS[Number(cpm[1])] || [] }; - const gm = u.match(/tag\/records\?where=\(customer,eq,(\d+)\)/); + const gm = u.match(/tag\/records\?where=\(customer\.Id,eq,(\d+)\)/); if (gm) return { list: GRANTS[Number(gm[1])] || [] }; const pm = u.match(/tp\/links\/lnk\/records\/(\d+)/); if (pm) return { list: PRODUCT_RES[Number(pm[1])] || [] };