@dforge-core/dforge-mcp 0.2.14 → 0.2.16
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/CHANGELOG.md +239 -1
- package/dist/server.js +155 -7
- package/package.json +2 -2
- package/resources/docs/conventions.md +74 -1
- package/resources/docs/dsl-reference.md +58 -1
- package/resources/schemas/entity.schema.json +80 -2
- package/resources/schemas/folders.schema.json +1 -1
- package/skills/dforge-mcp-author/examples/column-security/README.md +67 -0
- package/skills/dforge-mcp-author/examples/column-security/entities/product.json +79 -0
- package/skills/dforge-mcp-author/examples/column-security/manifest.json +23 -0
- package/skills/dforge-mcp-author/examples/column-security/security/roles.json +16 -0
- package/skills/dforge-mcp-author/examples/column-security/translations/en-US.json +35 -0
- package/skills/dforge-mcp-author/examples/column-security/ui/data_views.json +20 -0
- package/skills/dforge-mcp-author/examples/column-security/ui/folders.json +23 -0
- package/skills/dforge-mcp-author/examples/column-security/ui/menus.json +21 -0
- package/skills/dforge-mcp-author/examples/simple-todo/manifest.json +8 -12
- package/skills/dforge-mcp-author/references/action-dsl.md +113 -29
- package/skills/dforge-mcp-author/references/column-types.md +3 -2
- package/skills/dforge-mcp-author/references/data-views.md +2 -0
- package/skills/dforge-mcp-author/references/flags.md +41 -9
- package/skills/dforge-mcp-author/references/formulas.md +13 -1
- package/skills/dforge-mcp-author/references/jobs.md +4 -1
- package/skills/dforge-mcp-author/references/security.md +77 -1
- package/skills/dforge-mcp-author/references/validation-checklist.md +12 -0
- package/skills/dforge-module-build/SKILL.md +13 -2
package/CHANGELOG.md
CHANGED
|
@@ -4,7 +4,245 @@ All notable changes to `@dforge-core/dforge-mcp`. This project uses semver-ish
|
|
|
4
4
|
`0.1.0-rc.N` pre-release tags; the published version is set at publish time via
|
|
5
5
|
the release workflow, so committed `package.json` versions are placeholders.
|
|
6
6
|
|
|
7
|
-
## 0.2.
|
|
7
|
+
## 0.2.16
|
|
8
|
+
|
|
9
|
+
Entity views — the platform's **column-level security** — became reachable from a
|
|
10
|
+
module package (platform change `e6e29b604`). A view is a named subset of an
|
|
11
|
+
entity's columns declared under `views` on the entity file; a folder binds one per
|
|
12
|
+
entity with `entities.<code>.viewName`, and users working there see only the
|
|
13
|
+
columns that view lists. The schema had gained the property while nothing here
|
|
14
|
+
knew about it, so authoring one meant an editor error on valid metadata and a
|
|
15
|
+
refactor that could quietly break the module.
|
|
16
|
+
|
|
17
|
+
### Added — entity views are validated offline
|
|
18
|
+
|
|
19
|
+
`dforge_module_validate` now mirrors what the platform installer rejects, all of
|
|
20
|
+
which is silent at runtime if it slips through:
|
|
21
|
+
|
|
22
|
+
| Check | Why it matters |
|
|
23
|
+
|---|---|
|
|
24
|
+
| Column exists on the entity | A stale name is simply not in the view |
|
|
25
|
+
| View lists the primary key | Records are addressed by it — the client cannot open a row without it |
|
|
26
|
+
| View has at least one column | A view is the COMPLETE visible set, so an empty one would hide every field |
|
|
27
|
+
| No two views differing only by case | A folder binds case-insensitively; only one could ever be reached |
|
|
28
|
+
| No column listed twice under different casing | Both name one column and the later would silently win |
|
|
29
|
+
| `formula` override only on a `columnType: "F"` column | Elsewhere that field is the SQL default and the install FAILS — a view cannot change a physical column's `DEFAULT` |
|
|
30
|
+
| `folders.json` `viewName` resolves to a declared view | **Fails the install** — it does not fall back, because falling back shows every column |
|
|
31
|
+
|
|
32
|
+
`"default"` is exempt from the last check and means "no view": every shipped module
|
|
33
|
+
writes it, and the platform auto-creates a column-less row under that name for any
|
|
34
|
+
entity reached without a folder binding.
|
|
35
|
+
|
|
36
|
+
### Fixed — refactors reach into views
|
|
37
|
+
|
|
38
|
+
`dforge_entity_field_rename` and `dforge_entity_field_remove` rewrote fields, links,
|
|
39
|
+
formulas, data views and seed data but never touched `views.<v>.columns.<cd>`. Since
|
|
40
|
+
the platform now rejects a view naming a column the entity does not have, a rename
|
|
41
|
+
handed back a module that no longer installs. Both cascade into entity views now,
|
|
42
|
+
including per-view `formula` overrides, preserving the view's column order (which is
|
|
43
|
+
its display order). Removal warns when a view is left with no columns, or when a
|
|
44
|
+
per-view formula still names a removed field — the same judgement-call warnings the
|
|
45
|
+
tool already emitted for entity formulas.
|
|
46
|
+
|
|
47
|
+
### Fixed — `viewName` was documented as a data view code
|
|
48
|
+
|
|
49
|
+
It is not, and the mistake ran from `schemas/folders.schema.json` through
|
|
50
|
+
`references/security.md` to `dforge-module-build`'s Phase 3, which claimed
|
|
51
|
+
`"default"` was "a fallback alias the platform resolves to the entity's first
|
|
52
|
+
declared view". It resolves to *no view* (the full column set), and any other
|
|
53
|
+
undeclared name now fails the install — so the wrong mental model produced a hard
|
|
54
|
+
error rather than a shrug. Data view codes are what a menu's `dataViewCode`
|
|
55
|
+
references.
|
|
56
|
+
|
|
57
|
+
### Changed
|
|
58
|
+
|
|
59
|
+
- `@dforge-core/metadata` → `0.0.18`; `resources/schemas/` re-vendored, so
|
|
60
|
+
`entity.schema.json` carries `views` (with the per-column `formula` override), the
|
|
61
|
+
retired `H` flag and the per-view `M` fold below, and the editor stops flagging a
|
|
62
|
+
valid `views` block as an unknown property. The
|
|
63
|
+
`minimumReleaseAgeExclude` pin in `pnpm-workspace.yaml` moves with it — a caret on
|
|
64
|
+
a `0.0.x` version pins the patch, so the dep does not float onto a new metadata
|
|
65
|
+
release by itself.
|
|
66
|
+
- `references/security.md` gains a **Column-level security** section — the layer the
|
|
67
|
+
3-layer model named but never documented — with the rules, the empty-override trap
|
|
68
|
+
(`COALESCE` skips NULL only, so `"flags": ""` means "no flags", not "inherit"), and
|
|
69
|
+
a table disambiguating the platform's three "views": entity views, data views, and
|
|
70
|
+
`isView`/`viewSql` SQL-backed entities.
|
|
71
|
+
- Cross-references in `references/flags.md` (the same letters override per view),
|
|
72
|
+
`references/formulas.md` (the per-view override and its `"F"`-only rule) and
|
|
73
|
+
`references/data-views.md` (the name collision); two new lines in
|
|
74
|
+
`references/validation-checklist.md`; `dforge-module-build` Phase 5 gains guidance
|
|
75
|
+
on when column-level security is the right answer instead of more roles.
|
|
76
|
+
- `dforge_module_inspect` reports `entityViews` per entity (name → column count).
|
|
77
|
+
|
|
78
|
+
### Removed — the `H` flag
|
|
79
|
+
|
|
80
|
+
`H` was in the flag vocabulary from the start, documented as "permanently hidden —
|
|
81
|
+
stronger than omitting `V`", and **nothing ever read it**. A column carrying it was
|
|
82
|
+
hidden because its flags string contained no `V`. The platform retired it
|
|
83
|
+
(`abbedb009`), so `field-rules` now rejects `H` with a message pointing at the fix
|
|
84
|
+
rather than at the generic "U/S/P are not flag letters" hint: omit `V` to hide a
|
|
85
|
+
column, use an entity view to hide it from specific folders, `fieldTypeCd: "hidden"`
|
|
86
|
+
to render it as a hidden input, and `I` to mark it platform-managed.
|
|
87
|
+
|
|
88
|
+
`references/flags.md` and the `dforge://reference/flags` blurb drop it too, and both
|
|
89
|
+
`^[VIEMH]+$` checks in `field-rules` are now `^[VIEM]+$`.
|
|
90
|
+
|
|
91
|
+
### Added — `M` in a per-view override
|
|
92
|
+
|
|
93
|
+
`views.<v>.columns.<cd>.flags` previously accepted `M` and did nothing with it: the
|
|
94
|
+
platform's `MandatoryFlagNormalizer` folded `M` into `isNullable` for entity fields
|
|
95
|
+
only. The fold now covers view overrides, so the shorthand means the same thing at
|
|
96
|
+
both levels — and because a view writes no DDL, the requiredness it produces is
|
|
97
|
+
runtime-only. That makes **"nullable in the database, mandatory in this view"**
|
|
98
|
+
expressible for the first time:
|
|
99
|
+
|
|
100
|
+
```json
|
|
101
|
+
"vat_no": { "flags": "VEM" } // required in this view
|
|
102
|
+
"vat_no": { "flags": "VE", "isNullable": false } // the same thing, spelled out
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
`references/flags.md` gains a **Per-view overrides** section covering it, plus the
|
|
106
|
+
two traps that come with it: the letters **replace** rather than merge (so
|
|
107
|
+
`{ "flags": "V" }` is read-only however the entity flagged the column), and the `M`
|
|
108
|
+
fold is **one-directional** — dropping `M` from a view does not make a column
|
|
109
|
+
optional, since `isNullable` still inherits. `references/security.md` picks up the
|
|
110
|
+
same note.
|
|
111
|
+
|
|
112
|
+
### Fixed — smaller documentation corrections
|
|
113
|
+
|
|
114
|
+
- **`I` was called "Internal" throughout `references/flags.md`.** The runtime reads
|
|
115
|
+
it as **Identity** (`EntityColumn.IsIdentity => HasFlag('I')`); the letter is
|
|
116
|
+
unchanged, the name was wrong in the letter table, two rules and the role-rights
|
|
117
|
+
disambiguation note.
|
|
118
|
+
- **`"flags": null` was implied to mean "inherit".** It does not validate — the
|
|
119
|
+
property is typed `string`, so only OMITTING the key inherits. (To inherit
|
|
120
|
+
everything, set the whole column override to `{}` or `null`.)
|
|
121
|
+
|
|
122
|
+
### Added — a worked example module
|
|
123
|
+
|
|
124
|
+
`examples/column-security/` (served as `dforge://example/column-security/…`): one
|
|
125
|
+
`product` entity carrying both stock and pricing, two folders showing two different
|
|
126
|
+
column sets of it. It is the first bundled example with a `ui/folders.json` at all,
|
|
127
|
+
so the *binding* half of column-level security had no example anywhere before.
|
|
128
|
+
|
|
129
|
+
Its `accountant` view demonstrates both override kinds — `{ "flags": "V" }` making a
|
|
130
|
+
column read-only in that view only, and a per-view `formula` computing margin as a
|
|
131
|
+
percentage where the entity computes an amount — while `storekeeper` shows the plain
|
|
132
|
+
case where the price columns are simply absent. The README lists the five ways a view
|
|
133
|
+
fails the install, and a test pins the module at zero errors AND zero warnings, since
|
|
134
|
+
a warning copied out of an example propagates into every module built from it.
|
|
135
|
+
|
|
136
|
+
Note its manifest declares no `dataViews`/`menus`/`security` pointers: those files are
|
|
137
|
+
found by convention and the manifest schema is `additionalProperties: false`.
|
|
138
|
+
|
|
139
|
+
### Fixed — `simple-todo`'s manifest was not schema-valid
|
|
140
|
+
|
|
141
|
+
It declared `dataViews`, `menus`, `actions`, `security` and `seedData` pointers. None
|
|
142
|
+
of those are manifest properties, the schema is `additionalProperties: false`, and no
|
|
143
|
+
shipped module declares them — every path is resolved by convention from the module
|
|
144
|
+
root (`ui/data_views.json`, `security/roles.json`, …), here and in the platform alike.
|
|
145
|
+
Removed. This matters more than a stray key usually would: the examples are served as
|
|
146
|
+
resources and described to agents as mandatory structures to copy, so an invalid shape
|
|
147
|
+
in one propagates into every module built from it.
|
|
148
|
+
|
|
149
|
+
### Fixed — the view-column flag vocabulary
|
|
150
|
+
|
|
151
|
+
`entity.schema.json` described a view column's `flags` as `I/E/V/O/G/S/F/X/W`, with the
|
|
152
|
+
example `'VO'` "to expose it read-only". No such letters: the canonical set is
|
|
153
|
+
`V/I/E/M/H` (`flagDefs`), the client `Column` honours `V`/`E`/`I`, and the installer
|
|
154
|
+
resolves `M` into `isNullable`. Nothing reads `O`, `G`, `S`, `F`, `X` or `W`. Read-only
|
|
155
|
+
is plain `"V"` — visible, not editable.
|
|
156
|
+
|
|
157
|
+
`references/flags.md` here was already right, and both the new guidance and the
|
|
158
|
+
`column-security` example use only `V`/`VE`. Fixed upstream and picked up with
|
|
159
|
+
`@dforge-core/metadata` **0.0.17**, so `resources/schemas/entity.schema.json` now says
|
|
160
|
+
the same thing — which matters because that is the copy the editor `$schema` binding
|
|
161
|
+
validates against.
|
|
162
|
+
|
|
163
|
+
## 0.2.15
|
|
164
|
+
|
|
165
|
+
`canExecute` became a real server-side gate and `isTransacted` began opening a real
|
|
166
|
+
transaction (platform change `5398a48b0`). Both had been documented here as the
|
|
167
|
+
opposite — "a UI hint" and "does not open a transaction" — so this release is a
|
|
168
|
+
correction pass across the authoring guidance, with no tool or schema changes.
|
|
169
|
+
|
|
170
|
+
### Changed — `canExecute` is enforced, with a fail-open subset
|
|
171
|
+
|
|
172
|
+
`action.execute` now evaluates the stored `can_execute_parsed` AST over every targeted
|
|
173
|
+
record before any write and refuses the call with `ACTION_EXECUTION_FAIL`. So
|
|
174
|
+
`canExecute: false` is a genuine "only automation may fire this" — the scheduler and
|
|
175
|
+
triggers invoke the script engine directly and still bypass it.
|
|
176
|
+
|
|
177
|
+
The server evaluator is a scalar walker, not the formula engine, and the client has its
|
|
178
|
+
own separate gaps, so `references/action-dsl.md` and `dsl-reference.md` now carry a tier
|
|
179
|
+
table instead of a single list:
|
|
180
|
+
|
|
181
|
+
| tier | construct | client | server |
|
|
182
|
+
|---|---|---|---|
|
|
183
|
+
| 1 | **comparisons** (`=` `!=` `<` `<=` `>` `>=`) between fields and literals, combined with `AND` / `OR` / `NOT`; a bare `true` / `false` | evaluates | **enforces** |
|
|
184
|
+
| 2 | arithmetic, `IN`, `BETWEEN`, any formula function | evaluates | fail-open |
|
|
185
|
+
| 3 | ref navigation, `SUM` / `COUNT` / `AVG`, any unknown function (a typo included) | **fail-open (throws)** | fail-open |
|
|
186
|
+
| — | `$[setting]` references | **evaluates as null** (no throw) | fail-open |
|
|
187
|
+
|
|
188
|
+
Four things in there are easy to get wrong and are called out explicitly:
|
|
189
|
+
|
|
190
|
+
- **A bare boolean field is not enforced.** The walker evaluates conditions, not values —
|
|
191
|
+
a field resolves only as an operand of a comparison — so `canExecute: [is_active]` and
|
|
192
|
+
`NOT [is_blocked]` fail open. Write `[is_active] = true` and `NOT ([is_blocked] = true)`.
|
|
193
|
+
- **A misspelled function ships silently.** A `canExecute` is never validated against the
|
|
194
|
+
function set at install (`FindUnknownFunctions` is wired only into the formula-column
|
|
195
|
+
path), so `TOADY()` installs and enables the button for every record.
|
|
196
|
+
- **Tier 3 mixed into a predicate is worse than useless**, because the two sides disagree
|
|
197
|
+
about short-circuiting: the server's `AND` / `OR` short-circuit, the client's evaluator
|
|
198
|
+
does not. `[status] = 'Pending' AND COUNT([lines]) > 0` on a non-`Pending` record throws
|
|
199
|
+
in the browser, is caught, and **enables** the button — then the server refuses the call.
|
|
200
|
+
An error where a greyed-out button was wanted. Count with a Generated (`G`) column and
|
|
201
|
+
compare the scalar. Mixing tier 1 with tier 2 is fine and is the normal shape.
|
|
202
|
+
- **Settings do not work in `canExecute` at all** — the client resolver is stubbed to
|
|
203
|
+
`null` and the server has none, so the guard is unenforced server-side and tends to jam
|
|
204
|
+
the button *shut* client-side. Read them in `execute:`.
|
|
205
|
+
|
|
206
|
+
`references/jobs.md` also stops promising that `canExecute: false` hides the button: the
|
|
207
|
+
action stays in the toolbar, permanently disabled.
|
|
208
|
+
|
|
209
|
+
### Changed — `isTransacted` opens a transaction, and defaults to `true`
|
|
210
|
+
|
|
211
|
+
The *Don't assume your action is atomic* section is replaced by *Atomicity*. `true` now
|
|
212
|
+
makes the whole run one transaction in both execution modes, covering the script's own
|
|
213
|
+
`insert()` / `update()` / `query()` calls as well as the flush of `[field] = …`
|
|
214
|
+
assignments, and it no longer depends on the entity carrying `auditHistory` — advice to
|
|
215
|
+
reach for that trait as an atomicity switch is gone.
|
|
216
|
+
|
|
217
|
+
`false` is documented per mode, because "failures are isolated" only ever made sense where
|
|
218
|
+
there is a loop: `single` / `each` report the failure and continue with the next record,
|
|
219
|
+
while a `batch` script is one invocation, so a failure just ends the run with its earlier
|
|
220
|
+
writes committed and its `[field]` assignments never flushed.
|
|
221
|
+
|
|
222
|
+
The background path is the exception, and it splits by mode too: the worker opens no
|
|
223
|
+
transaction, so nothing is ever undone; its `single` / `each` loop still reads the flag for
|
|
224
|
+
control flow, while its `batch` path never reads it at all — a queued `batch` action gets
|
|
225
|
+
nothing from `isTransacted` in either direction.
|
|
226
|
+
|
|
227
|
+
### Fixed — `isAsync` does not force background execution, and there is no Hangfire
|
|
228
|
+
|
|
229
|
+
`isAsync: true` only *permits* queuing. The server branches on the `async` argument the
|
|
230
|
+
request carries, so a parameterless action is queued directly while one with parameters is
|
|
231
|
+
offered to the user as both "Run" (inline, transactional) and "Run in Background". Whether
|
|
232
|
+
an action is atomic is therefore not answerable from the manifest alone.
|
|
233
|
+
|
|
234
|
+
Two claims that dForge runs background actions "via Hangfire" are removed — the platform
|
|
235
|
+
uses a hosted `BackgroundService` polling `background_action`, and `FileCleanupService`
|
|
236
|
+
says so in as many words.
|
|
237
|
+
|
|
238
|
+
### Changed — validation checklist
|
|
239
|
+
|
|
240
|
+
Two new items under actions: that a `canExecute` meant to *gate* is written in the
|
|
241
|
+
server-enforced subset, and that no bare boolean field is used as a whole condition. The
|
|
242
|
+
`references/formulas.md` entry for `canExecute:` blocks now summarises the same rules and
|
|
243
|
+
points at `action-dsl.md`.
|
|
244
|
+
|
|
245
|
+
## 0.2.14
|
|
8
246
|
|
|
9
247
|
Record reports — a report attached to an entity so it opens **from a record**, with the
|
|
10
248
|
record's values feeding its parameters — plus a report-level home for report parameters,
|
package/dist/server.js
CHANGED
|
@@ -31214,7 +31214,7 @@ function buildZedSettings() {
|
|
|
31214
31214
|
var fs = __toESM(require("fs"));
|
|
31215
31215
|
var path = __toESM(require("path"));
|
|
31216
31216
|
|
|
31217
|
-
// node_modules/.pnpm/@dforge-core+metadata@0.0.
|
|
31217
|
+
// node_modules/.pnpm/@dforge-core+metadata@0.0.18/node_modules/@dforge-core/metadata/dist/index.js
|
|
31218
31218
|
var AggType = {
|
|
31219
31219
|
Sum: "sum",
|
|
31220
31220
|
Count: "count",
|
|
@@ -33137,9 +33137,10 @@ function checkFieldSpec(where, field) {
|
|
|
33137
33137
|
'dropdown options go under params.options, not at the field root, e.g. "params": { "options": [{ "value": "a", "label": "A" }] }.'
|
|
33138
33138
|
);
|
|
33139
33139
|
}
|
|
33140
|
-
if (typeof v.flags === "string" && v.flags.length > 0 && !/^[
|
|
33140
|
+
if (typeof v.flags === "string" && v.flags.length > 0 && !/^[VIEM]+$/.test(v.flags)) {
|
|
33141
|
+
const hint = v.flags.includes("H") ? " There is no 'H' flag \u2014 a column is hidden by omitting 'V'." : " U/S/P are not flag letters.";
|
|
33141
33142
|
err(
|
|
33142
|
-
`flags '${v.flags}' contains invalid letters \u2014 use only V/I/E/M
|
|
33143
|
+
`flags '${v.flags}' contains invalid letters \u2014 use only V/I/E/M (e.g. VEM, VE, V, E).${hint}`
|
|
33143
33144
|
);
|
|
33144
33145
|
}
|
|
33145
33146
|
if (typeof v.flags === "string" && v.flags.includes("M") && v.isNullable === true) {
|
|
@@ -33186,7 +33187,7 @@ function checkFieldSpec(where, field) {
|
|
|
33186
33187
|
if (typeof v.formula !== "string" || v.formula.trim() === "") {
|
|
33187
33188
|
err("formula column (columnType 'F') requires a non-empty 'formula' expression.");
|
|
33188
33189
|
}
|
|
33189
|
-
if (typeof v.flags === "string" && v.flags !== "V" && /^[
|
|
33190
|
+
if (typeof v.flags === "string" && v.flags !== "V" && /^[VIEM]+$/.test(v.flags)) {
|
|
33190
33191
|
warn(
|
|
33191
33192
|
`formula column has flags '${v.flags}' \u2014 a computed column is read-only, expected flags 'V'.`
|
|
33192
33193
|
);
|
|
@@ -33717,6 +33718,23 @@ function entityFieldRename(args) {
|
|
|
33717
33718
|
changes.push(`references.${rname}.from.field`);
|
|
33718
33719
|
}
|
|
33719
33720
|
}
|
|
33721
|
+
const entityViews = entity.views ?? {};
|
|
33722
|
+
for (const [vname, v] of Object.entries(entityViews)) {
|
|
33723
|
+
const cols = v.columns;
|
|
33724
|
+
if (!cols) continue;
|
|
33725
|
+
if (oldName in cols) {
|
|
33726
|
+
v.columns = renameKey(cols, oldName, newName);
|
|
33727
|
+
changes.push(`views.${vname}.columns.${oldName}`);
|
|
33728
|
+
}
|
|
33729
|
+
for (const [cn, ov] of Object.entries(
|
|
33730
|
+
v.columns
|
|
33731
|
+
)) {
|
|
33732
|
+
if (ov && typeof ov.formula === "string" && ov.formula.includes(oldToken)) {
|
|
33733
|
+
ov.formula = ov.formula.split(oldToken).join(newToken);
|
|
33734
|
+
changes.push(`views.${vname}.${cn}.formula`);
|
|
33735
|
+
}
|
|
33736
|
+
}
|
|
33737
|
+
}
|
|
33720
33738
|
files[rel(paths.root, entityPath)] = jsonText(entity);
|
|
33721
33739
|
for (const [oname, orel] of Object.entries(manifest.entities ?? {})) {
|
|
33722
33740
|
if (oname === entityName || oname.includes(".")) continue;
|
|
@@ -33840,6 +33858,31 @@ function entityFieldRemove(args) {
|
|
|
33840
33858
|
changes.push(`references.${rn}`);
|
|
33841
33859
|
}
|
|
33842
33860
|
}
|
|
33861
|
+
const entityViews = entity.views ?? {};
|
|
33862
|
+
for (const [vname, v] of Object.entries(entityViews)) {
|
|
33863
|
+
const cols = v.columns;
|
|
33864
|
+
if (!cols) continue;
|
|
33865
|
+
const kept = {};
|
|
33866
|
+
for (const [k, val] of Object.entries(cols)) if (!removed.has(k)) kept[k] = val;
|
|
33867
|
+
if (Object.keys(kept).length !== Object.keys(cols).length) {
|
|
33868
|
+
v.columns = kept;
|
|
33869
|
+
changes.push(`views.${vname}.columns`);
|
|
33870
|
+
}
|
|
33871
|
+
if (Object.keys(kept).length === 0) {
|
|
33872
|
+
warnings.push(
|
|
33873
|
+
`view '${vname}' has no columns left \u2014 delete the view, or give it columns (an empty view fails the install)`
|
|
33874
|
+
);
|
|
33875
|
+
}
|
|
33876
|
+
for (const [cn, ov] of Object.entries(kept)) {
|
|
33877
|
+
if (ov && typeof ov.formula === "string") {
|
|
33878
|
+
for (const r of removed) {
|
|
33879
|
+
if (ov.formula.includes(`[${r}]`)) {
|
|
33880
|
+
warnings.push(`formula in view '${vname}'.${cn} still references removed '[${r}]'`);
|
|
33881
|
+
}
|
|
33882
|
+
}
|
|
33883
|
+
}
|
|
33884
|
+
}
|
|
33885
|
+
}
|
|
33843
33886
|
files[rel(paths.root, entityPath)] = jsonText(entity);
|
|
33844
33887
|
for (const [fn, f] of Object.entries(entity.fields)) {
|
|
33845
33888
|
if (typeof f.formula !== "string") continue;
|
|
@@ -35372,6 +35415,11 @@ function moduleInspect(args) {
|
|
|
35372
35415
|
fieldCount: Object.keys(fields).length,
|
|
35373
35416
|
fields: Object.keys(fields),
|
|
35374
35417
|
hasNumberSequence: Boolean(e.numberSequence),
|
|
35418
|
+
entityViews: Object.fromEntries(
|
|
35419
|
+
Object.entries(e.views ?? {}).map(
|
|
35420
|
+
([vname, v]) => [vname, Object.keys(v?.columns ?? {}).length]
|
|
35421
|
+
)
|
|
35422
|
+
),
|
|
35375
35423
|
// Read as an OWN property — `toString` is inherited from
|
|
35376
35424
|
// Object.prototype, so a plain `e.toString` reports the built-in
|
|
35377
35425
|
// function (which JSON.stringify then silently drops) for every
|
|
@@ -35615,6 +35663,67 @@ function moduleValidate(args) {
|
|
|
35615
35663
|
}
|
|
35616
35664
|
}
|
|
35617
35665
|
}
|
|
35666
|
+
for (const [name, e] of Object.entries(entities)) {
|
|
35667
|
+
const entityViews = e.views ?? {};
|
|
35668
|
+
if (Object.keys(entityViews).length === 0) continue;
|
|
35669
|
+
const fields = fieldDefsOf[name] ?? {};
|
|
35670
|
+
const cols = columnsOf[name];
|
|
35671
|
+
const pk = pkOf2(name);
|
|
35672
|
+
const byLower = /* @__PURE__ */ new Map();
|
|
35673
|
+
for (const vname of Object.keys(entityViews)) {
|
|
35674
|
+
const list = byLower.get(vname.trim().toLowerCase()) ?? [];
|
|
35675
|
+
list.push(vname);
|
|
35676
|
+
byLower.set(vname.trim().toLowerCase(), list);
|
|
35677
|
+
}
|
|
35678
|
+
for (const [, spellings] of byLower) {
|
|
35679
|
+
if (spellings.length > 1) {
|
|
35680
|
+
err(
|
|
35681
|
+
`entities/${name}.json \u2192 views`,
|
|
35682
|
+
`views ${spellings.map((v) => `'${v}'`).join(" and ")} differ only by case or surrounding whitespace. A folder binds a view case-insensitively, so only one could ever be reached.`
|
|
35683
|
+
);
|
|
35684
|
+
}
|
|
35685
|
+
}
|
|
35686
|
+
for (const [vname, view] of Object.entries(entityViews)) {
|
|
35687
|
+
const where = `entities/${name}.json \u2192 views.${vname}`;
|
|
35688
|
+
const vcols = view?.columns ?? void 0;
|
|
35689
|
+
if (!vcols || Object.keys(vcols).length === 0) {
|
|
35690
|
+
err(
|
|
35691
|
+
where,
|
|
35692
|
+
"lists no columns. A view is the COMPLETE set of columns visible in a folder bound to it, so an empty one would hide every field \u2014 the installer rejects it."
|
|
35693
|
+
);
|
|
35694
|
+
continue;
|
|
35695
|
+
}
|
|
35696
|
+
const declared = Object.keys(vcols);
|
|
35697
|
+
for (const cd of declared) {
|
|
35698
|
+
if (cols && !cols.has(cd)) {
|
|
35699
|
+
err(where, `column '${cd}' is not a field on entity '${name}'`);
|
|
35700
|
+
}
|
|
35701
|
+
}
|
|
35702
|
+
const dupes = declared.filter(
|
|
35703
|
+
(c, i) => declared.findIndex((o) => o.toLowerCase() === c.toLowerCase()) !== i
|
|
35704
|
+
);
|
|
35705
|
+
for (const d of dupes) {
|
|
35706
|
+
err(where, `lists column '${d}' more than once (differing only by case)`);
|
|
35707
|
+
}
|
|
35708
|
+
if (pk && !declared.some((c) => c.toLowerCase() === pk.toLowerCase())) {
|
|
35709
|
+
err(
|
|
35710
|
+
where,
|
|
35711
|
+
`omits the primary key '${pk}'. A view must list it \u2014 records are addressed by it. To keep it off the screen, drop 'V' from its flags instead of omitting it.`
|
|
35712
|
+
);
|
|
35713
|
+
}
|
|
35714
|
+
for (const [cd, ovRaw] of Object.entries(vcols)) {
|
|
35715
|
+
const ov = ovRaw ?? {};
|
|
35716
|
+
if (typeof ov.formula !== "string" || ov.formula.trim() === "") continue;
|
|
35717
|
+
const colType = fields[cd]?.columnType;
|
|
35718
|
+
if (colType !== "F") {
|
|
35719
|
+
err(
|
|
35720
|
+
`${where}.columns.${cd}`,
|
|
35721
|
+
`sets a formula on a column of type '${colType ?? "D"}'. A view formula is only evaluated on a Formula ("F") column \u2014 on any other column that field holds the SQL default, and the override would do nothing.`
|
|
35722
|
+
);
|
|
35723
|
+
}
|
|
35724
|
+
}
|
|
35725
|
+
}
|
|
35726
|
+
}
|
|
35618
35727
|
const views = readJsonOrDefault(paths.dataViews, {});
|
|
35619
35728
|
const viewCodes = new Set(Object.keys(views));
|
|
35620
35729
|
for (const [vcode, v] of Object.entries(views)) {
|
|
@@ -35930,6 +36039,30 @@ function moduleValidate(args) {
|
|
|
35930
36039
|
`folder code '${code2}' is used ${dupPaths.length} times (${dupPaths.join(", ")}). Codes must be unique across the whole tree: role rights say 'folder:${code2}' with no path, and translations key on 'folders.${code2}.label', so duplicates are ambiguous and silently collide.`
|
|
35931
36040
|
);
|
|
35932
36041
|
}
|
|
36042
|
+
const folderNodes = [
|
|
36043
|
+
{ path: "(root)", node: folderRoot },
|
|
36044
|
+
...walkFolders(folderRoot).map((f) => ({ path: f.path, node: f.node }))
|
|
36045
|
+
];
|
|
36046
|
+
for (const { path: fpath, node } of folderNodes) {
|
|
36047
|
+
const bindings = node.entities ?? {};
|
|
36048
|
+
for (const [entityCd, binding] of Object.entries(bindings)) {
|
|
36049
|
+
const viewName = (binding ?? {})?.viewName;
|
|
36050
|
+
if (typeof viewName !== "string" || viewName.trim() === "") continue;
|
|
36051
|
+
if (viewName.trim().toLowerCase() === "default") continue;
|
|
36052
|
+
if (entityCd.includes(".")) continue;
|
|
36053
|
+
const e = entities[entityCd];
|
|
36054
|
+
if (!e) continue;
|
|
36055
|
+
const declared = Object.keys(
|
|
36056
|
+
e.views ?? {}
|
|
36057
|
+
).map((v) => v.trim().toLowerCase());
|
|
36058
|
+
if (!declared.includes(viewName.trim().toLowerCase())) {
|
|
36059
|
+
err(
|
|
36060
|
+
`ui/folders.json \u2192 ${fpath} \u2192 entities.${entityCd}`,
|
|
36061
|
+
`viewName '${viewName}' is not declared under 'views' in entities/${entityCd}.json. This fails the install: an unresolved view would show every column of the entity, which is the opposite of what naming one asks for. Declare the view, or drop 'viewName' (or use "default", which means no view).`
|
|
36062
|
+
);
|
|
36063
|
+
}
|
|
36064
|
+
}
|
|
36065
|
+
}
|
|
35933
36066
|
for (const [rcode, rawReport] of Object.entries(reports)) {
|
|
35934
36067
|
const report = rawReport ?? {};
|
|
35935
36068
|
const where = `ui/reports.json \u2192 ${rcode}`;
|
|
@@ -36350,6 +36483,15 @@ function matrixExample(relPath, description) {
|
|
|
36350
36483
|
read: () => readSkill(`examples/matrix-budget/${relPath}`)
|
|
36351
36484
|
};
|
|
36352
36485
|
}
|
|
36486
|
+
function columnSecurityExample(relPath, description) {
|
|
36487
|
+
return {
|
|
36488
|
+
uri: `dforge://example/column-security/${relPath}`,
|
|
36489
|
+
name: `Example: column-security/${relPath}`,
|
|
36490
|
+
description,
|
|
36491
|
+
mimeType: relPath.endsWith(".json") ? "application/json" : "text/plain",
|
|
36492
|
+
read: () => readSkill(`examples/column-security/${relPath}`)
|
|
36493
|
+
};
|
|
36494
|
+
}
|
|
36353
36495
|
var resources = [
|
|
36354
36496
|
schema(
|
|
36355
36497
|
"manifest",
|
|
@@ -36359,7 +36501,7 @@ var resources = [
|
|
|
36359
36501
|
schema(
|
|
36360
36502
|
"entity",
|
|
36361
36503
|
"Entity JSON schema",
|
|
36362
|
-
"JSON Schema for entity files under entities/*.json (description, dbObject, toString, traits, fields)."
|
|
36504
|
+
"JSON Schema for entity files under entities/*.json (description, dbObject, toString, traits, fields, views)."
|
|
36363
36505
|
),
|
|
36364
36506
|
schema(
|
|
36365
36507
|
"domains",
|
|
@@ -36442,7 +36584,7 @@ var resources = [
|
|
|
36442
36584
|
},
|
|
36443
36585
|
// ── Per-element authoring references (skills/.../references/*.md) ──────
|
|
36444
36586
|
reference("field-types", "Field types \u2014 fieldTypeCd (UI control) vs dbDatatype (SQL type), and the correct value for each. Load before adding any field."),
|
|
36445
|
-
reference("flags", "Column flags \u2014 only V/I/E/M
|
|
36587
|
+
reference("flags", "Column flags \u2014 only V/I/E/M (there is no H); valid combos (VEM/VE/V/EM/I), how M folds into isNullable, and how the same letters override per entity view. Load before setting any flags."),
|
|
36446
36588
|
reference("column-types", "The FK + Reference two-column pattern (the #1 source of broken modules) and Set columns. Load before any relation."),
|
|
36447
36589
|
reference("column-domains", "Column domains: a reusable field type (datatype + control + shared option list) referenced via a single `domain` key. Use when the same enum/type repeats across columns. Load before authoring domains.json or a domain-backed column."),
|
|
36448
36590
|
reference("formulas", "Formula columns (columnType 'F'): baseDatatypeCd, no dbDatatype, flags 'V', and the formula expression grammar."),
|
|
@@ -36488,7 +36630,13 @@ var resources = [
|
|
|
36488
36630
|
matrixExample("entities/budget_category.json", "Matrix row-axis (dataset) entity."),
|
|
36489
36631
|
matrixExample("manifest.json", "Matrix example manifest (entities + dataViews + roles + seed)."),
|
|
36490
36632
|
matrixExample("security/roles.json", "Matrix example roles: S on the axis entity, SIUD on the editable cell entity."),
|
|
36491
|
-
matrixExample("seed-data/01-categories.json", "Matrix example seed: row-axis category records.")
|
|
36633
|
+
matrixExample("seed-data/01-categories.json", "Matrix example seed: row-axis category records."),
|
|
36634
|
+
columnSecurityExample("entities/product.json", `Canonical ENTITY VIEWS (column-level security): one entity, two views \u2014 'storekeeper' (a plain column subset, prices absent entirely) and 'accountant' (with a per-column flags override making a column read-only in that view only, and a per-view formula override on an "F" column). Both list the primary key, which a view must.`),
|
|
36635
|
+
columnSecurityExample("ui/folders.json", "The BINDING half: two folders over one entity, each with a different entities.product.viewName. Without this the views exist and nothing uses them; with a name no view declares, the install fails rather than falling back to the full column set."),
|
|
36636
|
+
columnSecurityExample("security/roles.json", "Roles for the two folders. Note rights are per ENTITY, not per column \u2014 the column dimension is the entity view, not the role."),
|
|
36637
|
+
columnSecurityExample("ui/data_views.json", "The data view lists EVERY column including prices \u2014 correct: presentation is one axis, the folder's entity view decides what a user may see of it. You do not maintain one data view per role."),
|
|
36638
|
+
columnSecurityExample("manifest.json", "Column-security example manifest. Note it declares no dataViews/menus/security pointers \u2014 those files are found by convention, and the manifest schema is additionalProperties:false."),
|
|
36639
|
+
columnSecurityExample("README.md", "Why entity views exist, what each of the two views demonstrates, and the five ways a view fails the install.")
|
|
36492
36640
|
];
|
|
36493
36641
|
function readVendored(rel2) {
|
|
36494
36642
|
const p = path16.join(RESOURCES_DIR, rel2);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dforge-core/dforge-mcp",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.16",
|
|
4
4
|
"description": "MCP server for dForge module authoring. Exposes 34 scaffold/patch/validate/pack/install tools and schema resources so AI agents (Claude Code, Cursor, Zed) can create and ship dForge modules.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"homepage": "https://github.com/iash44/dForge-core",
|
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
},
|
|
37
37
|
"dependencies": {
|
|
38
38
|
"@dforge-core/dforge-cli": "^0.2.14",
|
|
39
|
-
"@dforge-core/metadata": "^0.0.
|
|
39
|
+
"@dforge-core/metadata": "^0.0.18",
|
|
40
40
|
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
41
41
|
"zod": "^4.4.3"
|
|
42
42
|
},
|
|
@@ -426,7 +426,7 @@ slashes.
|
|
|
426
426
|
- **The whole file is the root folder.** Top-level fields (`label`, `description`, `color`, `entities`) belong to the root.
|
|
427
427
|
- **Subfolders nest under `children`** (a dictionary keyed by path segment).
|
|
428
428
|
- **Each child key is a single path segment** — lowercase letters, digits, dashes, and underscores only, no slashes. Pattern: `^[a-z0-9][a-z0-9_-]*$`. Slashes are forbidden in keys because deeper trees use `children` recursively, not slashes in keys.
|
|
429
|
-
- **`entities` is a dictionary** mapping entity codes to settings objects. Each entity has `viewName` (
|
|
429
|
+
- **`entities` is a dictionary** mapping entity codes to settings objects. Each entity has `viewName` (an **entity view** name — see below — or null), `quickAdd` (boolean), and an optional `rowFilter`.
|
|
430
430
|
- **`inheritSecurity`** is allowed only on child folders. The root has no parent to inherit from, so the field is rejected at the root level with a clear error.
|
|
431
431
|
- **Children can have their own children**, recursively, for arbitrary tree depth.
|
|
432
432
|
|
|
@@ -464,6 +464,78 @@ The DB still has a `UNIQUE INDEX` on `(module_id, folder_path)` as
|
|
|
464
464
|
defense-in-depth, and `FolderTreeFlattener` validates the file format
|
|
465
465
|
before the install pipeline touches the database.
|
|
466
466
|
|
|
467
|
+
### Entity views — column-level security
|
|
468
|
+
|
|
469
|
+
An **entity view** is a named subset of an entity's columns, declared on the entity file and bound per folder. Users working in that folder see **only** the columns the view lists — the rest are neither visible nor queryable there. This is the column-level layer of the security model; roles grant rights per *entity*, never per field.
|
|
470
|
+
|
|
471
|
+
Two halves, both required:
|
|
472
|
+
|
|
473
|
+
| File | Key | Effect |
|
|
474
|
+
|------|-----|--------|
|
|
475
|
+
| `entities/<entity>.json` | `views.<viewName>.columns` | Declares the view and its column set |
|
|
476
|
+
| `ui/folders.json` | `entities.<code>.viewName` | Binds that view for this entity in this folder |
|
|
477
|
+
|
|
478
|
+
```jsonc
|
|
479
|
+
"views": {
|
|
480
|
+
"storekeeper": {
|
|
481
|
+
"columns": {
|
|
482
|
+
"product_id": {},
|
|
483
|
+
"name": {},
|
|
484
|
+
"quantity": {}
|
|
485
|
+
}
|
|
486
|
+
},
|
|
487
|
+
"accountant": {
|
|
488
|
+
"columns": {
|
|
489
|
+
"product_id": {},
|
|
490
|
+
"name": {},
|
|
491
|
+
"quantity": { "flags": "V" },
|
|
492
|
+
"cost_price": {},
|
|
493
|
+
"sale_price": {}
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
```
|
|
498
|
+
|
|
499
|
+
**Per-column overrides** (all optional, each falls back to the entity-level value): `flags`, `orderNum`, `isNullable`, `editMask`, `displayFmt`, `refFilter`, `params`, `formula`. `{}` means "expose this column, override nothing".
|
|
500
|
+
|
|
501
|
+
**An empty override is a value, not an absence.** The runtime merges view over entity with `COALESCE(view, entity)`, which skips NULL only — so `"flags": ""` means "no flags in this view" (a column neither visible nor editable) and `"params": {}` suppresses the entity-level params rather than inheriting them. Omit the key entirely to inherit.
|
|
502
|
+
|
|
503
|
+
`formula` lets one view compute a column differently from the entity, and only on a column the entity declares as `columnType: "F"` — elsewhere that field holds the SQL default, so an override there would be inert.
|
|
504
|
+
|
|
505
|
+
**Rules the installer enforces** (each is silent at runtime, which is why they fail the install instead):
|
|
506
|
+
|
|
507
|
+
- A column absent from `columns` is **hidden**. That is the mechanism, not an oversight.
|
|
508
|
+
- A view must list the **primary key** — records are addressed by it. Keep it off screen by dropping `V` from its flags instead of omitting it.
|
|
509
|
+
- A column code that doesn't exist, a view with **no** columns, one column listed twice under different casing, or two views whose names differ only by case (a folder binds case-insensitively, so only one could ever be reached).
|
|
510
|
+
- Views belong to the entity's **own** module; an `extends` extension declaring `views` is rejected, since a view enumerates the complete column set.
|
|
511
|
+
- A `viewName` that no view declares, or one naming a column-less view, **fails the install**. It does not fall back: the fallback is the full column set, so a typo would quietly *unrestrict* the folder.
|
|
512
|
+
|
|
513
|
+
**`"default"` is the one exempt name** and means "no view" (full column set). It is what the ~100 shipped folder entries write, what `FolderExporter` writes back for a NULL `view_name`, and what the runtime auto-creates (column-less) for an entity reached without a folder binding — so rejecting it would fail every install. A module that really declares a view named `default` still gets it bound.
|
|
514
|
+
|
|
515
|
+
Don't confuse the three things called "view": an **entity view** (`views`, this section) is column security; a **data view** (`ui/data_views.json`, referenced by a menu's `dataViewCode`) is a grid/kanban/calendar; `isView` + `viewSql` on an entity means it is backed by a SQL view. `folders.json`'s `viewName` binds the first.
|
|
516
|
+
|
|
517
|
+
## Column Domains (`domains.json`)
|
|
518
|
+
|
|
519
|
+
A **domain** is a reusable, named semantic type: a base datatype, a control, sizing, and a shared option list under one code. Columns reference it instead of restating the definition.
|
|
520
|
+
|
|
521
|
+
```json
|
|
522
|
+
{
|
|
523
|
+
"doc_status": {
|
|
524
|
+
"description": "Document Status",
|
|
525
|
+
"baseDatatypeCd": "string",
|
|
526
|
+
"dbDatatype": "varchar",
|
|
527
|
+
"maxLen": 20,
|
|
528
|
+
"fieldTypeCd": "dropdown",
|
|
529
|
+
"params": {
|
|
530
|
+
"options": [
|
|
531
|
+
{ "value": "draft", "label": "Draft" },
|
|
532
|
+
{ "value": "posted", "label": "Posted", "color": "#0a0" }
|
|
533
|
+
]
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
```
|
|
538
|
+
|
|
467
539
|
### 5. Security Roles (`security/roles.json`)
|
|
468
540
|
|
|
469
541
|
```json
|
|
@@ -939,6 +1011,7 @@ When creating a module, ensure:
|
|
|
939
1011
|
- [ ] `menus.json` uses nested dictionaries with `children` (not arrays)
|
|
940
1012
|
- [ ] `menus.json` leaf items use `dataViewCode` (not `viewCode`)
|
|
941
1013
|
- [ ] `folders.json` uses entity dictionary with `{ viewName, quickAdd }` objects
|
|
1014
|
+
- [ ] Every `viewName` is `"default"` (= no view) or a view the entity declares under `views`; each declared view lists the primary key and at least one column
|
|
942
1015
|
- [ ] `roles.json` uses `"rights"` property (not `"entityRights"`)
|
|
943
1016
|
- [ ] Seed data files are numbered for FK dependency order (01-, 02-, etc.)
|
|
944
1017
|
- [ ] Seed data includes explicit numeric (int8) PKs for cross-entity references — NOT UUID strings (`cuid` is `int8`)
|