@i4e/invest4edu-access-core 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@i4e/invest4edu-access-core",
3
- "version": "0.7.0",
3
+ "version": "0.9.0",
4
4
  "description": "Shared access-control primitives for NeoFindesk: tenant keystone, role capabilities, reportee tree, feature flags, and the unified access engine (registry schema, snapshot resolver, visibleWhen).",
5
5
  "type": "module",
6
6
  "exports": {
@@ -14,7 +14,8 @@
14
14
  "./access-resolver": "./src/access-resolver.js",
15
15
  "./visible-when": "./src/visible-when.js",
16
16
  "./entitlement": "./src/entitlement.js",
17
- "./entitlement-schema": "./src/entitlement-schema.js"
17
+ "./entitlement-schema": "./src/entitlement-schema.js",
18
+ "./grid-schema": "./src/grid-schema.js"
18
19
  },
19
20
  "scripts": {
20
21
  "test": "node --test test/"
@@ -12,7 +12,7 @@
12
12
  * Design: TDD-unified-access-and-subscription.md §4
13
13
  */
14
14
 
15
- import { parentCodeOf } from "./access-schema.js";
15
+ import { parentCodeOf, PRODUCT_UI_MODES } from "./access-schema.js";
16
16
 
17
17
  /** Reasons a feature can be locked, surfaced so the UI can distinguish upsell from hard-deny. */
18
18
  export const ENTITLEMENT_REASONS = Object.freeze({
@@ -219,6 +219,12 @@ export function resolveAccessSnapshot({
219
219
  actions: [],
220
220
  entitlement,
221
221
  tracking: !!f.tracking_enabled,
222
+ // How this SCREEN should present product-keyed things it renders — distinct from
223
+ // `products` above, which is *reachability* ("this screen exists only for these
224
+ // products"). The two are independent: Open Orders is product-agnostic yet renders
225
+ // product tabs that must be filtered, so the behaviour cannot be derived from the
226
+ // constraint. Default 'none' keeps every existing screen rendering exactly as today.
227
+ product_ui: PRODUCT_UI_MODES.includes(f.product_ui) ? f.product_ui : "none",
222
228
  };
223
229
 
224
230
  if (f.feature_type === "screen") allowedScreens.push(f);
@@ -23,6 +23,27 @@ export const REGISTRY_STATUSES = Object.freeze(["active", "dormant", "retired"])
23
23
  /** Per-feature enforcement ladder (mirrors the platform's off→warn→enforce doctrine). */
24
24
  export const ROLLOUT_MODES = Object.freeze(["off", "warn", "enforce"]);
25
25
 
26
+ /**
27
+ * How a SCREEN presents the product-keyed things it renders — product tiles, product tabs, the
28
+ * product column on Add Order, scheme lists.
29
+ *
30
+ * Deliberately separate from `products[]` on the same feature, which is *reachability* ("this
31
+ * screen exists only for these products"). The two are independent and neither implies the other:
32
+ * Open Orders is product-agnostic (`products: []`, it serves every product) yet renders product
33
+ * tabs that must be filtered; Product Setup is equally agnostic and should hide nothing. Same
34
+ * reachability, opposite presentation — so this cannot be derived, it has to be declared.
35
+ *
36
+ * none render everything. The default, so every existing screen is unchanged.
37
+ * filter hide product-keyed items outside the caller's scope. For transactional screens.
38
+ * readonly show everything, but out-of-scope items are not interactive. For configuration
39
+ * screens, where hiding a product reads as "the catalogue is broken" rather than as
40
+ * "you may not touch this".
41
+ *
42
+ * This is presentation only. The server-side gate is what actually refuses a write — hiding a
43
+ * tile has never stopped a POST.
44
+ */
45
+ export const PRODUCT_UI_MODES = Object.freeze(["none", "filter", "readonly"]);
46
+
26
47
  /** Grant effect. `deny` at the user level replaces the old `revoked_pages`. */
27
48
  export const GRANT_EFFECTS = Object.freeze(["allow", "deny"]);
28
49
 
@@ -96,6 +117,7 @@ export const ACCESS_FEATURE_DEF = Object.freeze({
96
117
  status: { type: "String", enum: REGISTRY_STATUSES, default: "active", index: true },
97
118
  kill_switch: { type: "String", enum: ["on", "off"], default: "off" },
98
119
  rollout_mode: { type: "String", enum: ROLLOUT_MODES, default: "off" },
120
+ product_ui: { type: "String", enum: PRODUCT_UI_MODES, default: "none" },
99
121
 
100
122
  // Survives an entitlement block (login, billing/checkout, profile) — see design §7.4.
101
123
  always_available: { type: "Boolean", default: false },
@@ -139,6 +161,7 @@ export default {
139
161
  FEATURE_TYPES,
140
162
  REGISTRY_STATUSES,
141
163
  ROLLOUT_MODES,
164
+ PRODUCT_UI_MODES,
142
165
  GRANT_EFFECTS,
143
166
  GRANT_SUBJECTS,
144
167
  FEATURE_CODE_PATTERN,
@@ -0,0 +1,231 @@
1
+ /**
2
+ * DataGrid v2 config — @i4e/invest4edu-access-core (P4). Definitions + validator. No React, no DB.
3
+ *
4
+ * ONE config defines a listing end to end: where the rows come from, which columns exist and who
5
+ * sees each, the filters, the row/bulk actions, the export, and the summary cards. It lives in the
6
+ * shared package because three consumers must agree on it exactly — the UI renders it, v1 serves
7
+ * and re-evaluates it, and the admin screen edits it.
8
+ *
9
+ * ── Why it is strictly serialisable ──────────────────────────────────────────────────────────
10
+ * No functions anywhere. A column says `type: "currency"`, never `cell: (row) => …`. That is the
11
+ * single property that makes a grid storable, diffable, admin-editable and server-re-evaluable.
12
+ * The moment one config carries a callback, it can no longer be saved — and the whole model
13
+ * collapses back into per-screen code.
14
+ *
15
+ * ── Why access binding lives IN the config ───────────────────────────────────────────────────
16
+ * `feature_code` gates the grid, every action carries an `action_code`, and `visibleWhen` gates
17
+ * columns/filters/metrics. So "who may see this column" and "who may press this button" are
18
+ * answered by the same engine that answers "who may open this screen" — not by a second, parallel
19
+ * set of role checks inside a component.
20
+ *
21
+ * `source.collection` is what lets the SERVER apply tenant + product + record scope to the query
22
+ * (scopedMatch). Without it a grid could be pointed at a collection nobody has scoped.
23
+ *
24
+ * Design: nfd-ui-nextjs/IV-CodingAgent/specs/access-management/TDD-unified-access-and-subscription.md §8
25
+ */
26
+
27
+ export const COLUMN_TYPES = Object.freeze([
28
+ "text", "number", "currency", "percent", "date", "datetime",
29
+ "boolean", "status-pill", "link", "actions", "inline-edit",
30
+ ]);
31
+
32
+ export const FILTER_TYPES = Object.freeze([
33
+ "text", "select", "multiselect", "reference", "boolean", "exists", "daterange", "numberrange",
34
+ ]);
35
+
36
+ export const AGGREGATIONS = Object.freeze(["sum", "avg", "count", "countWhere", "distinct", "raw"]);
37
+
38
+ export const GRID_SCOPES = Object.freeze(["global", "account"]);
39
+
40
+ /** The filter payload dialect the table and its summary endpoint MUST both speak. */
41
+ export const FILTER_DIALECT = "grid-ast-v1";
42
+
43
+ export const GRID_CONFIG_DEF = Object.freeze({
44
+ collection: "grid_configs",
45
+ fields: Object.freeze({
46
+ grid_code: { type: "String", required: true, index: true },
47
+ // Binds the whole grid to the access engine — no grid without a feature.
48
+ feature_code: { type: "String", required: true, index: true },
49
+ name: { type: "String" },
50
+ scope: { type: "String", enum: GRID_SCOPES, default: "global" },
51
+ // Set only on scope:'account' rows; the per-tenant override of a global config.
52
+ account_id: { type: "ObjectId", default: null, index: true },
53
+ config: { type: "Mixed", required: true },
54
+ // Bumped on every save. A saved user layout records the version it was made against, so the
55
+ // renderer can reconcile rather than break.
56
+ version: { type: "Number", default: 1 },
57
+ status: { type: "String", enum: ["active", "draft", "retired"], default: "draft" },
58
+ updated_by: { type: "ObjectId" },
59
+ updated_at: { type: "Date" },
60
+ }),
61
+ indexes: Object.freeze([
62
+ { keys: { grid_code: 1, scope: 1, account_id: 1 }, options: { unique: true } },
63
+ ]),
64
+ });
65
+
66
+ const isObj = (v) => !!v && typeof v === "object" && !Array.isArray(v);
67
+ const FEATURE_CODE = /^[A-Z][A-Z0-9_]*(\.[A-Z][A-Z0-9_]*){1,2}$/;
68
+
69
+ /**
70
+ * Validate a grid config. Returns `{ valid, errors[], warnings[] }`.
71
+ *
72
+ * Errors are things that would render a broken or unsafe grid; warnings are things that are legal
73
+ * but almost certainly a mistake. The admin screen must block on errors and show warnings — a
74
+ * config is edited by a human and saved straight into what everyone sees, so "it saved fine and
75
+ * then the screen was empty" is the failure mode this exists to prevent.
76
+ */
77
+ export function validateGridConfig(config) {
78
+ const errors = [];
79
+ const warnings = [];
80
+ const err = (m) => errors.push(m);
81
+ const warn = (m) => warnings.push(m);
82
+
83
+ if (!isObj(config)) return { valid: false, errors: ["config must be an object"], warnings };
84
+
85
+ if (!config.grid_code) err("grid_code is required");
86
+ if (!config.feature_code) err("feature_code is required — a grid must bind to the access engine");
87
+ else if (!FEATURE_CODE.test(config.feature_code)) err(`feature_code "${config.feature_code}" must be MODULE.SCREEN[.ACTION]`);
88
+
89
+ // ── source ──────────────────────────────────────────────────────────────────────────────────
90
+ const src = config.source;
91
+ if (!isObj(src)) err("source is required");
92
+ else {
93
+ if (!src.endpoint) err("source.endpoint is required");
94
+ // Without a collection the server cannot apply tenant/product/record scope to this grid.
95
+ if (!src.collection) err("source.collection is required — the server needs it to scope the query");
96
+ if (src.filter_dialect && src.filter_dialect !== FILTER_DIALECT) {
97
+ err(`source.filter_dialect must be "${FILTER_DIALECT}"`);
98
+ }
99
+ if (!src.id_field) warn("source.id_field not set — defaulting to _id");
100
+ }
101
+
102
+ // ── columns ─────────────────────────────────────────────────────────────────────────────────
103
+ const cols = Array.isArray(config.columns) ? config.columns : [];
104
+ if (!cols.length) err("at least one column is required");
105
+ const seen = new Set();
106
+ cols.forEach((c, i) => {
107
+ const at = `columns[${i}]`;
108
+ if (!isObj(c)) return err(`${at} must be an object`);
109
+ if (!c.key) err(`${at}.key is required`);
110
+ else if (seen.has(c.key)) err(`${at}.key "${c.key}" is duplicated — keys address saved layouts`);
111
+ else seen.add(c.key);
112
+ if (!c.header) warn(`${at} ("${c.key}") has no header`);
113
+ if (!c.path && c.type !== "actions") err(`${at} ("${c.key}") needs a path`);
114
+ if (c.type && !COLUMN_TYPES.includes(c.type)) err(`${at}.type "${c.type}" is not a known type`);
115
+ // A callback here is the one thing that would make the config unstorable.
116
+ for (const k of Object.keys(c)) {
117
+ if (typeof c[k] === "function") err(`${at}.${k} is a function — configs must be serialisable`);
118
+ }
119
+ });
120
+ if (cols.length && !cols.some((c) => c.defaultVisible !== false)) {
121
+ err("every column is hidden by default — the grid would render empty");
122
+ }
123
+
124
+ // ── filters ─────────────────────────────────────────────────────────────────────────────────
125
+ (config.filters || []).forEach((f, i) => {
126
+ const at = `filters[${i}]`;
127
+ if (!f?.key) err(`${at}.key is required`);
128
+ if (f?.type && !FILTER_TYPES.includes(f.type)) err(`${at}.type "${f.type}" is not a known type`);
129
+ if (f?.type === "select" && !f.options && !f.ref) {
130
+ warn(`${at} ("${f.key}") is a select with neither options nor ref — it will render empty`);
131
+ }
132
+ });
133
+
134
+ // ── actions: each is a FEATURE, so it must carry a code ─────────────────────────────────────
135
+ const checkActions = (list, label) => (list || []).forEach((a, i) => {
136
+ const at = `${label}[${i}]`;
137
+ if (!a?.action_code) {
138
+ err(`${at} has no action_code — actions are gated features, an ungated action is a hole`);
139
+ } else if (!FEATURE_CODE.test(a.action_code)) {
140
+ err(`${at}.action_code "${a.action_code}" must be MODULE.SCREEN.ACTION`);
141
+ }
142
+ if (!a?.label) warn(`${at} has no label`);
143
+ // Destructive things must ask. Cheap to require, expensive to forget.
144
+ if (a?.kind === "mutation" && /delete|cancel|remove|revoke/i.test(a.action_code || "") && !a.confirm) {
145
+ warn(`${at} ("${a.action_code}") looks destructive but has no confirm`);
146
+ }
147
+ });
148
+ checkActions(config.rowActions, "rowActions");
149
+ checkActions(config.bulkActions, "bulkActions");
150
+
151
+ if (config.export && !config.export.action_code) {
152
+ err("export needs an action_code — exports are gated and meterable like any other action");
153
+ }
154
+
155
+ // ── summary ─────────────────────────────────────────────────────────────────────────────────
156
+ if (config.summary) {
157
+ const s = config.summary;
158
+ if (!Array.isArray(s.metrics) || !s.metrics.length) warn("summary has no metrics");
159
+ // The rule the MIS rollups taught us: cards and the table must answer the same question.
160
+ if (s.source === "endpoint" && !s.endpoint) {
161
+ err("summary.source is 'endpoint' but no summary.endpoint is set");
162
+ }
163
+ (s.metrics || []).forEach((m, i) => {
164
+ const at = `summary.metrics[${i}]`;
165
+ if (!m?.key) err(`${at}.key is required`);
166
+ if (m?.agg && !AGGREGATIONS.includes(m.agg)) err(`${at}.agg "${m.agg}" is not a known aggregation`);
167
+ if (m?.agg && m.agg !== "count" && !m.path) err(`${at} ("${m.key}") needs a path for agg "${m.agg}"`);
168
+ if (m?.agg === "countWhere" && !m.where) err(`${at} ("${m.key}") is countWhere with no where clause`);
169
+ });
170
+ }
171
+
172
+ // ── pagination / sort ───────────────────────────────────────────────────────────────────────
173
+ if (config.pagination) {
174
+ const p = config.pagination;
175
+ if (p.defaultSize && Array.isArray(p.pageSizes) && !p.pageSizes.includes(p.defaultSize)) {
176
+ err(`pagination.defaultSize ${p.defaultSize} is not one of pageSizes`);
177
+ }
178
+ }
179
+ if (config.defaultSort?.key && !seen.has(config.defaultSort.key)) {
180
+ err(`defaultSort.key "${config.defaultSort.key}" is not a column key`);
181
+ }
182
+ if (config.totalsRow?.perColumn) {
183
+ config.totalsRow.perColumn.forEach((t, i) => {
184
+ if (!seen.has(t.key)) err(`totalsRow.perColumn[${i}].key "${t.key}" is not a column key`);
185
+ });
186
+ }
187
+
188
+ return { valid: errors.length === 0, errors, warnings };
189
+ }
190
+
191
+ /**
192
+ * Reconcile a user's saved layout against the current config.
193
+ *
194
+ * A layout is preference, never permission — so this drops anything the user may no longer see and
195
+ * fills gaps from the config. It cannot fail: the worst case is that the layout contributes nothing
196
+ * and the user gets the default grid.
197
+ *
198
+ * @param {Object} config the grid config
199
+ * @param {Object|null} layout the user's saved layout
200
+ * @param {string[]|null} permittedKeys column keys the access engine allows (null = all)
201
+ */
202
+ export function applyLayout(config, layout, permittedKeys = null) {
203
+ const cols = Array.isArray(config?.columns) ? config.columns : [];
204
+ const allowed = permittedKeys ? new Set(permittedKeys) : null;
205
+ // Permission first, preference second. Reversing these would let a stale layout pin access.
206
+ const visible = cols.filter((c) => !allowed || allowed.has(c.key));
207
+
208
+ const saved = Array.isArray(layout?.columns) ? layout.columns.filter((k) => visible.some((c) => c.key === k)) : null;
209
+ const ordered = saved && saved.length
210
+ ? [
211
+ ...saved.map((k) => visible.find((c) => c.key === k)),
212
+ // Columns added to the config since the layout was saved stay available, just not pinned.
213
+ ...visible.filter((c) => !saved.includes(c.key)).map((c) => ({ ...c, defaultVisible: false })),
214
+ ]
215
+ : visible;
216
+
217
+ return {
218
+ columns: ordered.filter(Boolean),
219
+ filters: layout?.filters ?? {},
220
+ sort: layout?.sort ?? config?.defaultSort ?? null,
221
+ pageSize: layout?.pageSize ?? config?.pagination?.defaultSize ?? 25,
222
+ // True when the layout referenced something that no longer exists — the UI can offer a reset
223
+ // instead of leaving someone confused by a view that silently lost columns.
224
+ stale: !!(layout?.columns && saved && saved.length !== layout.columns.length),
225
+ };
226
+ }
227
+
228
+ export default {
229
+ COLUMN_TYPES, FILTER_TYPES, AGGREGATIONS, GRID_SCOPES, FILTER_DIALECT,
230
+ GRID_CONFIG_DEF, validateGridConfig, applyLayout,
231
+ };