@dforge-core/dforge-mcp 0.2.15 → 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 +156 -0
- package/dist/server.js +155 -7
- package/package.json +2 -2
- package/resources/docs/conventions.md +74 -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/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 +7 -0
- package/skills/dforge-mcp-author/references/security.md +77 -1
- package/skills/dforge-mcp-author/references/validation-checklist.md +2 -0
- package/skills/dforge-module-build/SKILL.md +13 -2
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,162 @@ 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.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
|
+
|
|
7
163
|
## 0.2.15
|
|
8
164
|
|
|
9
165
|
`canExecute` became a real server-side gate and `isTransacted` began opening a real
|
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`)
|
|
@@ -95,10 +95,88 @@
|
|
|
95
95
|
}
|
|
96
96
|
},
|
|
97
97
|
"additionalProperties": true
|
|
98
|
+
},
|
|
99
|
+
"views": {
|
|
100
|
+
"type": "object",
|
|
101
|
+
"description": "Entity views keyed by view name — column-level security. Unrelated to 'isView'/'viewSql' (a SQL-view-backed entity) and to ui/data_views.json (grids, kanban, calendars). A folder binds one view per entity via ui/folders.json (entities.<code>.viewName); users working in that folder see ONLY the columns the view lists. Entities without views (or folders naming a view that isn't declared here) show the entity's full column set.",
|
|
102
|
+
"propertyNames": {
|
|
103
|
+
"pattern": "^[a-zA-Z_][a-zA-Z0-9_-]*$",
|
|
104
|
+
"maxLength": 100
|
|
105
|
+
},
|
|
106
|
+
"additionalProperties": {
|
|
107
|
+
"$ref": "#/$defs/entityView"
|
|
108
|
+
}
|
|
98
109
|
}
|
|
99
110
|
},
|
|
100
111
|
"additionalProperties": false,
|
|
101
112
|
"$defs": {
|
|
113
|
+
"entityView": {
|
|
114
|
+
"type": "object",
|
|
115
|
+
"description": "One entity view: the columns it exposes, plus optional per-view overrides on each.",
|
|
116
|
+
"required": ["columns"],
|
|
117
|
+
"properties": {
|
|
118
|
+
"columns": {
|
|
119
|
+
"type": "object",
|
|
120
|
+
"description": "Column code -> per-view overrides. Columns absent from this map are hidden in a folder bound to this view. Use {} (or null) to expose a column with no overrides. Must include the entity's primary key column(s) — records are addressed by them; hide the key from the UI by omitting 'V' from its flags instead.",
|
|
121
|
+
"minProperties": 1,
|
|
122
|
+
"propertyNames": {
|
|
123
|
+
"pattern": "^[a-zA-Z_][a-zA-Z0-9_]*$"
|
|
124
|
+
},
|
|
125
|
+
"additionalProperties": {
|
|
126
|
+
"$ref": "#/$defs/entityViewColumn"
|
|
127
|
+
}
|
|
128
|
+
},
|
|
129
|
+
"params": {
|
|
130
|
+
"type": "object",
|
|
131
|
+
"description": "Free-form view configuration, stored on entity_view.params.",
|
|
132
|
+
"additionalProperties": true
|
|
133
|
+
}
|
|
134
|
+
},
|
|
135
|
+
"additionalProperties": false
|
|
136
|
+
},
|
|
137
|
+
"entityViewColumn": {
|
|
138
|
+
"description": "Per-view overrides for one column. Every property is optional; anything omitted falls back to the entity-level value on the column itself.",
|
|
139
|
+
"type": ["object", "null"],
|
|
140
|
+
"properties": {
|
|
141
|
+
"flags": {
|
|
142
|
+
"type": "string",
|
|
143
|
+
"maxLength": 10,
|
|
144
|
+
"pattern": "^[VIEM]*$",
|
|
145
|
+
"description": "Flag letters for this column in this view — the same V/I/E/M vocabulary as a field's 'flags'. One rule, three cases: OMIT the key to inherit the entity-level flags (omission is the only inherit spelling — this field is a string, so a literal null does not validate; to inherit everything, set the whole column override to null or {}); any string, empty included, OVERRIDES them, because the runtime merges view over entity with COALESCE, which skips a missing value only; and a non-empty string REPLACES the entity letters rather than merging with them. So 'V' exposes the column read-only (visible, not editable) however the entity flagged it, and '' means 'no flags here' — neither visible nor editable. M works here as it does on a field: the installer folds it into this override's isNullable, making the column required IN THIS VIEW even when it is nullable in the database (a view writes no DDL, so the requiredness is runtime-only). Declaring M together with \"isNullable\": true is rejected at pack time. Note the fold is one-directional — DROPPING M from a view's flags does not make a column optional, because isNullable still COALESCEs to the entity value; write \"isNullable\": true to loosen it."
|
|
146
|
+
},
|
|
147
|
+
"orderNum": {
|
|
148
|
+
"type": "integer",
|
|
149
|
+
"description": "Display order within this view."
|
|
150
|
+
},
|
|
151
|
+
"isNullable": {
|
|
152
|
+
"type": "boolean",
|
|
153
|
+
"description": "Nullability override for this view."
|
|
154
|
+
},
|
|
155
|
+
"editMask": {
|
|
156
|
+
"type": "string",
|
|
157
|
+
"maxLength": 100
|
|
158
|
+
},
|
|
159
|
+
"displayFmt": {
|
|
160
|
+
"type": "string",
|
|
161
|
+
"maxLength": 100
|
|
162
|
+
},
|
|
163
|
+
"refFilter": {
|
|
164
|
+
"type": "object",
|
|
165
|
+
"description": "Lookup filter override for a reference column in this view.",
|
|
166
|
+
"additionalProperties": true
|
|
167
|
+
},
|
|
168
|
+
"formula": {
|
|
169
|
+
"type": "string",
|
|
170
|
+
"description": "Formula override for this column in this view, in the same DSL as a field's 'formula'. Allowed only on a column the entity declares as columnType 'F'; on any other column the install FAILS (it does not silently ignore the override), because there that field holds the SQL default and a view cannot change a physical column's DEFAULT. There is no per-view default value for a normal column today. The runtime prefers it over the entity-level formula, so one view can compute a column differently from the entity. The parsed AST is regenerated at install; a formula that does not parse, calls an unimplemented function, or aggregates over a 1:N set fails the install (each renders empty at runtime)."
|
|
171
|
+
},
|
|
172
|
+
"params": {
|
|
173
|
+
"type": "object",
|
|
174
|
+
"description": "Control-specific configuration override for this view.",
|
|
175
|
+
"additionalProperties": true
|
|
176
|
+
}
|
|
177
|
+
},
|
|
178
|
+
"additionalProperties": false
|
|
179
|
+
},
|
|
102
180
|
"field": {
|
|
103
181
|
"type": "object",
|
|
104
182
|
"description": "Entity column/field definition",
|
|
@@ -129,8 +207,8 @@
|
|
|
129
207
|
},
|
|
130
208
|
"flags": {
|
|
131
209
|
"type": "string",
|
|
132
|
-
"pattern": "^[
|
|
133
|
-
"description": "
|
|
210
|
+
"pattern": "^[VIEM]*$",
|
|
211
|
+
"description": "Column flags, in any order. V=Visible and E=Editable are display/interaction; I=Identity marks a platform-managed column (never user-editable, excluded from required validation); M=Mandatory is not a runtime flag at all — it is resolved into isNullable:false at install, so it makes the column NOT NULL, is inert on virtual (R/S/F) and identity columns, and declaring it together with isNullable:true is rejected at pack time. Any other letter is rejected at pack time; there is no H flag — a column is hidden by omitting V."
|
|
134
212
|
},
|
|
135
213
|
"isPk": {
|
|
136
214
|
"type": "boolean",
|
|
@@ -58,7 +58,7 @@
|
|
|
58
58
|
"properties": {
|
|
59
59
|
"viewName": {
|
|
60
60
|
"type": "string",
|
|
61
|
-
"description": "Entity view name (data view code
|
|
61
|
+
"description": "Entity view to bind for this entity in this folder — the name of a key under the entity definition's 'views' (column-level security), NOT a data view code from ui/data_views.json. Users in this folder then see only the columns that view lists. The conventional 'default' declares nothing and means 'no view' (full column set); any other name that the entity does not declare fails the install."
|
|
62
62
|
},
|
|
63
63
|
"quickAdd": {
|
|
64
64
|
"type": "boolean",
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# column-security — entity views example
|
|
2
|
+
|
|
3
|
+
A minimal module whose only job is to demonstrate **entity views**: dForge's
|
|
4
|
+
column-level security. One `product` entity holds both stock and pricing, and two
|
|
5
|
+
folders show two different column sets of it.
|
|
6
|
+
|
|
7
|
+
## The shape
|
|
8
|
+
|
|
9
|
+
Column-level security is always **two halves**, and both are required:
|
|
10
|
+
|
|
11
|
+
| Piece | File | Effect |
|
|
12
|
+
|---|---|---|
|
|
13
|
+
| The column set | `entities/product.json` → `views` | Declares `storekeeper` and `accountant` — each a named subset of the entity's columns |
|
|
14
|
+
| The binding | `ui/folders.json` → `entities.product.viewName` | The Warehouse folder binds `storekeeper`, the Finance folder binds `accountant` |
|
|
15
|
+
|
|
16
|
+
```
|
|
17
|
+
product columns: name sku quantity cost_price sale_price margin
|
|
18
|
+
|
|
19
|
+
Warehouse folder → view "storekeeper": name sku quantity
|
|
20
|
+
Finance folder → view "accountant": name sku quantity(read-only) cost_price sale_price margin
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
A user working in Warehouse cannot see `cost_price` **at all** — not on screen, not
|
|
24
|
+
through a filter, not in an export. The columns a view omits are invisible *and*
|
|
25
|
+
unqueryable there. That is the mechanism: omission is the restriction.
|
|
26
|
+
|
|
27
|
+
## What each view demonstrates
|
|
28
|
+
|
|
29
|
+
- **`storekeeper`** — the plain case: list the columns this folder may see, and no
|
|
30
|
+
others. Note it still lists `product_id`; a view must include the primary key
|
|
31
|
+
because records are addressed by it.
|
|
32
|
+
- **`accountant`** — the override cases:
|
|
33
|
+
- `"quantity": { "flags": "V" }` — visible but **not** editable here, while the
|
|
34
|
+
entity itself declares `VEM`. A per-column `flags` override re-flags the column
|
|
35
|
+
for the folders bound to this view only.
|
|
36
|
+
- `"margin": { "formula": "…", "displayFmt": "N1" }` — a per-view **formula**
|
|
37
|
+
override: the entity computes margin as an absolute amount, this view computes it
|
|
38
|
+
as a percentage. Allowed only because `margin` is a `columnType: "F"` column — on
|
|
39
|
+
any other column that field holds the SQL default, so the override would be inert
|
|
40
|
+
and the install rejects it.
|
|
41
|
+
|
|
42
|
+
## Things that would fail the install
|
|
43
|
+
|
|
44
|
+
Worth knowing, because each one is silent at runtime if it slips through:
|
|
45
|
+
|
|
46
|
+
- Dropping `product_id` from a view — records the client cannot open or save.
|
|
47
|
+
- A view with **no** columns, or a column code the entity doesn't have.
|
|
48
|
+
- Two views whose names differ only by case — a folder binds case-insensitively, so
|
|
49
|
+
only one could ever be reached.
|
|
50
|
+
- A `viewName` in `folders.json` that no view declares. This does **not** fall back:
|
|
51
|
+
the fallback would be the entity's full column set, so a typo would quietly
|
|
52
|
+
*unrestrict* the folder. `"default"` is the one exempt name and means "no view".
|
|
53
|
+
|
|
54
|
+
`dforge_module_validate` checks all of these offline, before packing.
|
|
55
|
+
|
|
56
|
+
## What this example deliberately leaves out
|
|
57
|
+
|
|
58
|
+
The `product_grid` data view lists **every** column, including the prices. That is
|
|
59
|
+
correct: a data view describes the presentation, and the folder's entity view decides
|
|
60
|
+
what a user in that folder is allowed to see of it. The grid renders whichever
|
|
61
|
+
columns survive the view — you do not maintain one data view per role.
|
|
62
|
+
|
|
63
|
+
Entity views are also unrelated to the two other things called "view" here: a *data
|
|
64
|
+
view* (`ui/data_views.json`) is a grid/kanban/calendar, and `isView` + `viewSql` on an
|
|
65
|
+
entity means it is backed by a SQL view.
|
|
66
|
+
|
|
67
|
+
See `dforge://reference/security` → Column-level security for the full rules.
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
{
|
|
2
|
+
"description": "Products, with stock and pricing on one record",
|
|
3
|
+
"dbObject": "product",
|
|
4
|
+
"toString": "{name}",
|
|
5
|
+
"traits": ["identity", "audit"],
|
|
6
|
+
"fields": {
|
|
7
|
+
"name": {
|
|
8
|
+
"dbDatatype": "varchar",
|
|
9
|
+
"fieldTypeCd": "text",
|
|
10
|
+
"flags": "VEM",
|
|
11
|
+
"maxLen": 200,
|
|
12
|
+
"orderNum": 10,
|
|
13
|
+
"description": "Product Name"
|
|
14
|
+
},
|
|
15
|
+
"sku": {
|
|
16
|
+
"dbDatatype": "varchar",
|
|
17
|
+
"fieldTypeCd": "text",
|
|
18
|
+
"flags": "VEM",
|
|
19
|
+
"maxLen": 40,
|
|
20
|
+
"orderNum": 20,
|
|
21
|
+
"description": "SKU"
|
|
22
|
+
},
|
|
23
|
+
"quantity": {
|
|
24
|
+
"dbDatatype": "numeric(18,2)",
|
|
25
|
+
"fieldTypeCd": "number",
|
|
26
|
+
"flags": "VEM",
|
|
27
|
+
"precision": 2,
|
|
28
|
+
"orderNum": 30,
|
|
29
|
+
"description": "Quantity On Hand"
|
|
30
|
+
},
|
|
31
|
+
"cost_price": {
|
|
32
|
+
"dbDatatype": "numeric(18,2)",
|
|
33
|
+
"fieldTypeCd": "currency",
|
|
34
|
+
"flags": "VEM",
|
|
35
|
+
"precision": 2,
|
|
36
|
+
"orderNum": 40,
|
|
37
|
+
"description": "Cost Price"
|
|
38
|
+
},
|
|
39
|
+
"sale_price": {
|
|
40
|
+
"dbDatatype": "numeric(18,2)",
|
|
41
|
+
"fieldTypeCd": "currency",
|
|
42
|
+
"flags": "VEM",
|
|
43
|
+
"precision": 2,
|
|
44
|
+
"orderNum": 50,
|
|
45
|
+
"description": "Sale Price"
|
|
46
|
+
},
|
|
47
|
+
"margin": {
|
|
48
|
+
"columnType": "F",
|
|
49
|
+
"fieldTypeCd": "currency",
|
|
50
|
+
"baseDatatypeCd": "number",
|
|
51
|
+
"flags": "V",
|
|
52
|
+
"precision": 2,
|
|
53
|
+
"formula": "[sale_price] - [cost_price]",
|
|
54
|
+
"orderNum": 60,
|
|
55
|
+
"description": "Margin"
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
"views": {
|
|
59
|
+
"storekeeper": {
|
|
60
|
+
"columns": {
|
|
61
|
+
"product_id": {},
|
|
62
|
+
"name": {},
|
|
63
|
+
"sku": {},
|
|
64
|
+
"quantity": {}
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
"accountant": {
|
|
68
|
+
"columns": {
|
|
69
|
+
"product_id": {},
|
|
70
|
+
"name": {},
|
|
71
|
+
"sku": {},
|
|
72
|
+
"quantity": { "flags": "V" },
|
|
73
|
+
"cost_price": {},
|
|
74
|
+
"sale_price": {},
|
|
75
|
+
"margin": { "formula": "([sale_price] - [cost_price]) / [sale_price] * 100", "displayFmt": "N1" }
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"packageFormat": 1,
|
|
3
|
+
"moduleId": "20000000-0000-0000-0000-000000000003",
|
|
4
|
+
"code": "column_security",
|
|
5
|
+
"version": "0.1.0",
|
|
6
|
+
"dbSchemaVersion": "0.0.1",
|
|
7
|
+
"displayName": "Column Security",
|
|
8
|
+
"description": "Example module demonstrating entity views — column-level security",
|
|
9
|
+
"author": { "name": "dForge Examples" },
|
|
10
|
+
"license": "MIT",
|
|
11
|
+
"category": "Examples",
|
|
12
|
+
"tags": ["example", "security", "entity-views"],
|
|
13
|
+
"icon": "bi-eye-slash",
|
|
14
|
+
"dependencies": {
|
|
15
|
+
"admin": ">=0.0.1"
|
|
16
|
+
},
|
|
17
|
+
"supportedLocales": ["en-US"],
|
|
18
|
+
"created": "2026-08-19",
|
|
19
|
+
"updated": "2026-08-19",
|
|
20
|
+
"entities": {
|
|
21
|
+
"product": "./entities/product.json"
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"column_security.storekeeper": {
|
|
3
|
+
"description": "Counts stock; never sees cost or price",
|
|
4
|
+
"rights": {
|
|
5
|
+
"product": "SIU",
|
|
6
|
+
"folder:warehouse": "E"
|
|
7
|
+
}
|
|
8
|
+
},
|
|
9
|
+
"column_security.accountant": {
|
|
10
|
+
"description": "Owns costing and pricing",
|
|
11
|
+
"rights": {
|
|
12
|
+
"product": "SIU",
|
|
13
|
+
"folder:finance": "E"
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"entities": {
|
|
3
|
+
"product": {
|
|
4
|
+
"label": "Product",
|
|
5
|
+
"fields": {
|
|
6
|
+
"name": { "label": "Product Name" },
|
|
7
|
+
"sku": { "label": "SKU" },
|
|
8
|
+
"quantity": { "label": "Quantity On Hand" },
|
|
9
|
+
"cost_price": { "label": "Cost Price" },
|
|
10
|
+
"sale_price": { "label": "Sale Price" },
|
|
11
|
+
"margin": { "label": "Margin" }
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"folders": {
|
|
16
|
+
"warehouse": { "label": "Warehouse" },
|
|
17
|
+
"finance": { "label": "Finance" }
|
|
18
|
+
},
|
|
19
|
+
"roles": {
|
|
20
|
+
"column_security.storekeeper": { "label": "Storekeeper" },
|
|
21
|
+
"column_security.accountant": { "label": "Accountant" }
|
|
22
|
+
},
|
|
23
|
+
"views": {
|
|
24
|
+
"product_grid": { "label": "Products" }
|
|
25
|
+
},
|
|
26
|
+
"menus": {
|
|
27
|
+
"product_menu": {
|
|
28
|
+
"label": "Products",
|
|
29
|
+
"items": {
|
|
30
|
+
"catalog": { "label": "Catalog" },
|
|
31
|
+
"all_products": { "label": "Products" }
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"product_grid": {
|
|
3
|
+
"label": "Products",
|
|
4
|
+
"description": "One grid, two column sets — whichever the folder's entity view allows",
|
|
5
|
+
"viewType": "grid",
|
|
6
|
+
"icon": "bi-box-seam",
|
|
7
|
+
"dataSources": [{
|
|
8
|
+
"entityCode": "product",
|
|
9
|
+
"columns": [
|
|
10
|
+
{ "column_cd": "name", "width": 240 },
|
|
11
|
+
{ "column_cd": "sku", "width": 120 },
|
|
12
|
+
{ "column_cd": "quantity", "width": 120 },
|
|
13
|
+
{ "column_cd": "cost_price", "width": 120 },
|
|
14
|
+
{ "column_cd": "sale_price", "width": 120 },
|
|
15
|
+
{ "column_cd": "margin", "width": 120 }
|
|
16
|
+
],
|
|
17
|
+
"order": ["name"]
|
|
18
|
+
}]
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"label": "Products",
|
|
3
|
+
"description": "Product master data",
|
|
4
|
+
"icon": "bi-box-seam",
|
|
5
|
+
"children": {
|
|
6
|
+
"warehouse": {
|
|
7
|
+
"label": "Warehouse",
|
|
8
|
+
"description": "Stock keeping — no pricing",
|
|
9
|
+
"color": "#0ea5e9",
|
|
10
|
+
"entities": {
|
|
11
|
+
"product": { "viewName": "storekeeper", "quickAdd": true }
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"finance": {
|
|
15
|
+
"label": "Finance",
|
|
16
|
+
"description": "Costing and pricing",
|
|
17
|
+
"color": "#f59e0b",
|
|
18
|
+
"entities": {
|
|
19
|
+
"product": { "viewName": "accountant", "quickAdd": false }
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"product_menu": {
|
|
3
|
+
"label": "Products",
|
|
4
|
+
"items": {
|
|
5
|
+
"catalog": {
|
|
6
|
+
"orderNum": 1,
|
|
7
|
+
"label": "Catalog",
|
|
8
|
+
"icon": "box-seam",
|
|
9
|
+
"children": {
|
|
10
|
+
"all_products": {
|
|
11
|
+
"itemType": "V",
|
|
12
|
+
"label": "Products",
|
|
13
|
+
"icon": "box-seam",
|
|
14
|
+
"dataViewCode": "product_grid",
|
|
15
|
+
"orderNum": 1
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -6,10 +6,15 @@
|
|
|
6
6
|
"dbSchemaVersion": "0.0.1",
|
|
7
7
|
"displayName": "Simple Todo",
|
|
8
8
|
"description": "Minimal example module demonstrating the dForge package format",
|
|
9
|
-
"author": {
|
|
9
|
+
"author": {
|
|
10
|
+
"name": "dForge Examples"
|
|
11
|
+
},
|
|
10
12
|
"license": "MIT",
|
|
11
13
|
"category": "Examples",
|
|
12
|
-
"tags": [
|
|
14
|
+
"tags": [
|
|
15
|
+
"example",
|
|
16
|
+
"todo"
|
|
17
|
+
],
|
|
13
18
|
"icon": "bi-check-square",
|
|
14
19
|
"dependencies": {
|
|
15
20
|
"admin": ">=0.0.1"
|
|
@@ -19,14 +24,5 @@
|
|
|
19
24
|
"entities": {
|
|
20
25
|
"todo_list": "./entities/todo_list.json",
|
|
21
26
|
"todo_item": "./entities/todo_item.json"
|
|
22
|
-
}
|
|
23
|
-
"dataViews": "./ui/data_views.json",
|
|
24
|
-
"menus": "./ui/menus.json",
|
|
25
|
-
"actions": "./ui/actions.json",
|
|
26
|
-
"security": {
|
|
27
|
-
"roles": "./security/roles.json"
|
|
28
|
-
},
|
|
29
|
-
"seedData": [
|
|
30
|
-
"./seed-data/01-lists.json"
|
|
31
|
-
]
|
|
27
|
+
}
|
|
32
28
|
}
|
|
@@ -36,8 +36,9 @@ Virtual N:1 lookup. No physical column. Always **paired with a hidden FK column*
|
|
|
36
36
|
The example below is a **required** relation, so both halves carry `M`. For an optional
|
|
37
37
|
one, drop it from both: FK `"E"`, Reference `"VE"`. `M` resolves to `isNullable: false`
|
|
38
38
|
at install, so it is what makes the FK column `NOT NULL` — and while `M` is inert on the
|
|
39
|
-
virtual Reference half
|
|
40
|
-
|
|
39
|
+
virtual Reference half (at the entity level; a per-view override behaves differently, see
|
|
40
|
+
`flags.md`), leaving it there over an optional FK reads as a required field to the next
|
|
41
|
+
author. `dforge_entity_reference_add({ required })` emits both halves in step.
|
|
41
42
|
|
|
42
43
|
```json
|
|
43
44
|
// Hidden FK column — physical, hidden. "E" instead of "EM" if the relation is optional.
|
|
@@ -4,6 +4,8 @@ Data views define how users see and interact with entity data. One entity can ha
|
|
|
4
4
|
|
|
5
5
|
Lives in: `ui/data_views.json`
|
|
6
6
|
|
|
7
|
+
> **Not to be confused with entity views.** An *entity view* (`views` on an entity file) is column-level **security** — which columns a folder may see at all — and is what `folders.json`'s `viewName` binds. A *data view* is a presentation of an entity, referenced by a menu's `dataViewCode`. A third thing, `isView` + `viewSql` on an entity, means the entity is backed by a SQL view. See `security.md` → Column-level security.
|
|
8
|
+
|
|
7
9
|
## Structure
|
|
8
10
|
|
|
9
11
|
```json
|
|
@@ -2,19 +2,21 @@
|
|
|
2
2
|
|
|
3
3
|
Column flags are single-letter codes concatenated into a string. Order doesn't matter — `"VEM"` and `"EMV"` are equivalent.
|
|
4
4
|
|
|
5
|
-
Valid flags regex: `^[
|
|
5
|
+
Valid flags regex: `^[VIEM]*$`
|
|
6
6
|
|
|
7
7
|
## Flag letters
|
|
8
8
|
|
|
9
9
|
| Letter | Name | Meaning |
|
|
10
10
|
|---|---|---|
|
|
11
11
|
| `V` | Visible | Column appears in grid / form / card views. Omit to hide from standard UI. |
|
|
12
|
-
| `I` |
|
|
12
|
+
| `I` | Identity | Auto-managed by the platform. Used on PK columns, audit timestamps, system fields. Not shown in UI, not directly editable by users, excluded from required validation. |
|
|
13
13
|
| `E` | Editable | User can modify the value in the UI. Omit for read-only. |
|
|
14
14
|
| `M` | Mandatory | Required. Resolved into `isNullable: false` at install — see below. |
|
|
15
|
-
| `H` | Hidden | Permanently hidden — never shown, not even in admin metadata views. Stronger than omitting `V`. |
|
|
16
15
|
|
|
17
|
-
**Only these
|
|
16
|
+
**Only these 4 letters are valid.** The platform rejects any other characters.
|
|
17
|
+
> **There is no `H` flag.** Earlier drafts listed one, described as "stronger than omitting `V`", and nothing ever read it — a column with `flags: "H"` is hidden because the string contains no `V`, not because it contains `H`. To hide a column, omit `V`. To hide it from specific folders (and make it unqueryable there), use an entity view. To render it as a hidden input, use `fieldTypeCd: "hidden"`.
|
|
18
|
+
|
|
19
|
+
> **The same letters override per entity view.** `views.<v>.columns.<cd>.flags` re-flags a column for the folders bound to that view, while the entity keeps its own flags everywhere else. Same `^[VIEM]*$` vocabulary — see "Per-view overrides" below. See `security.md` → Column-level security.
|
|
18
20
|
|
|
19
21
|
## `M` is the platform's one notion of "required"
|
|
20
22
|
|
|
@@ -31,7 +33,8 @@ Four consequences worth knowing before you type a flag:
|
|
|
31
33
|
entity and every offending field. It's a contradiction only you can resolve: drop
|
|
32
34
|
`M` if the column is optional, or drop `isNullable` if it is required.
|
|
33
35
|
- **`M` is inert on virtual (`R`/`S`/`F`) and identity columns** — nullability is
|
|
34
|
-
meaningless where there is no physical column.
|
|
36
|
+
meaningless where there is no physical column. (At the *entity* level; a per-view
|
|
37
|
+
override behaves differently — see "Per-view overrides".) It won't fail, but `VEM` on a
|
|
35
38
|
Reference over an *optional* FK reads as a required field to the next author, which
|
|
36
39
|
is exactly how these get out of step. Match the Reference to its FK.
|
|
37
40
|
- **Adding `M` to a shipped field aborts the upgrade** on any tenant whose existing
|
|
@@ -41,6 +44,33 @@ Four consequences worth knowing before you type a flag:
|
|
|
41
44
|
|
|
42
45
|
Omitting both `M` and `isNullable` leaves the column nullable — that is the default.
|
|
43
46
|
|
|
47
|
+
## Per-view overrides
|
|
48
|
+
|
|
49
|
+
`views.<v>.columns.<cd>.flags` re-flags one column for the folders bound to that view. The letters are the same four, and `M` means the same thing — the installer folds it into **that override's** `isNullable`.
|
|
50
|
+
|
|
51
|
+
Because a view writes no DDL, the requiredness `M` produces there is runtime-only. That is how you express **"nullable in the database, mandatory in this view"**, which no entity-level flag can say:
|
|
52
|
+
|
|
53
|
+
```json
|
|
54
|
+
"views": {
|
|
55
|
+
"compliance": {
|
|
56
|
+
"columns": {
|
|
57
|
+
"party_id": {},
|
|
58
|
+
"party_name": { "flags": "V" },
|
|
59
|
+
"vat_no": { "flags": "VEM" },
|
|
60
|
+
"salary": { "flags": "" }
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Three rules to keep straight:
|
|
67
|
+
|
|
68
|
+
- **The letters replace, they don't merge.** The cascade is `COALESCE(view, entity)`. A non-empty string wins outright, so `{ "flags": "V" }` is read-only there however the entity flagged the column.
|
|
69
|
+
- **`""` is a real value, omission is not.** An empty string means "no flags here" — neither visible nor editable. To inherit, leave the key out; `"flags": null` fails schema validation, since the property is typed `string`. (To inherit *everything*, set the whole column override to `{}` or `null`.)
|
|
70
|
+
- **The `M` fold is one-directional.** Dropping `M` from a view's flags does NOT make the column optional — `isNullable` still `COALESCE`s to the entity value. Write `"isNullable": true` to loosen it, and only where the database actually allows null, or the save trades a clean validation error for a raw constraint violation.
|
|
71
|
+
|
|
72
|
+
Unlike the entity level, `M` here is *not* inert on virtual or identity columns: the fold only reaches the runtime required check, so a view can mark a Reference required. (Identity columns stay excluded — they are never asked of the user.)
|
|
73
|
+
|
|
44
74
|
## Common column flag combinations
|
|
45
75
|
|
|
46
76
|
| Flags | Meaning | Use case |
|
|
@@ -50,7 +80,7 @@ Omitting both `M` and `isNullable` leaves the column nullable — that is the de
|
|
|
50
80
|
| `"V"` | Visible, read-only | Display-only, like formula columns or computed values |
|
|
51
81
|
| `"E"` | Editable, **not visible** | Hidden FK column of an *optional* relation |
|
|
52
82
|
| `"EM"` | Editable, Mandatory, **not visible** | Hidden FK column of a *required* relation |
|
|
53
|
-
| `"I"` |
|
|
83
|
+
| `"I"` | Identity | Trait-provided columns (PK, audit timestamps). Platform-managed. |
|
|
54
84
|
| `""` (empty) | No flags | Rare — column exists but is invisible and not editable |
|
|
55
85
|
|
|
56
86
|
## Rules
|
|
@@ -60,9 +90,9 @@ Omitting both `M` and `isNullable` leaves the column nullable — that is the de
|
|
|
60
90
|
2. **Visible Reference columns**: `"VE"`, or `"VEM"` when the FK is required — **keep the
|
|
61
91
|
two halves in step**. `dforge_entity_reference_add({ required })` sets both for you.
|
|
62
92
|
3. **Formula columns**: usually `"V"` (visible, read-only). The user never edits them.
|
|
63
|
-
4. **Trait columns** (PK, audit): use `"I"` (
|
|
93
|
+
4. **Trait columns** (PK, audit): use `"I"` (identity). These are set automatically by `traits: ["identity", "audit"]`.
|
|
64
94
|
5. **Don't use `I` on your own columns** — it's reserved for trait-provided and platform-managed columns.
|
|
65
|
-
6. **Don't mix `I` with `V` or `E`** —
|
|
95
|
+
6. **Don't mix `I` with `V` or `E`** — identity columns are not user-facing.
|
|
66
96
|
|
|
67
97
|
## Entity rights flags (different namespace — roles only)
|
|
68
98
|
|
|
@@ -79,7 +109,7 @@ These letters appear in role `rights` declarations, **NOT** on column flags. Don
|
|
|
79
109
|
|
|
80
110
|
So `"SIUD"` on a role grants full CRUD; `"SI"` grants read + create only.
|
|
81
111
|
|
|
82
|
-
**Note**: the `I` in column flags (
|
|
112
|
+
**Note**: the `I` in column flags (Identity) and the `I` in role rights (Insert) are different concepts using the same letter. Context makes them unambiguous — column flags go on entity fields, role rights go on `security/roles.json`.
|
|
83
113
|
|
|
84
114
|
## How uniqueness, PKs, and search work
|
|
85
115
|
|
|
@@ -101,4 +131,6 @@ These are **NOT** flag letters. They're separate properties on the column defini
|
|
|
101
131
|
- Putting `M` on a Reference whose hidden FK is optional — inert, but it misreports the field as required.
|
|
102
132
|
- Using `"VEM"` on trait-provided columns (PK, audit) — **don't**. Traits set their own flags (`"I"`).
|
|
103
133
|
- Using `"I"` on business columns — **don't**. `I` means "platform-managed"; regular business columns use `V`/`E`/`M`.
|
|
134
|
+
- Expecting a per-view `{ "flags": "VE" }` to make a column **optional** because it drops `M` — it doesn't. `isNullable` still inherits; write `"isNullable": true`.
|
|
135
|
+
- Writing `"flags": null` in a view override to inherit — fails validation. Omit the key.
|
|
104
136
|
- Using long names like `"Visible,Editable"` — **wrong**. Single letters concatenated: `"VE"`.
|
|
@@ -3,6 +3,13 @@
|
|
|
3
3
|
Formulas are used in:
|
|
4
4
|
|
|
5
5
|
- **Formula columns** (`columnType: "F"`) — computed values
|
|
6
|
+
- **Per-view formula overrides** (`views.<v>.columns.<cd>.formula`) — one entity view
|
|
7
|
+
computing a column differently from the entity. Allowed **only** on a column the
|
|
8
|
+
entity declares as `columnType: "F"`; on any other column the install FAILS, because
|
|
9
|
+
there that field holds the SQL default and a view cannot change a physical column's
|
|
10
|
+
`DEFAULT`. (There is no per-view default value for a normal column.) Same rules as an
|
|
11
|
+
`F` column otherwise: it must parse, call only implemented functions, and not
|
|
12
|
+
aggregate over a 1:N set. See `security.md` → Column-level security.
|
|
6
13
|
- **Action `canExecute:` blocks** — availability checks. Evaluated on both sides
|
|
7
14
|
and **enforced** by `action.execute`, but only over a narrow subset: comparisons
|
|
8
15
|
between fields and literals combined with `AND` / `OR` / `NOT`. Functions,
|
|
@@ -213,7 +213,7 @@ Lives in: `ui/folders.json`
|
|
|
213
213
|
|
|
214
214
|
| Property | Type | Description |
|
|
215
215
|
|---|---|---|
|
|
216
|
-
| `viewName` | string |
|
|
216
|
+
| `viewName` | string | Entity view to bind — a key under the entity's `views` (see [Column-level security](#column-level-security-entity-views)), **not** a `ui/data_views.json` code. `"default"` means "no view" (full column set). |
|
|
217
217
|
| `quickAdd` | boolean | Show "+" quick-add button (`true` for main entities, `false` for detail/line items) |
|
|
218
218
|
| `rowFilter` | object | Row-level filter scoping which records are visible in this folder |
|
|
219
219
|
|
|
@@ -226,6 +226,82 @@ Row filters use a compact filter object with:
|
|
|
226
226
|
|
|
227
227
|
This is how **row-level security** works in the module package — subfolders with `rowFilter` restrict which rows are visible. Users assigned to "Central Warehouse" only see stock where `warehouse_id = 2001`.
|
|
228
228
|
|
|
229
|
+
## Column-level security (entity views)
|
|
230
|
+
|
|
231
|
+
> Worked example: `dforge://example/column-security/` — one entity, two folders, two
|
|
232
|
+
> column sets. Its README walks the overrides and the ways a view fails the install.
|
|
233
|
+
|
|
234
|
+
Layer 2 of the model. A **entity view** is a named subset of an entity's columns, declared on the entity itself; a folder binds one per entity. Users working in that folder see **only** the columns the view lists — the rest are neither visible nor queryable there.
|
|
235
|
+
|
|
236
|
+
Two halves, both required:
|
|
237
|
+
|
|
238
|
+
| File | Key | Effect |
|
|
239
|
+
|---|---|---|
|
|
240
|
+
| `entities/<entity>.json` | `views.<viewName>.columns` | Declares the view and its column set |
|
|
241
|
+
| `ui/folders.json` | `entities.<code>.viewName` | Binds that view for this entity in this folder |
|
|
242
|
+
|
|
243
|
+
```json
|
|
244
|
+
{
|
|
245
|
+
"fields": { "product_id": {}, "name": {}, "price": {}, "cost": {}, "quantity": {} },
|
|
246
|
+
"views": {
|
|
247
|
+
"accountant": {
|
|
248
|
+
"columns": {
|
|
249
|
+
"product_id": {},
|
|
250
|
+
"name": { "flags": "V" },
|
|
251
|
+
"price": { "flags": "VE" },
|
|
252
|
+
"cost": { "flags": "V" }
|
|
253
|
+
}
|
|
254
|
+
},
|
|
255
|
+
"storekeeper": {
|
|
256
|
+
"columns": {
|
|
257
|
+
"product_id": {},
|
|
258
|
+
"name": { "flags": "V" },
|
|
259
|
+
"quantity": { "flags": "VE" }
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
```json
|
|
267
|
+
"entities": { "product": { "viewName": "accountant", "quickAdd": true } }
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
The storekeeper folder cannot see `price` or `cost` at all — not on screen, not through a filter, not in an export.
|
|
271
|
+
|
|
272
|
+
**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".
|
|
273
|
+
|
|
274
|
+
> **An empty override is a value, not an absence.** The runtime merges view over entity with `COALESCE`, 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 — `"flags": null` fails schema validation. See `flags.md` for the flag letters.
|
|
275
|
+
|
|
276
|
+
> **`M` works in a view override too**, folding into that override's `isNullable`. Since a view writes no DDL, the requiredness is runtime-only — which is how a column nullable in the database is made mandatory inside one view: `{ "flags": "VEM" }`. The fold is one-directional: dropping `M` does not loosen the column, because `isNullable` still inherits. See `flags.md` → Per-view overrides.
|
|
277
|
+
|
|
278
|
+
`formula` lets one view compute a column differently from the entity — same DSL as a field's `formula`, and **only on a column the entity declares as `columnType: "F"`**; elsewhere the install fails, because there that field holds the SQL default and a view cannot change a physical column's `DEFAULT`. There is no per-view default value for a normal column. See `formulas.md`.
|
|
279
|
+
|
|
280
|
+
### Rules the installer enforces
|
|
281
|
+
|
|
282
|
+
Each of these is an install-time error, and each is silent at runtime if it slips through — which is why `dforge_module_validate` checks them offline:
|
|
283
|
+
|
|
284
|
+
- A column absent from `columns` is **hidden**. That is the mechanism, not an oversight.
|
|
285
|
+
- A view must list the entity's **primary key** — records are addressed by it. To keep it off screen, drop `V` from its flags instead of omitting it.
|
|
286
|
+
- A column code that doesn't exist on the entity, a view with **no columns**, the same column listed twice under different casing, or two views on one entity whose names differ only by case (a folder binds case-insensitively, so only one could ever be reached).
|
|
287
|
+
- Views must be declared on the entity's **own** module. An `extends` extension declaring `views` is rejected — a view enumerates the complete column set, which an extension does not own.
|
|
288
|
+
- A `viewName` that no view declares, or one naming a view with no columns, **fails the install**. It does not fall back: the fallback is the entity's full column set, so a typo would quietly *unrestrict* the folder.
|
|
289
|
+
- Columns removed from a view are reaped on upgrade — that is the security change the author asked for. Whole views are not: `entity_view` also holds tenant views from the card-layout editor, and the installer cannot tell them apart.
|
|
290
|
+
|
|
291
|
+
### `"default"` is the one exempt name
|
|
292
|
+
|
|
293
|
+
`"viewName": "default"` means **no view** (full column set) and declares nothing. It is what every shipped module writes, and what the runtime auto-creates for an entity reached without a folder binding — so it is exempt from the checks above. A module that really declares a view named `default` still gets it bound.
|
|
294
|
+
|
|
295
|
+
### Don't confuse the three "views"
|
|
296
|
+
|
|
297
|
+
| Term | Where | What it is |
|
|
298
|
+
|---|---|---|
|
|
299
|
+
| **Entity view** | `entities/*.json` → `views` | Column-level security (this section) |
|
|
300
|
+
| **Data view** | `ui/data_views.json` | A grid / kanban / calendar over an entity (`data-views.md`) |
|
|
301
|
+
| **SQL view** | `entities/*.json` → `isView` + `viewSql` | An entity backed by a SQL view instead of a table |
|
|
302
|
+
|
|
303
|
+
`viewName` in `folders.json` binds the first. Menus' `dataViewCode` references the second.
|
|
304
|
+
|
|
229
305
|
### When to use subfolders
|
|
230
306
|
|
|
231
307
|
- **Multi-location modules** (warehouses, offices, branches) — one subfolder per location, filtered by location FK
|
|
@@ -105,6 +105,8 @@ For each entity:
|
|
|
105
105
|
## Security
|
|
106
106
|
|
|
107
107
|
- [ ] `security/roles.json` exists (at least one role)
|
|
108
|
+
- [ ] Every entity view (`views.<v>.columns` on an entity) lists the entity's **primary key**, names only real columns, and has at least one column — each of these fails the install
|
|
109
|
+
- [ ] Every `viewName` in `ui/folders.json` is either `"default"` (= no view) or a view that entity actually declares — an unresolved name fails the install rather than falling back
|
|
108
110
|
- [ ] Every role has `description` and `rights` (both required; no `label` field in `roles.json` — the `roles.schema.json` sets `additionalProperties: false`, so any extra field including `label` is rejected here). `description` is the English display-name fallback; **localized role labels live in the translation files** (`roles.<code>.label`) and are completeness-enforced — see Translations below.
|
|
109
111
|
- [ ] The field name is `rights`, NOT `entityRights`
|
|
110
112
|
- [ ] Rights strings use only valid letters: `SIUDC` for entities, `E` for actions/reports/folders
|
|
@@ -31,6 +31,7 @@ flags, or column patterns.
|
|
|
31
31
|
| Add a grid / list view | `dforge://reference/data-views`, `dforge://example/ui/data_views.json` | — |
|
|
32
32
|
| Add a matrix (pivot) view | `dforge://reference/data-views` (§Matrix), `dforge://example/matrix-budget/ui/data_views.json`, `dforge://example/matrix-budget/entities/budget_line.json` | — |
|
|
33
33
|
| Add kanban / calendar / tree-grid / master-detail | `dforge://reference/data-views` | `dforge://schema/data-views` |
|
|
34
|
+
| Hide columns from a user type (column-level security) | `dforge://reference/security` (§Column-level security), `dforge://example/column-security/entities/product.json`, `dforge://example/column-security/ui/folders.json` | — |
|
|
34
35
|
| Add a menu | `dforge://reference/menus`, `dforge://example/ui/menus.json` | — |
|
|
35
36
|
| Add an action | `dforge://reference/action-dsl`, `dforge://example/logic/actions/mark_done.dsl`, `dforge://example/ui/actions.json` | `dforge://docs/dsl` |
|
|
36
37
|
| Add a trigger | — | `dforge://schema/triggers`; condition syntax in `dforge://docs/dsl` |
|
|
@@ -204,8 +205,11 @@ per entity, so you'll often `view_modify` rather than `view_add`.
|
|
|
204
205
|
|
|
205
206
|
View codes are semantic — the entity name (`feedback_item`), a plural
|
|
206
207
|
(`invoices`), or descriptive (`invoices_kanban`). **Never use the literal code
|
|
207
|
-
`default
|
|
208
|
-
|
|
208
|
+
`default`.** Note that `ui/folders.json`'s `viewName` is a different axis
|
|
209
|
+
entirely: it binds an *entity view* (column-level security, declared under
|
|
210
|
+
`views` on the entity file), not a data view code. `"default"` there means "no
|
|
211
|
+
entity view — show the full column set", and any other name the entity does not
|
|
212
|
+
declare **fails the install**. See `references/security.md`.
|
|
209
213
|
|
|
210
214
|
Then wire menus with `dforge_menu_add` — a section node per group, leaves
|
|
211
215
|
pointing at the views.
|
|
@@ -273,6 +277,13 @@ stated trigger; every report's params sit on a dataset.
|
|
|
273
277
|
4. **Show the rights matrix as a table** (rows = entities/actions/reports,
|
|
274
278
|
columns = roles, cells = rights strings), each cell explained by the verb it
|
|
275
279
|
maps to. Get sign-off.
|
|
280
|
+
5. **Column-level security only when the intake asks for it.** Roles grant rights
|
|
281
|
+
per entity, not per field. When a user type must not see specific *columns*
|
|
282
|
+
(salaries, cost prices, personal data), that is an **entity view**: declare
|
|
283
|
+
`views.<name>.columns` on the entity and bind it with `viewName` on the folder
|
|
284
|
+
that user type works in. Don't reach for it otherwise — a view is the complete
|
|
285
|
+
column list, so it is one more list to keep in step with the fields. Rules and
|
|
286
|
+
the worked example: `references/security.md` → Column-level security.
|
|
276
287
|
5. `dforge_role_add` for new roles; `dforge_role_right_set` per grant when
|
|
277
288
|
amending an existing one.
|
|
278
289
|
|