@protobase/server 0.10.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/check-views.js +28 -5
- package/dist/create-admin.js +1 -1
- package/dist/view-access.js +136 -37
- package/package.json +4 -4
package/dist/check-views.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { relatedErrors } from "./view-related.js";
|
|
2
|
+
import { addressErrors, addressPlaceholders, appHrefError, displayFields, fieldRefsIn, hrefPlaceholders } from "@protobase/schema";
|
|
2
3
|
//#region src/check-views.ts
|
|
4
|
+
const linkFields = (run) => run?.kind !== "link" ? [] : "to" in run ? addressPlaceholders(run.to) : hrefPlaceholders(run.href);
|
|
3
5
|
const sensitiveErrors = (view, model) => {
|
|
4
6
|
const sensitive = (name) => Boolean(model.fields[name]?.sensitive);
|
|
5
7
|
return [
|
|
@@ -10,11 +12,12 @@ const sensitiveErrors = (view, model) => {
|
|
|
10
12
|
...(view.list?.sort ?? []).map(([name]) => name),
|
|
11
13
|
...view.list?.search ?? []
|
|
12
14
|
]],
|
|
15
|
+
["a list display", (view.list?.displays ?? []).flatMap(displayFields)],
|
|
13
16
|
["a filter", view.filters.map((widget) => widget.field)],
|
|
14
17
|
["the chart", view.chart ? [view.chart.field] : []],
|
|
15
18
|
["the sidebar", view.layout.flatMap((item) => item.kind === "sidebar" ? item.fields : [])],
|
|
16
19
|
["the recent records", view.nav?.recent ? [view.nav.recent.status] : []],
|
|
17
|
-
["an action", view.actions.flatMap((action) => action.run?.kind === "update" ? Object.keys(action.run.values) : action.run
|
|
20
|
+
["an action", view.actions.flatMap((action) => action.run?.kind === "update" ? Object.keys(action.run.values) : linkFields(action.run))]
|
|
18
21
|
].flatMap(([place, names]) => [...new Set(names.filter(sensitive))].map((name) => `sensitive field "${name}" cannot be in ${place}`));
|
|
19
22
|
};
|
|
20
23
|
const valueLabelErrors = (view, model) => Object.entries(view.fields).flatMap(([name, hints]) => {
|
|
@@ -23,20 +26,40 @@ const valueLabelErrors = (view, model) => Object.entries(view.fields).flatMap(([
|
|
|
23
26
|
if (field?.type !== "enum") return [`value labels of "${name}" need an enum field`];
|
|
24
27
|
return Object.keys(hints.valueLabels).filter((value) => !field.enumValues?.includes(value)).map((value) => `value label "${value}" is not a value of "${name}"`);
|
|
25
28
|
});
|
|
29
|
+
const linkErrors = (view, model, models, pages) => view.actions.flatMap(({ name, run }) => {
|
|
30
|
+
if (run?.kind !== "link") return [];
|
|
31
|
+
if ("href" in run) {
|
|
32
|
+
const error = appHrefError(run.href);
|
|
33
|
+
return error ? [`action "${name}": ${error}`] : [];
|
|
34
|
+
}
|
|
35
|
+
return addressErrors(run.to, {
|
|
36
|
+
models,
|
|
37
|
+
pages,
|
|
38
|
+
source: model
|
|
39
|
+
}).map((error) => `action "${name}": ${error}`);
|
|
40
|
+
});
|
|
41
|
+
const unknownFieldErrors = (view, model) => {
|
|
42
|
+
const unknown = (names, what) => names.filter((name) => !Object.hasOwn(model.fields, name)).map((name) => `${what} names field "${name}", which ${model.name} does not have`);
|
|
43
|
+
return [...(view.list?.displays ?? []).flatMap((display) => unknown(displayFields(display), `display "${display.name}"`)), ...view.layout.flatMap((item) => item.kind === "custom" ? unknown(fieldRefsIn(item.props), `custom section ${item.component}`) : [])];
|
|
44
|
+
};
|
|
26
45
|
/**
|
|
27
|
-
* Fails at startup when a view uses a sensitive field where it would show empty,
|
|
28
|
-
*
|
|
46
|
+
* Fails at startup when a view uses a sensitive field where it would show empty, a display or custom section names a
|
|
47
|
+
* field the resource does not have, labels values its enum does not have,
|
|
48
|
+
* has a related section that does not fit the resources, or a link to an address the app does not have.
|
|
29
49
|
*/
|
|
30
|
-
const checkViews = (registry, views) => {
|
|
50
|
+
const checkViews = (registry, views, pages = []) => {
|
|
31
51
|
const models = Object.fromEntries(registry.entries.map((entry) => [entry.name, entry.model]));
|
|
52
|
+
const pageNames = new Set(pages);
|
|
32
53
|
const problems = views.flatMap((view) => {
|
|
33
54
|
const model = models[view.resource];
|
|
34
55
|
if (!model) return [];
|
|
35
56
|
const related = view.layout.flatMap((item) => item.kind === "related" ? relatedErrors(item, view.resource, models).map((error) => `related "${item.title}": ${error}`) : []);
|
|
36
57
|
return [
|
|
37
58
|
...sensitiveErrors(view, model),
|
|
59
|
+
...unknownFieldErrors(view, model),
|
|
38
60
|
...valueLabelErrors(view, model),
|
|
39
|
-
...related
|
|
61
|
+
...related,
|
|
62
|
+
...linkErrors(view, model, models, pageNames)
|
|
40
63
|
].map((problem) => `View "${view.resource}": ${problem}`);
|
|
41
64
|
});
|
|
42
65
|
if (problems.length > 0) throw new Error(`The views do not match the resources:\n${problems.map((problem) => ` - ${problem}`).join("\n")}`);
|
package/dist/create-admin.js
CHANGED
|
@@ -50,7 +50,7 @@ const createAdmin = ({ resources: appResources, views: appViews = [], pages = []
|
|
|
50
50
|
checkShell(registry, viewModels, userMenuModel, pageModels.map((entry) => entry.name));
|
|
51
51
|
const bottomBarModels = bottomBars.map((bar) => bar.toBottomBarModel());
|
|
52
52
|
checkBottomBars(registry, bottomBarModels, pageModels.map((entry) => entry.name), declaredRoles(auth, options.roles));
|
|
53
|
-
checkViews(registry, viewModels);
|
|
53
|
+
checkViews(registry, viewModels, pageModels.map((entry) => entry.name));
|
|
54
54
|
checkPages(pageModels, {
|
|
55
55
|
models: Object.fromEntries(registry.entries.map((entry) => [entry.name, entry.model])),
|
|
56
56
|
actions: (resource) => new Set(viewModels.filter((view) => view.resource === resource).flatMap((view) => view.actions.map((action) => action.name)))
|
package/dist/view-access.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { relatedErrors } from "./view-related.js";
|
|
2
2
|
import { resolveFieldPath } from "@protobase/layout";
|
|
3
|
-
import { navRecentErrors } from "@protobase/schema";
|
|
3
|
+
import { addressErrors, hrefPlaceholders, navRecentErrors, pruneByShape, shapes, tableDisplay } from "@protobase/schema";
|
|
4
4
|
//#region src/view-access.ts
|
|
5
5
|
const pruneRelated = (item, resource, models) => {
|
|
6
6
|
const { columns, ...rest } = item;
|
|
@@ -9,46 +9,145 @@ const pruneRelated = (item, resource, models) => {
|
|
|
9
9
|
...rest,
|
|
10
10
|
columns: visible
|
|
11
11
|
} : rest;
|
|
12
|
-
return relatedErrors(narrowed, resource, models).length === 0 ?
|
|
12
|
+
return relatedErrors(narrowed, resource, models).length === 0 ? narrowed : void 0;
|
|
13
13
|
};
|
|
14
|
+
const keepDefault = ({ default: opens, displays, ...list }) => {
|
|
15
|
+
const shown = displays && displays.length > 0 ? displays : void 0;
|
|
16
|
+
const kept = opens === tableDisplay || shown?.some((display) => display.name === opens);
|
|
17
|
+
return {
|
|
18
|
+
...list,
|
|
19
|
+
...shown && { displays: shown },
|
|
20
|
+
...opens !== void 0 && kept && { default: opens }
|
|
21
|
+
};
|
|
22
|
+
};
|
|
23
|
+
const listShape = shapes.object({
|
|
24
|
+
columns: shapes.list(shapes.field),
|
|
25
|
+
sort: shapes.list(shapes.tuple(shapes.field, shapes.data)),
|
|
26
|
+
search: shapes.list(shapes.field),
|
|
27
|
+
displays: shapes.list(shapes.oneOf({
|
|
28
|
+
cards: shapes.object({
|
|
29
|
+
kind: shapes.data,
|
|
30
|
+
name: shapes.data,
|
|
31
|
+
label: shapes.data,
|
|
32
|
+
icon: shapes.data,
|
|
33
|
+
title: shapes.field,
|
|
34
|
+
subtitle: shapes.field,
|
|
35
|
+
badge: shapes.field,
|
|
36
|
+
meta: shapes.list(shapes.field)
|
|
37
|
+
}),
|
|
38
|
+
custom: shapes.object({
|
|
39
|
+
kind: shapes.data,
|
|
40
|
+
name: shapes.data,
|
|
41
|
+
label: shapes.data,
|
|
42
|
+
icon: shapes.data,
|
|
43
|
+
component: shapes.data,
|
|
44
|
+
fields: shapes.list(shapes.field),
|
|
45
|
+
props: shapes.refs
|
|
46
|
+
}, { requires: ["props"] })
|
|
47
|
+
})),
|
|
48
|
+
default: shapes.data
|
|
49
|
+
}, { after: keepDefault });
|
|
50
|
+
const widgetShape = shapes.oneOf({
|
|
51
|
+
range: shapes.object({
|
|
52
|
+
kind: shapes.data,
|
|
53
|
+
field: shapes.field,
|
|
54
|
+
histogram: shapes.data
|
|
55
|
+
}, { requires: ["field"] }),
|
|
56
|
+
facets: shapes.object({
|
|
57
|
+
kind: shapes.data,
|
|
58
|
+
field: shapes.field,
|
|
59
|
+
search: shapes.data
|
|
60
|
+
}, { requires: ["field"] }),
|
|
61
|
+
dateRange: shapes.object({
|
|
62
|
+
kind: shapes.data,
|
|
63
|
+
field: shapes.field,
|
|
64
|
+
presets: shapes.data
|
|
65
|
+
}, { requires: ["field"] }),
|
|
66
|
+
toggle: shapes.object({
|
|
67
|
+
kind: shapes.data,
|
|
68
|
+
field: shapes.field
|
|
69
|
+
}, { requires: ["field"] })
|
|
70
|
+
});
|
|
71
|
+
const layoutShape = (resource, models) => shapes.oneOf({
|
|
72
|
+
section: shapes.object({
|
|
73
|
+
kind: shapes.data,
|
|
74
|
+
title: shapes.data,
|
|
75
|
+
help: shapes.data,
|
|
76
|
+
fields: shapes.list(shapes.field, { nonEmpty: true })
|
|
77
|
+
}, { requires: ["fields"] }),
|
|
78
|
+
sidebar: shapes.object({
|
|
79
|
+
kind: shapes.data,
|
|
80
|
+
fields: shapes.list(shapes.field, { nonEmpty: true })
|
|
81
|
+
}, { requires: ["fields"] }),
|
|
82
|
+
related: shapes.map((item) => pruneRelated(item, resource, models)),
|
|
83
|
+
custom: shapes.object({
|
|
84
|
+
kind: shapes.data,
|
|
85
|
+
component: shapes.data,
|
|
86
|
+
title: shapes.data,
|
|
87
|
+
sidebar: shapes.data,
|
|
88
|
+
props: shapes.refs
|
|
89
|
+
}, { requires: ["props"] })
|
|
90
|
+
});
|
|
91
|
+
const linkShape = (model, models) => shapes.map((run) => {
|
|
92
|
+
return ("to" in run ? addressErrors(run.to, {
|
|
93
|
+
models,
|
|
94
|
+
source: model
|
|
95
|
+
}).length === 0 : hrefPlaceholders(run.href).every((name) => Object.hasOwn(model.fields, name))) ? run : void 0;
|
|
96
|
+
});
|
|
97
|
+
const actionShape = (model, models) => shapes.object({
|
|
98
|
+
name: shapes.data,
|
|
99
|
+
label: shapes.data,
|
|
100
|
+
icon: shapes.data,
|
|
101
|
+
confirm: shapes.data,
|
|
102
|
+
bulk: shapes.data,
|
|
103
|
+
run: shapes.oneOf({
|
|
104
|
+
update: shapes.object({
|
|
105
|
+
kind: shapes.data,
|
|
106
|
+
values: shapes.keys({ all: true })
|
|
107
|
+
}, { requires: ["values"] }),
|
|
108
|
+
delete: shapes.data,
|
|
109
|
+
link: linkShape(model, models)
|
|
110
|
+
})
|
|
111
|
+
}, { requires: ["run"] });
|
|
112
|
+
const viewShape = (resource, model, models) => shapes.object({
|
|
113
|
+
resource: shapes.data,
|
|
114
|
+
roles: shapes.data,
|
|
115
|
+
title: shapes.field,
|
|
116
|
+
searchResult: shapes.object({
|
|
117
|
+
title: shapes.list(shapes.field, { nonEmpty: true }),
|
|
118
|
+
subtitle: shapes.field
|
|
119
|
+
}),
|
|
120
|
+
nav: shapes.object({
|
|
121
|
+
hidden: shapes.data,
|
|
122
|
+
group: shapes.data,
|
|
123
|
+
order: shapes.data,
|
|
124
|
+
recent: shapes.map((recent) => navRecentErrors(recent, model).length === 0 ? recent : void 0)
|
|
125
|
+
}),
|
|
126
|
+
names: shapes.data,
|
|
127
|
+
icon: shapes.data,
|
|
128
|
+
help: shapes.data,
|
|
129
|
+
fields: shapes.keys(),
|
|
130
|
+
list: listShape,
|
|
131
|
+
filters: shapes.list(widgetShape),
|
|
132
|
+
layout: shapes.list(layoutShape(resource, models)),
|
|
133
|
+
chart: shapes.object({
|
|
134
|
+
field: shapes.field,
|
|
135
|
+
range: shapes.data,
|
|
136
|
+
granularity: shapes.data
|
|
137
|
+
}, { requires: ["field"] }),
|
|
138
|
+
saveFeedback: shapes.data,
|
|
139
|
+
actions: shapes.list(actionShape(model, models))
|
|
140
|
+
});
|
|
14
141
|
/**
|
|
15
|
-
* The view with every reference to a field the caller cannot read removed: columns, sort, search
|
|
16
|
-
* filter widgets,
|
|
17
|
-
*
|
|
18
|
-
* `models`, the caller's view of every resource.
|
|
142
|
+
* The view with every reference to a field the caller cannot read removed: list columns, sort, search and displays,
|
|
143
|
+
* field hints, filter widgets, record sections and custom components' props, the chart, title and search result, the
|
|
144
|
+
* actions that set or read one, and the sidebar's `recent` group. Nothing in it may name a hidden field. Related sections
|
|
145
|
+
* and links are checked against `models`, the caller's view of every resource.
|
|
19
146
|
*/
|
|
20
147
|
const pruneView = (view, model, models) => {
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
const resultTitle = searchResult?.title?.filter(visible);
|
|
25
|
-
const subtitle = searchResult?.subtitle;
|
|
26
|
-
return {
|
|
27
|
-
...rest,
|
|
28
|
-
...searchResult && { searchResult: {
|
|
29
|
-
...resultTitle?.length && { title: resultTitle },
|
|
30
|
-
...subtitle !== void 0 && visible(subtitle) && { subtitle }
|
|
31
|
-
} },
|
|
32
|
-
...nav && { nav: recent && navRecentErrors(recent, model).length === 0 ? nav : placement },
|
|
33
|
-
...title !== void 0 && visible(title) && { title },
|
|
34
|
-
...chart && visible(chart.field) && { chart },
|
|
35
|
-
...list && { list: {
|
|
36
|
-
columns: list.columns.filter(visible),
|
|
37
|
-
...list.sort && { sort: list.sort.filter(([name]) => visible(name)) },
|
|
38
|
-
...list.search && { search: list.search.filter(visible) }
|
|
39
|
-
} },
|
|
40
|
-
fields: Object.fromEntries(Object.entries(view.fields).filter(([name]) => visible(name))),
|
|
41
|
-
filters: view.filters.filter((widget) => visible(widget.field)),
|
|
42
|
-
actions: view.actions.filter((action) => action.run?.kind !== "update" || Object.keys(action.run.values).every(visible)),
|
|
43
|
-
layout: view.layout.flatMap((item) => {
|
|
44
|
-
if (item.kind === "related") return pruneRelated(item, view.resource, models);
|
|
45
|
-
const fields = item.fields.filter(visible);
|
|
46
|
-
return fields.length > 0 ? [{
|
|
47
|
-
...item,
|
|
48
|
-
fields
|
|
49
|
-
}] : [];
|
|
50
|
-
})
|
|
51
|
-
};
|
|
148
|
+
const pruned = pruneByShape(view, viewShape(view.resource, model, models), (name) => Object.hasOwn(model.fields, name));
|
|
149
|
+
if (!pruned) throw new Error(`The view of ${view.resource} is not an object`);
|
|
150
|
+
return pruned;
|
|
52
151
|
};
|
|
53
152
|
//#endregion
|
|
54
153
|
export { pruneView };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@protobase/server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
4
|
"description": "The Protobase REST API as a Hono app, with Better Auth sign-in.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|
|
@@ -30,9 +30,9 @@
|
|
|
30
30
|
},
|
|
31
31
|
"dependencies": {
|
|
32
32
|
"@better-auth/passkey": "^1.7.7",
|
|
33
|
-
"@protobase/layout": "0.
|
|
34
|
-
"@protobase/query": "0.
|
|
35
|
-
"@protobase/schema": "0.
|
|
33
|
+
"@protobase/layout": "0.11.0",
|
|
34
|
+
"@protobase/query": "0.11.0",
|
|
35
|
+
"@protobase/schema": "0.11.0",
|
|
36
36
|
"@scalar/hono-api-reference": "^0.12.10",
|
|
37
37
|
"@simplewebauthn/server": "^13.3.1",
|
|
38
38
|
"better-auth": "^1.7.7",
|