@cynodia/axiom-core 0.5.2-alpha.1 → 0.6.1-alpha.1
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/README.md +2 -2
- package/dist/authority.d.ts +63 -0
- package/dist/authority.js +253 -0
- package/dist/diagnostics.d.ts +7 -0
- package/dist/diagnostics.js +9 -0
- package/dist/graph.d.ts +6 -0
- package/dist/graph.js +17 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/ir.d.ts +12 -0
- package/dist/nodes.d.ts +25 -0
- package/dist/resolve-presentation.js +5 -2
- package/dist/server-ir.d.ts +46 -0
- package/dist/server-ir.js +5 -0
- package/dist/types.d.ts +5 -0
- package/dist/ui.d.ts +12 -0
- package/dist/ui.js +18 -0
- package/dist/validate-authority.d.ts +16 -0
- package/dist/validate-authority.js +192 -0
- package/dist/validate-presentation.js +6 -3
- package/dist/validate-value.d.ts +24 -0
- package/dist/validate-value.js +100 -0
- package/dist/validate.js +51 -70
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -21,13 +21,13 @@ builders, `Presentation`, `Theme`, `resolvePresentationMap`, `validateGraph`,
|
|
|
21
21
|
## Installation
|
|
22
22
|
|
|
23
23
|
```bash
|
|
24
|
-
npm install @cynodia/axiom-core
|
|
24
|
+
npm install @cynodia/axiom-core
|
|
25
25
|
```
|
|
26
26
|
|
|
27
27
|
Most applications should install the facade package instead, which re-exports this one:
|
|
28
28
|
|
|
29
29
|
```bash
|
|
30
|
-
npm install @cynodia/axiom
|
|
30
|
+
npm install @cynodia/axiom
|
|
31
31
|
```
|
|
32
32
|
|
|
33
33
|
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { Expression } from './expressions.js';
|
|
2
|
+
import type { NodeId } from './ids.js';
|
|
3
|
+
import type { ActionDef, ConstraintDef, StateDef, TransitionConstraintDef } from './nodes.js';
|
|
4
|
+
import type { AnyNode } from './types.js';
|
|
5
|
+
/**
|
|
6
|
+
* Who may commit a canonical mutation.
|
|
7
|
+
*
|
|
8
|
+
* Authority and persistence are separate concerns. Authority says *who decides* a value;
|
|
9
|
+
* persistence says *where a decided value survives*. A server-authoritative state may be
|
|
10
|
+
* held only in memory, and a client-authoritative state may be persisted to local storage.
|
|
11
|
+
*/
|
|
12
|
+
export type Authority = 'client' | 'server';
|
|
13
|
+
export declare const AUTHORITIES: readonly Authority[];
|
|
14
|
+
/**
|
|
15
|
+
* The scope an authorization expression resolves the caller through.
|
|
16
|
+
*
|
|
17
|
+
* It is bound to a record keyed by the field ids of the graph's principal entity, so an
|
|
18
|
+
* authorization rule is written with the same `field`/`ref` vocabulary as everything else:
|
|
19
|
+
*
|
|
20
|
+
* ```ts
|
|
21
|
+
* binary('eq', field(ref(PRINCIPAL), F_USER_ROLE), literal('admin'))
|
|
22
|
+
* ```
|
|
23
|
+
*
|
|
24
|
+
* It is bound only where a server evaluates. A client never sees it.
|
|
25
|
+
*/
|
|
26
|
+
export declare const PRINCIPAL: NodeId;
|
|
27
|
+
/**
|
|
28
|
+
* The authority of a state. Absent metadata means `client`, so every 0.5.x graph keeps
|
|
29
|
+
* executing exactly as it did.
|
|
30
|
+
*/
|
|
31
|
+
export declare function stateAuthority(state: StateDef): Authority;
|
|
32
|
+
/** Whether the client may observe this state at all. */
|
|
33
|
+
export declare function isObservable(state: StateDef): boolean;
|
|
34
|
+
export interface AuthorityContext {
|
|
35
|
+
states: Map<NodeId, StateDef>;
|
|
36
|
+
actions: Map<NodeId, ActionDef>;
|
|
37
|
+
constraints: ConstraintDef[];
|
|
38
|
+
transitionConstraints: TransitionConstraintDef[];
|
|
39
|
+
principalEntityId?: NodeId;
|
|
40
|
+
}
|
|
41
|
+
export declare function authorityContext(nodes: readonly AnyNode[], principalEntityId?: NodeId): AuthorityContext;
|
|
42
|
+
/** Every state a set of expressions reads, following derived state transitively. */
|
|
43
|
+
export declare function statesReadBy(expressions: readonly Expression[], context: AuthorityContext): Set<NodeId>;
|
|
44
|
+
/** The states an action writes, following `for-each`, `invoke` and declared native effects. */
|
|
45
|
+
export declare function statesWrittenBy(action: ActionDef, context: AuthorityContext, visited?: Set<NodeId>): Set<NodeId>;
|
|
46
|
+
/** The states an action reads: its guards, its values, its selectors, its authorization. */
|
|
47
|
+
export declare function statesReadByAction(action: ActionDef, context: AuthorityContext, visited?: Set<NodeId>): Set<NodeId>;
|
|
48
|
+
/**
|
|
49
|
+
* Where an action must execute.
|
|
50
|
+
*
|
|
51
|
+
* An action that writes any server-authoritative state is a **server action**: only the
|
|
52
|
+
* authority that owns the state may commit it. This is derived, never declared, so it
|
|
53
|
+
* cannot disagree with what the action actually does.
|
|
54
|
+
*/
|
|
55
|
+
export declare function actionAuthority(action: ActionDef, context: AuthorityContext): Authority;
|
|
56
|
+
/** Actions the client must send to the authority rather than execute itself. */
|
|
57
|
+
export declare function serverActionIds(context: AuthorityContext): NodeId[];
|
|
58
|
+
/**
|
|
59
|
+
* Every state a server action needs in order to execute: what it writes, what it reads,
|
|
60
|
+
* and the derived state either of those depends on.
|
|
61
|
+
*/
|
|
62
|
+
export declare function serverStateClosure(context: AuthorityContext): Set<NodeId>;
|
|
63
|
+
//# sourceMappingURL=authority.d.ts.map
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import { referencedIds } from './derive-edges.js';
|
|
2
|
+
import { actionGuards, isMutationOperation } from './nodes.js';
|
|
3
|
+
import { locationExpressions, locationRootStateId } from './location.js';
|
|
4
|
+
export const AUTHORITIES = ['client', 'server'];
|
|
5
|
+
/**
|
|
6
|
+
* The scope an authorization expression resolves the caller through.
|
|
7
|
+
*
|
|
8
|
+
* It is bound to a record keyed by the field ids of the graph's principal entity, so an
|
|
9
|
+
* authorization rule is written with the same `field`/`ref` vocabulary as everything else:
|
|
10
|
+
*
|
|
11
|
+
* ```ts
|
|
12
|
+
* binary('eq', field(ref(PRINCIPAL), F_USER_ROLE), literal('admin'))
|
|
13
|
+
* ```
|
|
14
|
+
*
|
|
15
|
+
* It is bound only where a server evaluates. A client never sees it.
|
|
16
|
+
*/
|
|
17
|
+
export const PRINCIPAL = 'axiom_principal';
|
|
18
|
+
/**
|
|
19
|
+
* The authority of a state. Absent metadata means `client`, so every 0.5.x graph keeps
|
|
20
|
+
* executing exactly as it did.
|
|
21
|
+
*/
|
|
22
|
+
export function stateAuthority(state) {
|
|
23
|
+
return state.authority === 'server' ? 'server' : 'client';
|
|
24
|
+
}
|
|
25
|
+
/** Whether the client may observe this state at all. */
|
|
26
|
+
export function isObservable(state) {
|
|
27
|
+
return state.serverOnly !== true;
|
|
28
|
+
}
|
|
29
|
+
export function authorityContext(nodes, principalEntityId) {
|
|
30
|
+
const states = new Map();
|
|
31
|
+
const actions = new Map();
|
|
32
|
+
const constraints = [];
|
|
33
|
+
const transitionConstraints = [];
|
|
34
|
+
for (const node of nodes) {
|
|
35
|
+
switch (node.kind) {
|
|
36
|
+
case 'state':
|
|
37
|
+
states.set(node.id, node);
|
|
38
|
+
break;
|
|
39
|
+
case 'action':
|
|
40
|
+
actions.set(node.id, node);
|
|
41
|
+
break;
|
|
42
|
+
case 'constraint':
|
|
43
|
+
constraints.push(node);
|
|
44
|
+
break;
|
|
45
|
+
case 'transition-constraint':
|
|
46
|
+
transitionConstraints.push(node);
|
|
47
|
+
break;
|
|
48
|
+
default:
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return {
|
|
52
|
+
states,
|
|
53
|
+
actions,
|
|
54
|
+
constraints,
|
|
55
|
+
transitionConstraints,
|
|
56
|
+
...(principalEntityId ? { principalEntityId } : {}),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
/** Every state a set of expressions reads, following derived state transitively. */
|
|
60
|
+
export function statesReadBy(expressions, context) {
|
|
61
|
+
const found = new Set();
|
|
62
|
+
const seen = new Set();
|
|
63
|
+
const walk = (expression) => {
|
|
64
|
+
for (const id of referencedIds(expression)) {
|
|
65
|
+
const state = context.states.get(id);
|
|
66
|
+
if (!state || seen.has(id)) {
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
seen.add(id);
|
|
70
|
+
found.add(id);
|
|
71
|
+
if (state.derivation) {
|
|
72
|
+
walk(state.derivation);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
expressions.forEach(walk);
|
|
77
|
+
return found;
|
|
78
|
+
}
|
|
79
|
+
/** Every expression an operation evaluates, including those inside its locations. */
|
|
80
|
+
function operationExpressions(operation) {
|
|
81
|
+
const found = [];
|
|
82
|
+
if (isMutationOperation(operation)) {
|
|
83
|
+
const target = operation.kind === 'remove' ? operation.target : operation.target;
|
|
84
|
+
found.push(...locationExpressions(target));
|
|
85
|
+
if (operation.kind !== 'remove') {
|
|
86
|
+
found.push(operation.value);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
switch (operation.kind) {
|
|
90
|
+
case 'for-each':
|
|
91
|
+
found.push(operation.collection);
|
|
92
|
+
for (const nested of operation.operations ?? []) {
|
|
93
|
+
found.push(...operationExpressions(nested));
|
|
94
|
+
}
|
|
95
|
+
break;
|
|
96
|
+
case 'invoke':
|
|
97
|
+
found.push(...Object.values(operation.arguments ?? {}));
|
|
98
|
+
break;
|
|
99
|
+
case 'navigate':
|
|
100
|
+
found.push(...Object.values(operation.parameters ?? {}));
|
|
101
|
+
break;
|
|
102
|
+
case 'native':
|
|
103
|
+
found.push(...Object.values(operation.inputs ?? {}));
|
|
104
|
+
if (operation.resultTarget) {
|
|
105
|
+
found.push(...locationExpressions(operation.resultTarget));
|
|
106
|
+
}
|
|
107
|
+
break;
|
|
108
|
+
default:
|
|
109
|
+
}
|
|
110
|
+
return found;
|
|
111
|
+
}
|
|
112
|
+
/** The states an action writes, following `for-each`, `invoke` and declared native effects. */
|
|
113
|
+
export function statesWrittenBy(action, context, visited = new Set()) {
|
|
114
|
+
const found = new Set();
|
|
115
|
+
if (visited.has(action.id)) {
|
|
116
|
+
return found;
|
|
117
|
+
}
|
|
118
|
+
visited.add(action.id);
|
|
119
|
+
const walk = (operations) => {
|
|
120
|
+
for (const operation of operations) {
|
|
121
|
+
if (isMutationOperation(operation)) {
|
|
122
|
+
found.add(locationRootStateId(operation.target));
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
switch (operation.kind) {
|
|
126
|
+
case 'for-each':
|
|
127
|
+
walk(operation.operations ?? []);
|
|
128
|
+
break;
|
|
129
|
+
case 'invoke': {
|
|
130
|
+
const target = context.actions.get(operation.actionId);
|
|
131
|
+
if (target) {
|
|
132
|
+
for (const id of statesWrittenBy(target, context, visited)) {
|
|
133
|
+
found.add(id);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
break;
|
|
137
|
+
}
|
|
138
|
+
case 'native':
|
|
139
|
+
if (operation.resultTarget) {
|
|
140
|
+
found.add(locationRootStateId(operation.resultTarget));
|
|
141
|
+
}
|
|
142
|
+
for (const effect of operation.declaredEffects ?? []) {
|
|
143
|
+
if (effect.kind === 'writes-state') {
|
|
144
|
+
found.add(effect.stateId);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
break;
|
|
148
|
+
default:
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
};
|
|
152
|
+
walk(action.operations ?? []);
|
|
153
|
+
return found;
|
|
154
|
+
}
|
|
155
|
+
/** The states an action reads: its guards, its values, its selectors, its authorization. */
|
|
156
|
+
export function statesReadByAction(action, context, visited = new Set()) {
|
|
157
|
+
if (visited.has(action.id)) {
|
|
158
|
+
return new Set();
|
|
159
|
+
}
|
|
160
|
+
visited.add(action.id);
|
|
161
|
+
const expressions = [
|
|
162
|
+
...actionGuards(action).map((guard) => guard.condition),
|
|
163
|
+
...(action.postconditions ?? []),
|
|
164
|
+
...(action.authorization ? [action.authorization] : []),
|
|
165
|
+
];
|
|
166
|
+
for (const operation of action.operations ?? []) {
|
|
167
|
+
expressions.push(...operationExpressions(operation));
|
|
168
|
+
}
|
|
169
|
+
const found = statesReadBy(expressions, context);
|
|
170
|
+
for (const operation of action.operations ?? []) {
|
|
171
|
+
if (operation.kind === 'invoke') {
|
|
172
|
+
const target = context.actions.get(operation.actionId);
|
|
173
|
+
if (target) {
|
|
174
|
+
for (const id of statesReadByAction(target, context, visited)) {
|
|
175
|
+
found.add(id);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return found;
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Where an action must execute.
|
|
184
|
+
*
|
|
185
|
+
* An action that writes any server-authoritative state is a **server action**: only the
|
|
186
|
+
* authority that owns the state may commit it. This is derived, never declared, so it
|
|
187
|
+
* cannot disagree with what the action actually does.
|
|
188
|
+
*/
|
|
189
|
+
export function actionAuthority(action, context) {
|
|
190
|
+
for (const stateId of statesWrittenBy(action, context)) {
|
|
191
|
+
const state = context.states.get(stateId);
|
|
192
|
+
if (state && stateAuthority(state) === 'server') {
|
|
193
|
+
return 'server';
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
return 'client';
|
|
197
|
+
}
|
|
198
|
+
/** Actions the client must send to the authority rather than execute itself. */
|
|
199
|
+
export function serverActionIds(context) {
|
|
200
|
+
return [...context.actions.values()]
|
|
201
|
+
.filter((action) => actionAuthority(action, context) === 'server')
|
|
202
|
+
.map((action) => action.id);
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* Every state a server action needs in order to execute: what it writes, what it reads,
|
|
206
|
+
* and the derived state either of those depends on.
|
|
207
|
+
*/
|
|
208
|
+
export function serverStateClosure(context) {
|
|
209
|
+
const needed = new Set();
|
|
210
|
+
for (const state of context.states.values()) {
|
|
211
|
+
if (stateAuthority(state) === 'server') {
|
|
212
|
+
needed.add(state.id);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
for (const action of context.actions.values()) {
|
|
216
|
+
if (actionAuthority(action, context) !== 'server') {
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
for (const id of statesWrittenBy(action, context)) {
|
|
220
|
+
needed.add(id);
|
|
221
|
+
}
|
|
222
|
+
for (const id of statesReadByAction(action, context)) {
|
|
223
|
+
needed.add(id);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
// Constraints and transition rules that govern what the server commits are evaluated
|
|
227
|
+
// there, so whatever they read has to be present too.
|
|
228
|
+
const ruleExpressions = [
|
|
229
|
+
...context.constraints.map((constraint) => constraint.expression),
|
|
230
|
+
...context.transitionConstraints.map((constraint) => constraint.expression),
|
|
231
|
+
];
|
|
232
|
+
for (const id of statesReadBy(ruleExpressions, context)) {
|
|
233
|
+
needed.add(id);
|
|
234
|
+
}
|
|
235
|
+
// Derived state is only meaningful if what it derives from is present as well.
|
|
236
|
+
let growing = true;
|
|
237
|
+
while (growing) {
|
|
238
|
+
growing = false;
|
|
239
|
+
for (const id of [...needed]) {
|
|
240
|
+
const state = context.states.get(id);
|
|
241
|
+
if (!state?.derivation) {
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
for (const dependency of statesReadBy([state.derivation], context)) {
|
|
245
|
+
if (!needed.has(dependency)) {
|
|
246
|
+
needed.add(dependency);
|
|
247
|
+
growing = true;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
return needed;
|
|
253
|
+
}
|
package/dist/diagnostics.d.ts
CHANGED
|
@@ -65,5 +65,12 @@ export declare const VALIDATION_CODES: {
|
|
|
65
65
|
readonly formInputMissingLabel: "FORM_INPUT_MISSING_LABEL";
|
|
66
66
|
readonly invalidHeadingStructure: "INVALID_HEADING_STRUCTURE";
|
|
67
67
|
readonly opaquePresentation: "OPAQUE_PRESENTATION";
|
|
68
|
+
readonly clientWriteToServerState: "CLIENT_WRITE_TO_SERVER_STATE";
|
|
69
|
+
readonly serverDependsOnClientState: "SERVER_DEPENDS_ON_CLIENT_STATE";
|
|
70
|
+
readonly authorizationWithoutPrincipal: "AUTHORIZATION_WITHOUT_PRINCIPAL";
|
|
71
|
+
readonly principalReferenceOnClient: "PRINCIPAL_REFERENCE_ON_CLIENT";
|
|
72
|
+
readonly serverOnlyStateObserved: "SERVER_ONLY_STATE_OBSERVED";
|
|
73
|
+
readonly invalidPrincipalEntity: "INVALID_PRINCIPAL_ENTITY";
|
|
74
|
+
readonly missingActionArgument: "MISSING_ACTION_ARGUMENT";
|
|
68
75
|
};
|
|
69
76
|
//# sourceMappingURL=diagnostics.d.ts.map
|
package/dist/diagnostics.js
CHANGED
|
@@ -50,4 +50,13 @@ export const VALIDATION_CODES = {
|
|
|
50
50
|
formInputMissingLabel: 'FORM_INPUT_MISSING_LABEL',
|
|
51
51
|
invalidHeadingStructure: 'INVALID_HEADING_STRUCTURE',
|
|
52
52
|
opaquePresentation: 'OPAQUE_PRESENTATION',
|
|
53
|
+
// Authority. A graph that cannot execute safely across the trust boundary is rejected
|
|
54
|
+
// rather than left to fail at run time.
|
|
55
|
+
clientWriteToServerState: 'CLIENT_WRITE_TO_SERVER_STATE',
|
|
56
|
+
serverDependsOnClientState: 'SERVER_DEPENDS_ON_CLIENT_STATE',
|
|
57
|
+
authorizationWithoutPrincipal: 'AUTHORIZATION_WITHOUT_PRINCIPAL',
|
|
58
|
+
principalReferenceOnClient: 'PRINCIPAL_REFERENCE_ON_CLIENT',
|
|
59
|
+
serverOnlyStateObserved: 'SERVER_ONLY_STATE_OBSERVED',
|
|
60
|
+
invalidPrincipalEntity: 'INVALID_PRINCIPAL_ENTITY',
|
|
61
|
+
missingActionArgument: 'MISSING_ACTION_ARGUMENT',
|
|
53
62
|
};
|
package/dist/graph.d.ts
CHANGED
|
@@ -39,6 +39,12 @@ export declare class ApplicationGraph {
|
|
|
39
39
|
get theme(): Theme;
|
|
40
40
|
/** Exactly what the application declared, before defaults were filled in. */
|
|
41
41
|
get declaredTheme(): ThemeInput | undefined;
|
|
42
|
+
/**
|
|
43
|
+
* The entity an authorization expression reads the caller through, bound to `PRINCIPAL`
|
|
44
|
+
* when the authority evaluates the rule.
|
|
45
|
+
*/
|
|
46
|
+
get principalEntityId(): NodeId | undefined;
|
|
47
|
+
setPrincipalEntity(entityId: NodeId | undefined): void;
|
|
42
48
|
setTheme(theme: ThemeInput | undefined): void;
|
|
43
49
|
addNode<T extends AnyNode>(node: NodeInput<T>): NodeId;
|
|
44
50
|
getNode<T extends AnyNode = AnyNode>(id: NodeId): T | undefined;
|
package/dist/graph.js
CHANGED
|
@@ -23,7 +23,7 @@ export class ApplicationGraph {
|
|
|
23
23
|
/** Bumped by every change, so the derived edge index can never serve stale data. */
|
|
24
24
|
revision = 0;
|
|
25
25
|
semanticIndex;
|
|
26
|
-
constructor(id, name, version = '0.
|
|
26
|
+
constructor(id, name, version = '0.6.1') {
|
|
27
27
|
this.data = { id, name, version, nodes: {}, edges: {} };
|
|
28
28
|
}
|
|
29
29
|
get id() {
|
|
@@ -46,6 +46,22 @@ export class ApplicationGraph {
|
|
|
46
46
|
get declaredTheme() {
|
|
47
47
|
return this.data.theme ? structuredClone(this.data.theme) : undefined;
|
|
48
48
|
}
|
|
49
|
+
/**
|
|
50
|
+
* The entity an authorization expression reads the caller through, bound to `PRINCIPAL`
|
|
51
|
+
* when the authority evaluates the rule.
|
|
52
|
+
*/
|
|
53
|
+
get principalEntityId() {
|
|
54
|
+
return this.data.principalEntityId;
|
|
55
|
+
}
|
|
56
|
+
setPrincipalEntity(entityId) {
|
|
57
|
+
if (entityId === undefined) {
|
|
58
|
+
delete this.data.principalEntityId;
|
|
59
|
+
}
|
|
60
|
+
else {
|
|
61
|
+
this.data.principalEntityId = entityId;
|
|
62
|
+
}
|
|
63
|
+
this.revision += 1;
|
|
64
|
+
}
|
|
49
65
|
setTheme(theme) {
|
|
50
66
|
if (theme === undefined) {
|
|
51
67
|
delete this.data.theme;
|
package/dist/index.d.ts
CHANGED
|
@@ -15,6 +15,10 @@ export * from './validate-location.js';
|
|
|
15
15
|
export * from './resolve-presentation.js';
|
|
16
16
|
export * from './validate.js';
|
|
17
17
|
export * from './validate-presentation.js';
|
|
18
|
+
export * from './validate-authority.js';
|
|
19
|
+
export * from './validate-value.js';
|
|
18
20
|
export * from './derive-edges.js';
|
|
21
|
+
export * from './authority.js';
|
|
19
22
|
export * from './ir.js';
|
|
23
|
+
export * from './server-ir.js';
|
|
20
24
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.js
CHANGED
|
@@ -15,5 +15,9 @@ export * from './validate-location.js';
|
|
|
15
15
|
export * from './resolve-presentation.js';
|
|
16
16
|
export * from './validate.js';
|
|
17
17
|
export * from './validate-presentation.js';
|
|
18
|
+
export * from './validate-authority.js';
|
|
19
|
+
export * from './validate-value.js';
|
|
18
20
|
export * from './derive-edges.js';
|
|
21
|
+
export * from './authority.js';
|
|
19
22
|
export * from './ir.js';
|
|
23
|
+
export * from './server-ir.js';
|
package/dist/ir.d.ts
CHANGED
|
@@ -4,6 +4,7 @@ import type { FieldIndexEntry } from './graph.js';
|
|
|
4
4
|
import type { UINode } from './ui.js';
|
|
5
5
|
import type { AnyNode } from './types.js';
|
|
6
6
|
import type { TypeRef } from './type-ref.js';
|
|
7
|
+
import type { Authority } from './authority.js';
|
|
7
8
|
import type { ResolvedPresentation } from './presentation.js';
|
|
8
9
|
import type { Theme } from './theme.js';
|
|
9
10
|
export interface RouteSegment {
|
|
@@ -64,6 +65,17 @@ export interface ApplicationIR {
|
|
|
64
65
|
* renderer falls back to the iteration index.
|
|
65
66
|
*/
|
|
66
67
|
repeatIdentityFields: Record<NodeId, FieldId>;
|
|
68
|
+
/**
|
|
69
|
+
* The authority of each state. A client runtime refuses to write a state whose authority
|
|
70
|
+
* is `server`, whatever path attempts it.
|
|
71
|
+
*/
|
|
72
|
+
authority: Record<NodeId, Authority>;
|
|
73
|
+
/**
|
|
74
|
+
* Actions the client must send to the authority rather than execute itself. Their
|
|
75
|
+
* operations, guards and authorization are **absent** from this IR: a client is never
|
|
76
|
+
* given the rules it is not trusted to apply.
|
|
77
|
+
*/
|
|
78
|
+
remoteActionIds: NodeId[];
|
|
67
79
|
/** The application's visual identity, completed against the default theme. */
|
|
68
80
|
theme: Theme;
|
|
69
81
|
/**
|
package/dist/nodes.d.ts
CHANGED
|
@@ -3,6 +3,7 @@ import type { CollectionItemLocation, Location } from './location.js';
|
|
|
3
3
|
import type { EdgeId, FieldId, NodeId } from './ids.js';
|
|
4
4
|
import type { TypeRef } from './type-ref.js';
|
|
5
5
|
import type { ConfirmationPresentation } from './presentation.js';
|
|
6
|
+
import type { Authority } from './authority.js';
|
|
6
7
|
export interface NodeBase {
|
|
7
8
|
id: NodeId;
|
|
8
9
|
name?: string;
|
|
@@ -102,6 +103,21 @@ export interface StateDef extends NodeBase {
|
|
|
102
103
|
* is governed exactly as before.
|
|
103
104
|
*/
|
|
104
105
|
ephemeral?: boolean;
|
|
106
|
+
/**
|
|
107
|
+
* Who may commit a canonical mutation to this state. Absent, it is `'client'`, so every
|
|
108
|
+
* 0.5.x graph keeps executing exactly as it did.
|
|
109
|
+
*
|
|
110
|
+
* A client MUST NOT commit a mutation to `'server'` state through any path: an action
|
|
111
|
+
* that writes it executes on the authority, and an input bound into it is a validation
|
|
112
|
+
* error. Authority is separate from `persistence` — one says who decides a value, the
|
|
113
|
+
* other where a decided value survives.
|
|
114
|
+
*/
|
|
115
|
+
authority?: Authority;
|
|
116
|
+
/**
|
|
117
|
+
* Marks server-authoritative state the client may not observe at all. Such a state is
|
|
118
|
+
* excluded from the client IR entirely rather than merely being unwritable.
|
|
119
|
+
*/
|
|
120
|
+
serverOnly?: boolean;
|
|
105
121
|
persistence?: StatePersistence;
|
|
106
122
|
}
|
|
107
123
|
export interface ActionParameter {
|
|
@@ -144,6 +160,15 @@ export interface ActionDef extends NodeBase {
|
|
|
144
160
|
destructive?: boolean;
|
|
145
161
|
requiresConfirmation?: boolean;
|
|
146
162
|
confirmationMessage?: string;
|
|
163
|
+
/**
|
|
164
|
+
* Whether the caller may invoke this action at all, evaluated **on the authority** with
|
|
165
|
+
* the caller bound to `PRINCIPAL`.
|
|
166
|
+
*
|
|
167
|
+
* It is checked before any guard and before any transaction opens, and it is stripped
|
|
168
|
+
* from the client IR — a client never learns the rule and can never satisfy it by
|
|
169
|
+
* claiming to. `requiresConfirmation` is UX and is not an authorization mechanism.
|
|
170
|
+
*/
|
|
171
|
+
authorization?: Expression;
|
|
147
172
|
/** What the confirmation says, when a plain message is not enough. */
|
|
148
173
|
confirmation?: ConfirmationPresentation;
|
|
149
174
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { isUINode, uiChildIds } from './ui.js';
|
|
1
|
+
import { formSubmitActionId, isUINode, uiChildIds } from './ui.js';
|
|
2
2
|
import { TEXT_ROLE_HEADING_LEVELS, normalizeDensity, normalizeLayout, normalizePadding, normalizeRole, } from './presentation.js';
|
|
3
3
|
import { DEFAULT_THEME } from './theme.js';
|
|
4
4
|
/**
|
|
@@ -249,7 +249,10 @@ function semanticLayer(node, index) {
|
|
|
249
249
|
const layer = { origin: 'inferred' };
|
|
250
250
|
const formId = index.formOf.get(node.id);
|
|
251
251
|
const form = formId ? index.nodes.get(formId) : undefined;
|
|
252
|
-
|
|
252
|
+
const submitActionId = form && form.kind === 'form'
|
|
253
|
+
? formSubmitActionId(form, (id) => index.nodes.get(id))
|
|
254
|
+
: undefined;
|
|
255
|
+
if (submitActionId !== undefined && submitActionId === node.actionId) {
|
|
253
256
|
layer.role = 'primary';
|
|
254
257
|
layer.emphasis = 'strong';
|
|
255
258
|
layer.uxRole = 'primary-action';
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { FieldId, NodeId } from './ids.js';
|
|
2
|
+
import type { ActionDef, ConstraintDef, EntityDef, StateDef, TransitionConstraintDef } from './nodes.js';
|
|
3
|
+
import type { FieldIndexEntry } from './graph.js';
|
|
4
|
+
/**
|
|
5
|
+
* The contract version a Server IR conforms to. A runtime that does not recognize the
|
|
6
|
+
* value MUST refuse the IR rather than interpret it partially.
|
|
7
|
+
*/
|
|
8
|
+
export declare const SERVER_IR_CONTRACT = "axiom.server.v1";
|
|
9
|
+
export type ServerIRContract = typeof SERVER_IR_CONTRACT;
|
|
10
|
+
/**
|
|
11
|
+
* The normalized form an authority executes: everything required to decide a mutation, and
|
|
12
|
+
* nothing else.
|
|
13
|
+
*
|
|
14
|
+
* It is deliberately **not** the client IR. It carries no UI nodes, no presentation, no
|
|
15
|
+
* theme and no routes, because none of that decides anything. It does carry the rules a
|
|
16
|
+
* client must never be trusted with — authorization expressions, guards, constraints — and
|
|
17
|
+
* the state the client may not observe.
|
|
18
|
+
*
|
|
19
|
+
* It is plain JSON: serializable, deterministic, free of closures and of anything specific
|
|
20
|
+
* to a language or a host. A conforming runtime in another language executing the same
|
|
21
|
+
* serialized IR must reach the same semantic result, which is what the conformance suite
|
|
22
|
+
* exists to check.
|
|
23
|
+
*/
|
|
24
|
+
export interface ServerIR {
|
|
25
|
+
contract: ServerIRContract;
|
|
26
|
+
id: string;
|
|
27
|
+
name: string;
|
|
28
|
+
version: string;
|
|
29
|
+
entities: EntityDef[];
|
|
30
|
+
/** Field lookup, pre-indexed so a runtime re-derives nothing. */
|
|
31
|
+
fields: Record<FieldId, FieldIndexEntry>;
|
|
32
|
+
/** Authoritative state, plus whatever authoritative execution reads. */
|
|
33
|
+
states: StateDef[];
|
|
34
|
+
/** Only the actions this authority executes, fully specified. */
|
|
35
|
+
actions: Record<NodeId, ActionDef>;
|
|
36
|
+
constraints: ConstraintDef[];
|
|
37
|
+
transitionConstraints: TransitionConstraintDef[];
|
|
38
|
+
/**
|
|
39
|
+
* The entity whose fields an authorization expression reads through `PRINCIPAL`. Absent
|
|
40
|
+
* when the application declares no authorization.
|
|
41
|
+
*/
|
|
42
|
+
principalEntityId?: NodeId;
|
|
43
|
+
/** The states a client is permitted to observe, in declaration order. */
|
|
44
|
+
observableStateIds: NodeId[];
|
|
45
|
+
}
|
|
46
|
+
//# sourceMappingURL=server-ir.d.ts.map
|
package/dist/types.d.ts
CHANGED
|
@@ -20,6 +20,11 @@ export interface ApplicationGraphData {
|
|
|
20
20
|
edges: Record<string, GraphEdge>;
|
|
21
21
|
/** Visual identity. Presentation only: a theme can never change behaviour. */
|
|
22
22
|
theme?: ThemeInput;
|
|
23
|
+
/**
|
|
24
|
+
* The entity whose fields an authorization expression reads through `PRINCIPAL`. It
|
|
25
|
+
* describes a caller, and is never stored as application state.
|
|
26
|
+
*/
|
|
27
|
+
principalEntityId?: NodeId;
|
|
23
28
|
metadata?: Record<string, unknown>;
|
|
24
29
|
}
|
|
25
30
|
//# sourceMappingURL=types.d.ts.map
|
package/dist/ui.d.ts
CHANGED
|
@@ -198,6 +198,18 @@ export declare function isUINode(node: {
|
|
|
198
198
|
* content that never appears at the same time.
|
|
199
199
|
*/
|
|
200
200
|
export declare function primaryChildIds(node: UINode): NodeId[];
|
|
201
|
+
/**
|
|
202
|
+
* The action a form submits, however it was declared.
|
|
203
|
+
*
|
|
204
|
+
* A form may name the action directly (`submitActionId`) or name a declared button that
|
|
205
|
+
* invokes it (`submitButtonId`). Every layer — execution, validation, presentation
|
|
206
|
+
* inference, `AgentAPI` — must resolve it the same way, or a form's primary action means
|
|
207
|
+
* one thing to the renderer and another to an agent.
|
|
208
|
+
*/
|
|
209
|
+
export declare function formSubmitActionId(form: FormNode, lookup: (id: NodeId) => {
|
|
210
|
+
kind: string;
|
|
211
|
+
actionId?: NodeId;
|
|
212
|
+
} | undefined): NodeId | undefined;
|
|
201
213
|
/** Child ids declared by a UI node, in render order. */
|
|
202
214
|
export declare function uiChildIds(node: UINode): NodeId[];
|
|
203
215
|
//# sourceMappingURL=ui.d.ts.map
|
package/dist/ui.js
CHANGED
|
@@ -32,6 +32,24 @@ export function primaryChildIds(node) {
|
|
|
32
32
|
return uiChildIds(node);
|
|
33
33
|
}
|
|
34
34
|
}
|
|
35
|
+
/**
|
|
36
|
+
* The action a form submits, however it was declared.
|
|
37
|
+
*
|
|
38
|
+
* A form may name the action directly (`submitActionId`) or name a declared button that
|
|
39
|
+
* invokes it (`submitButtonId`). Every layer — execution, validation, presentation
|
|
40
|
+
* inference, `AgentAPI` — must resolve it the same way, or a form's primary action means
|
|
41
|
+
* one thing to the renderer and another to an agent.
|
|
42
|
+
*/
|
|
43
|
+
export function formSubmitActionId(form, lookup) {
|
|
44
|
+
if (form.submitActionId) {
|
|
45
|
+
return form.submitActionId;
|
|
46
|
+
}
|
|
47
|
+
if (!form.submitButtonId) {
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
const button = lookup(form.submitButtonId);
|
|
51
|
+
return button?.kind === 'button' ? button.actionId : undefined;
|
|
52
|
+
}
|
|
35
53
|
/** Child ids declared by a UI node, in render order. */
|
|
36
54
|
export function uiChildIds(node) {
|
|
37
55
|
switch (node.kind) {
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { ValidationIssue } from './diagnostics.js';
|
|
2
|
+
import type { NodeId } from './ids.js';
|
|
3
|
+
import type { AnyNode } from './types.js';
|
|
4
|
+
/**
|
|
5
|
+
* Validation of the authority boundary.
|
|
6
|
+
*
|
|
7
|
+
* These are errors, not advice. A graph in which a client could commit server-authoritative
|
|
8
|
+
* state, or in which an authority would have to read state it does not own, cannot execute
|
|
9
|
+
* correctly — and the point of the boundary is that its correctness does not depend on an
|
|
10
|
+
* author remembering where to bind an input.
|
|
11
|
+
*/
|
|
12
|
+
export declare function validateAuthority(nodes: readonly AnyNode[], principalEntityId: NodeId | undefined): {
|
|
13
|
+
errors: ValidationIssue[];
|
|
14
|
+
warnings: ValidationIssue[];
|
|
15
|
+
};
|
|
16
|
+
//# sourceMappingURL=validate-authority.d.ts.map
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { PRINCIPAL, actionAuthority, authorityContext, stateAuthority, statesReadByAction } from './authority.js';
|
|
2
|
+
import { referencedIds } from './derive-edges.js';
|
|
3
|
+
import { VALIDATION_CODES } from './diagnostics.js';
|
|
4
|
+
import { locationRootStateId } from './location.js';
|
|
5
|
+
import { isUINode } from './ui.js';
|
|
6
|
+
/**
|
|
7
|
+
* Validation of the authority boundary.
|
|
8
|
+
*
|
|
9
|
+
* These are errors, not advice. A graph in which a client could commit server-authoritative
|
|
10
|
+
* state, or in which an authority would have to read state it does not own, cannot execute
|
|
11
|
+
* correctly — and the point of the boundary is that its correctness does not depend on an
|
|
12
|
+
* author remembering where to bind an input.
|
|
13
|
+
*/
|
|
14
|
+
export function validateAuthority(nodes, principalEntityId) {
|
|
15
|
+
const errors = [];
|
|
16
|
+
const warnings = [];
|
|
17
|
+
const context = authorityContext(nodes, principalEntityId);
|
|
18
|
+
const entities = new Set(nodes.filter((node) => node.kind === 'entity').map((node) => node.id));
|
|
19
|
+
const hasServerState = [...context.states.values()].some((state) => stateAuthority(state) === 'server');
|
|
20
|
+
const authorizedActions = [...context.actions.values()].filter((action) => action.authorization);
|
|
21
|
+
if (principalEntityId !== undefined && !entities.has(principalEntityId)) {
|
|
22
|
+
errors.push({
|
|
23
|
+
code: VALIDATION_CODES.invalidPrincipalEntity,
|
|
24
|
+
message: `The principal entity ${principalEntityId} is not an entity`,
|
|
25
|
+
details: { principalEntityId },
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
for (const action of authorizedActions) {
|
|
29
|
+
if (principalEntityId === undefined) {
|
|
30
|
+
errors.push({
|
|
31
|
+
code: VALIDATION_CODES.authorizationWithoutPrincipal,
|
|
32
|
+
message: `${action.name ?? action.id} declares authorization, but the graph declares no principal entity to read the caller through`,
|
|
33
|
+
nodeId: action.id,
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
// A client write can never reach server-authoritative state. An input binding is the one
|
|
38
|
+
// path that would otherwise slip through, because it looks like presentation.
|
|
39
|
+
for (const node of nodes) {
|
|
40
|
+
if (!isUINode(node) || node.kind !== 'input') {
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
const rootStateId = locationRootStateId(node.binding.location);
|
|
44
|
+
const state = context.states.get(rootStateId);
|
|
45
|
+
if (state && stateAuthority(state) === 'server') {
|
|
46
|
+
errors.push({
|
|
47
|
+
code: VALIDATION_CODES.clientWriteToServerState,
|
|
48
|
+
message: `Input ${node.name ?? node.id} writes ${rootStateId}, which is server-authoritative; bind it to a draft and commit through an action instead`,
|
|
49
|
+
nodeId: node.id,
|
|
50
|
+
details: { stateId: rootStateId },
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
if (state?.serverOnly) {
|
|
54
|
+
errors.push({
|
|
55
|
+
code: VALIDATION_CODES.serverOnlyStateObserved,
|
|
56
|
+
message: `Input ${node.name ?? node.id} reads ${rootStateId}, which the client may not observe`,
|
|
57
|
+
nodeId: node.id,
|
|
58
|
+
details: { stateId: rootStateId },
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
// A server action cannot read state the authority does not own. A draft on the client is
|
|
63
|
+
// exactly the case: it must arrive as an argument, not be read across the boundary.
|
|
64
|
+
for (const action of context.actions.values()) {
|
|
65
|
+
if (actionAuthority(action, context) !== 'server') {
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
for (const stateId of statesReadByAction(action, context)) {
|
|
69
|
+
const state = context.states.get(stateId);
|
|
70
|
+
if (state && stateAuthority(state) === 'client') {
|
|
71
|
+
errors.push({
|
|
72
|
+
code: VALIDATION_CODES.serverDependsOnClientState,
|
|
73
|
+
message: `${action.name ?? action.id} executes on the authority but reads ${stateId}, which is client-authoritative; pass the value as an action parameter instead`,
|
|
74
|
+
nodeId: action.id,
|
|
75
|
+
details: { stateId, authority: 'server' },
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
// Server-authoritative state may not derive from client state either: the authority
|
|
81
|
+
// would have nothing to compute it from.
|
|
82
|
+
for (const state of context.states.values()) {
|
|
83
|
+
if (stateAuthority(state) !== 'server' || !state.derivation) {
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
for (const id of referencedIds(state.derivation)) {
|
|
87
|
+
const source = context.states.get(id);
|
|
88
|
+
if (source && stateAuthority(source) === 'client') {
|
|
89
|
+
errors.push({
|
|
90
|
+
code: VALIDATION_CODES.serverDependsOnClientState,
|
|
91
|
+
message: `${state.name ?? state.id} is server-authoritative but derives from ${id}, which is client-authoritative`,
|
|
92
|
+
nodeId: state.id,
|
|
93
|
+
details: { stateId: id },
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
// The client may not observe server-only state, however indirectly.
|
|
99
|
+
for (const state of context.states.values()) {
|
|
100
|
+
if (!state.derivation || stateAuthority(state) === 'server') {
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
for (const id of referencedIds(state.derivation)) {
|
|
104
|
+
if (context.states.get(id)?.serverOnly) {
|
|
105
|
+
errors.push({
|
|
106
|
+
code: VALIDATION_CODES.serverOnlyStateObserved,
|
|
107
|
+
message: `${state.name ?? state.id} is observable by the client but derives from ${id}, which is server-only`,
|
|
108
|
+
nodeId: state.id,
|
|
109
|
+
details: { stateId: id },
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
for (const node of nodes) {
|
|
115
|
+
if (!isUINode(node)) {
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
const expressions = [
|
|
119
|
+
...(node.visibleWhen ? [node.visibleWhen] : []),
|
|
120
|
+
...(node.kind === 'text' && typeof node.value !== 'string' ? [node.value] : []),
|
|
121
|
+
...(node.kind === 'repeat' ? [node.source] : []),
|
|
122
|
+
...(node.kind === 'field-display' ? [node.source] : []),
|
|
123
|
+
...(node.kind === 'form' ? [node.target] : []),
|
|
124
|
+
...(node.kind === 'conditional' ? [node.condition] : []),
|
|
125
|
+
];
|
|
126
|
+
for (const expression of expressions) {
|
|
127
|
+
for (const id of referencedIds(expression)) {
|
|
128
|
+
if (context.states.get(id)?.serverOnly) {
|
|
129
|
+
errors.push({
|
|
130
|
+
code: VALIDATION_CODES.serverOnlyStateObserved,
|
|
131
|
+
message: `${node.name ?? node.id} reads ${id}, which the client may not observe`,
|
|
132
|
+
nodeId: node.id,
|
|
133
|
+
details: { stateId: id },
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
// The principal exists only where an authority evaluates. Reading it anywhere a client
|
|
140
|
+
// evaluates would be a rule the client could simply not apply.
|
|
141
|
+
reportPrincipalOnClient(nodes, context, errors);
|
|
142
|
+
if (hasServerState && authorizedActions.length === 0) {
|
|
143
|
+
warnings.push({
|
|
144
|
+
code: VALIDATION_CODES.authorizationWithoutPrincipal,
|
|
145
|
+
message: 'This application has server-authoritative state but no action declares authorization, so every caller may invoke every action',
|
|
146
|
+
details: { serverActions: [...context.actions.values()].filter((a) => actionAuthority(a, context) === 'server').length },
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
return { errors, warnings };
|
|
150
|
+
}
|
|
151
|
+
/** `PRINCIPAL` is bound only by an authority; anywhere else it cannot resolve. */
|
|
152
|
+
function reportPrincipalOnClient(nodes, context, errors) {
|
|
153
|
+
const report = (nodeId, where) => {
|
|
154
|
+
errors.push({
|
|
155
|
+
code: VALIDATION_CODES.principalReferenceOnClient,
|
|
156
|
+
message: `${where} reads the caller through PRINCIPAL, which only an authority binds`,
|
|
157
|
+
nodeId,
|
|
158
|
+
details: { scope: PRINCIPAL },
|
|
159
|
+
});
|
|
160
|
+
};
|
|
161
|
+
for (const state of context.states.values()) {
|
|
162
|
+
if (state.derivation && referencedIds(state.derivation).includes(PRINCIPAL)) {
|
|
163
|
+
report(state.id, `Derived state ${state.name ?? state.id}`);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
for (const node of nodes) {
|
|
167
|
+
if (!isUINode(node)) {
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
const expressions = [
|
|
171
|
+
...(node.visibleWhen ? [node.visibleWhen] : []),
|
|
172
|
+
...(node.kind === 'text' && typeof node.value !== 'string' ? [node.value] : []),
|
|
173
|
+
...(node.kind === 'repeat' ? [node.source] : []),
|
|
174
|
+
...(node.kind === 'conditional' ? [node.condition] : []),
|
|
175
|
+
];
|
|
176
|
+
if (expressions.some((expression) => referencedIds(expression).includes(PRINCIPAL))) {
|
|
177
|
+
report(node.id, `${node.kind} ${node.name ?? node.id}`);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
// An action that only the client executes cannot check an authorization rule either.
|
|
181
|
+
for (const action of context.actions.values()) {
|
|
182
|
+
if (!action.authorization || actionAuthority(action, context) === 'server') {
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
errors.push({
|
|
186
|
+
code: VALIDATION_CODES.principalReferenceOnClient,
|
|
187
|
+
message: `${action.name ?? action.id} declares authorization but writes no server-authoritative state, so nothing authoritative would ever evaluate it`,
|
|
188
|
+
nodeId: action.id,
|
|
189
|
+
details: { scope: PRINCIPAL },
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
}
|
|
@@ -2,7 +2,7 @@ import { VALIDATION_CODES } from './diagnostics.js';
|
|
|
2
2
|
import { ACTION_UX_ROLES, ALIGNMENTS, BOUNDED_SIZES, CONTROL_VARIANTS, DENSITIES, DEVICE_CLASSES, EMPHASIS_LEVELS, ICON_NAMES, JUSTIFICATIONS, LAYOUT_KINDS, PRESENTATION_ROLES, SIZING_VALUES, SPACING_TOKENS, SURFACE_ROLES, TEXT_ROLES, TREATMENTS, HEADING_LEVELS, UX_ROLES, VALUE_FORMAT_KINDS, } from './presentation.js';
|
|
3
3
|
import { isDestructiveAction } from './resolve-presentation.js';
|
|
4
4
|
import { APPEARANCES, RADIUS_TOKENS, SEMANTIC_COLOR_ROLES } from './theme.js';
|
|
5
|
-
import { isUINode, primaryChildIds, uiChildIds } from './ui.js';
|
|
5
|
+
import { formSubmitActionId, isUINode, primaryChildIds, uiChildIds } from './ui.js';
|
|
6
6
|
/** More than this many controls side by side stops being a group and becomes a wall. */
|
|
7
7
|
const HORIZONTAL_ACTION_LIMIT = 5;
|
|
8
8
|
/** Below this a non-wrapping row is not yet a responsive problem. */
|
|
@@ -666,8 +666,11 @@ function checkPrimaryActions(bag, index, resolved) {
|
|
|
666
666
|
continue;
|
|
667
667
|
}
|
|
668
668
|
const primary = new Set();
|
|
669
|
-
if (isForm && node.kind === 'form'
|
|
670
|
-
|
|
669
|
+
if (isForm && node.kind === 'form') {
|
|
670
|
+
const submitActionId = formSubmitActionId(node, (id) => index.nodes.get(id));
|
|
671
|
+
if (submitActionId) {
|
|
672
|
+
primary.add(submitActionId);
|
|
673
|
+
}
|
|
671
674
|
}
|
|
672
675
|
for (const child of descendants(index, node.id)) {
|
|
673
676
|
if (child.kind === 'button' && resolved[child.id]?.uxRole === 'primary-action') {
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { ValidationIssue } from './diagnostics.js';
|
|
2
|
+
import type { NodeId } from './ids.js';
|
|
3
|
+
import type { EntityDef } from './nodes.js';
|
|
4
|
+
import type { TypeRef } from './type-ref.js';
|
|
5
|
+
/**
|
|
6
|
+
* Checks a value against a declared `TypeRef`, recursively.
|
|
7
|
+
*
|
|
8
|
+
* One implementation serves two purposes that must not drift apart: seed data checked at
|
|
9
|
+
* authoring time, and **untrusted input checked at the authority boundary**. Network data
|
|
10
|
+
* is untyped, and TypeScript proves nothing about it, so the same walk that catches a
|
|
11
|
+
* mistyped `initialValue` is what rejects a hostile argument.
|
|
12
|
+
*
|
|
13
|
+
* Entity records are keyed by `FieldId`. A record keyed by field *name* is reported as
|
|
14
|
+
* unknown fields plus missing required ones, rather than surfacing later as absent data.
|
|
15
|
+
*/
|
|
16
|
+
export interface ValueCheckOptions {
|
|
17
|
+
/** Where in the value the walk currently is, for diagnostics. */
|
|
18
|
+
path: string;
|
|
19
|
+
getEntity(id: NodeId): EntityDef | undefined;
|
|
20
|
+
/** When true, a missing required field is allowed — a draft is incomplete by definition. */
|
|
21
|
+
allowIncomplete?: boolean;
|
|
22
|
+
}
|
|
23
|
+
export declare function validateValueAgainstType(value: unknown, type: TypeRef, options: ValueCheckOptions): ValidationIssue[];
|
|
24
|
+
//# sourceMappingURL=validate-value.d.ts.map
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { VALIDATION_CODES } from './diagnostics.js';
|
|
2
|
+
function describeType(type) {
|
|
3
|
+
switch (type.kind) {
|
|
4
|
+
case 'primitive':
|
|
5
|
+
return type.primitive;
|
|
6
|
+
case 'entity':
|
|
7
|
+
return `a ${type.entityId} record`;
|
|
8
|
+
case 'collection':
|
|
9
|
+
return `a collection of ${describeType(type.itemType)}`;
|
|
10
|
+
case 'optional':
|
|
11
|
+
return `${describeType(type.valueType)} or nothing`;
|
|
12
|
+
case 'enum':
|
|
13
|
+
return `one of ${type.values.join(', ')}`;
|
|
14
|
+
default:
|
|
15
|
+
return 'an unknown type';
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
function describeActual(value) {
|
|
19
|
+
return value === null ? 'null' : Array.isArray(value) ? 'a collection' : typeof value;
|
|
20
|
+
}
|
|
21
|
+
export function validateValueAgainstType(value, type, options) {
|
|
22
|
+
const problems = [];
|
|
23
|
+
const { path } = options;
|
|
24
|
+
const report = (code, message, extra = {}) => {
|
|
25
|
+
problems.push({ code, message, path, ...extra });
|
|
26
|
+
};
|
|
27
|
+
if (type.kind === 'optional') {
|
|
28
|
+
if (value === null || value === undefined) {
|
|
29
|
+
return problems;
|
|
30
|
+
}
|
|
31
|
+
return validateValueAgainstType(value, type.valueType, options);
|
|
32
|
+
}
|
|
33
|
+
if (value === undefined || value === null) {
|
|
34
|
+
report(VALIDATION_CODES.initialValueTypeMismatch, `${path} is ${value === undefined ? 'missing' : 'null'} but is declared as ${describeType(type)}`, { details: { expected: describeType(type), actual: describeActual(value) } });
|
|
35
|
+
return problems;
|
|
36
|
+
}
|
|
37
|
+
switch (type.kind) {
|
|
38
|
+
case 'primitive': {
|
|
39
|
+
const expected = type.primitive === 'number' ? 'number' : type.primitive === 'boolean' ? 'boolean' : 'string';
|
|
40
|
+
if (typeof value !== expected) {
|
|
41
|
+
report(VALIDATION_CODES.initialValueTypeMismatch, `${path} should be a ${type.primitive} but is ${describeActual(value)}`, { details: { expected: type.primitive, actual: describeActual(value), value } });
|
|
42
|
+
return problems;
|
|
43
|
+
}
|
|
44
|
+
// `NaN` and the infinities are numbers to JavaScript and nothing to a domain model.
|
|
45
|
+
if (type.primitive === 'number' && !Number.isFinite(value)) {
|
|
46
|
+
report(VALIDATION_CODES.initialValueTypeMismatch, `${path} should be a finite number but is ${String(value)}`, { details: { expected: 'a finite number', value: String(value) } });
|
|
47
|
+
}
|
|
48
|
+
return problems;
|
|
49
|
+
}
|
|
50
|
+
case 'enum': {
|
|
51
|
+
if (typeof value !== 'string' || !type.values.includes(value)) {
|
|
52
|
+
report(VALIDATION_CODES.initialValueTypeMismatch, `${path} should be one of ${type.values.join(', ')} but is ${JSON.stringify(value)}`, { details: { expected: type.values, value } });
|
|
53
|
+
}
|
|
54
|
+
return problems;
|
|
55
|
+
}
|
|
56
|
+
case 'collection': {
|
|
57
|
+
if (!Array.isArray(value)) {
|
|
58
|
+
report(VALIDATION_CODES.initialValueTypeMismatch, `${path} should be a collection but is ${describeActual(value)}`, { details: { expected: describeType(type), actual: describeActual(value) } });
|
|
59
|
+
return problems;
|
|
60
|
+
}
|
|
61
|
+
value.forEach((item, index) => {
|
|
62
|
+
problems.push(...validateValueAgainstType(item, type.itemType, { ...options, path: `${path}[${index}]` }));
|
|
63
|
+
});
|
|
64
|
+
return problems;
|
|
65
|
+
}
|
|
66
|
+
case 'entity': {
|
|
67
|
+
const entity = options.getEntity(type.entityId);
|
|
68
|
+
if (!entity) {
|
|
69
|
+
return problems;
|
|
70
|
+
}
|
|
71
|
+
if (typeof value !== 'object' || Array.isArray(value)) {
|
|
72
|
+
report(VALIDATION_CODES.initialValueInvalidEntity, `${path} should be a ${entity.name ?? entity.id} record but is ${describeActual(value)}`, { details: { entityId: entity.id, actual: describeActual(value) } });
|
|
73
|
+
return problems;
|
|
74
|
+
}
|
|
75
|
+
const record = value;
|
|
76
|
+
const declared = new Map(entity.fields.map((field) => [String(field.id), field]));
|
|
77
|
+
for (const key of Object.keys(record)) {
|
|
78
|
+
if (!declared.has(key)) {
|
|
79
|
+
report(VALIDATION_CODES.initialValueUnknownField, `${path}.${key} is not a field of ${entity.name ?? entity.id}. Records are keyed by field id, not by field name.`, { details: { entityId: entity.id, key, expected: [...declared.keys()] } });
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
for (const field of entity.fields) {
|
|
83
|
+
const present = Object.prototype.hasOwnProperty.call(record, String(field.id));
|
|
84
|
+
if (!present) {
|
|
85
|
+
if (field.required && field.valueType.kind !== 'optional' && !options.allowIncomplete) {
|
|
86
|
+
report(VALIDATION_CODES.initialValueMissingRequiredField, `${path} is missing required field ${field.name ?? field.id}`, { fieldId: field.id, details: { entityId: entity.id, fieldId: field.id } });
|
|
87
|
+
}
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
problems.push(...validateValueAgainstType(record[String(field.id)], field.valueType, {
|
|
91
|
+
...options,
|
|
92
|
+
path: `${path}.${field.id}`,
|
|
93
|
+
}));
|
|
94
|
+
}
|
|
95
|
+
return problems;
|
|
96
|
+
}
|
|
97
|
+
default:
|
|
98
|
+
return problems;
|
|
99
|
+
}
|
|
100
|
+
}
|
package/dist/validate.js
CHANGED
|
@@ -9,6 +9,9 @@ import { locationExpressions } from './location.js';
|
|
|
9
9
|
import { validateLocation } from './validate-location.js';
|
|
10
10
|
import { resolvePresentationMap } from './resolve-presentation.js';
|
|
11
11
|
import { validatePresentation } from './validate-presentation.js';
|
|
12
|
+
import { validateAuthority } from './validate-authority.js';
|
|
13
|
+
import { validateValueAgainstType } from './validate-value.js';
|
|
14
|
+
import { PRINCIPAL } from './authority.js';
|
|
12
15
|
/**
|
|
13
16
|
* Verifies referential integrity of an application graph. An invalid graph must never
|
|
14
17
|
* be executed, so every reference — nodes, fields, edges, expressions, UI children —
|
|
@@ -35,7 +38,9 @@ export function validateGraph(graph) {
|
|
|
35
38
|
}
|
|
36
39
|
nodes.set(node.id, node);
|
|
37
40
|
}
|
|
38
|
-
// Only these ids can be resolved by a `ref` expression at runtime.
|
|
41
|
+
// Only these ids can be resolved by a `ref` expression at runtime. The principal is
|
|
42
|
+
// bound by an authority; `validateAuthority` rejects reading it anywhere else.
|
|
43
|
+
context.scopes.add(PRINCIPAL);
|
|
39
44
|
for (const node of nodes.values()) {
|
|
40
45
|
if (node.kind === 'state' || node.kind === 'entity' || node.kind === 'repeat') {
|
|
41
46
|
context.scopes.add(node.id);
|
|
@@ -83,6 +88,11 @@ export function validateGraph(graph) {
|
|
|
83
88
|
const presentation = validatePresentation(allNodes, graph.declaredTheme, resolvePresentationMap(allNodes, graph.theme));
|
|
84
89
|
errors.push(...presentation.errors);
|
|
85
90
|
warnings.push(...presentation.warnings);
|
|
91
|
+
// The authority boundary. A graph that could let a client commit server state, or that
|
|
92
|
+
// would make an authority read state it does not own, cannot execute safely.
|
|
93
|
+
const authority = validateAuthority(allNodes, graph.principalEntityId);
|
|
94
|
+
errors.push(...authority.errors);
|
|
95
|
+
warnings.push(...authority.warnings);
|
|
86
96
|
return { valid: errors.length === 0, errors, warnings };
|
|
87
97
|
}
|
|
88
98
|
function validateNode(node, context) {
|
|
@@ -160,78 +170,19 @@ function validateState(state, context) {
|
|
|
160
170
|
* than one that surfaces later as a mysteriously empty UI.
|
|
161
171
|
*/
|
|
162
172
|
function validateValue(value, type, path, state, context) {
|
|
163
|
-
const
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
}
|
|
171
|
-
validateValue(value, type.valueType, path, state, context);
|
|
172
|
-
return;
|
|
173
|
-
}
|
|
174
|
-
if (value === undefined || value === null) {
|
|
175
|
-
report(VALIDATION_CODES.initialValueTypeMismatch, `${path} is ${value === undefined ? 'missing' : 'null'} but is declared as ${describeType(type)}`, { details: { expected: describeType(type), actual: actual(value) } });
|
|
176
|
-
return;
|
|
177
|
-
}
|
|
178
|
-
switch (type.kind) {
|
|
179
|
-
case 'primitive': {
|
|
180
|
-
const expected = type.primitive === 'number' ? 'number' : type.primitive === 'boolean' ? 'boolean' : 'string';
|
|
181
|
-
if (typeof value !== expected) {
|
|
182
|
-
report(VALIDATION_CODES.initialValueTypeMismatch, `${path} should be a ${type.primitive} but is ${actual(value)}`, { details: { expected: type.primitive, actual: actual(value), value } });
|
|
183
|
-
}
|
|
184
|
-
return;
|
|
185
|
-
}
|
|
186
|
-
case 'enum': {
|
|
187
|
-
if (typeof value !== 'string' || !type.values.includes(value)) {
|
|
188
|
-
report(VALIDATION_CODES.initialValueTypeMismatch, `${path} should be one of ${type.values.join(', ')} but is ${JSON.stringify(value)}`, { details: { expected: type.values, value } });
|
|
189
|
-
}
|
|
190
|
-
return;
|
|
191
|
-
}
|
|
192
|
-
case 'collection': {
|
|
193
|
-
if (!Array.isArray(value)) {
|
|
194
|
-
report(VALIDATION_CODES.initialValueTypeMismatch, `${path} should be a collection but is ${actual(value)}`, { details: { expected: describeType(type), actual: actual(value) } });
|
|
195
|
-
return;
|
|
196
|
-
}
|
|
197
|
-
value.forEach((item, index) => {
|
|
198
|
-
validateValue(item, type.itemType, `${path}[${index}]`, state, context);
|
|
199
|
-
});
|
|
200
|
-
return;
|
|
201
|
-
}
|
|
202
|
-
case 'entity': {
|
|
203
|
-
const entity = context.semantics.getEntity(type.entityId);
|
|
204
|
-
if (!entity) {
|
|
205
|
-
return;
|
|
206
|
-
}
|
|
207
|
-
if (typeof value !== 'object' || Array.isArray(value)) {
|
|
208
|
-
report(VALIDATION_CODES.initialValueInvalidEntity, `${path} should be a ${entity.name ?? entity.id} record but is ${actual(value)}`, { details: { entityId: entity.id, actual: actual(value) } });
|
|
209
|
-
return;
|
|
210
|
-
}
|
|
211
|
-
const record = value;
|
|
212
|
-
const declared = new Map(entity.fields.map((field) => [String(field.id), field]));
|
|
213
|
-
for (const key of Object.keys(record)) {
|
|
214
|
-
if (!declared.has(key)) {
|
|
215
|
-
report(VALIDATION_CODES.initialValueUnknownField, `${path}.${key} is not a field of ${entity.name ?? entity.id}. Records are keyed by field id, not by field name.`, { details: { entityId: entity.id, key, expected: [...declared.keys()] } });
|
|
216
|
-
}
|
|
217
|
-
}
|
|
218
|
-
for (const field of entity.fields) {
|
|
219
|
-
const present = Object.prototype.hasOwnProperty.call(record, String(field.id));
|
|
220
|
-
if (!present) {
|
|
221
|
-
// A draft holds work in progress, so it is allowed to be incomplete.
|
|
222
|
-
if (field.required && field.valueType.kind !== 'optional' && !state.draft) {
|
|
223
|
-
report(VALIDATION_CODES.initialValueMissingRequiredField, `${path} is missing required field ${field.name ?? field.id}`, { fieldId: field.id, details: { entityId: entity.id, fieldId: field.id } });
|
|
224
|
-
}
|
|
225
|
-
continue;
|
|
226
|
-
}
|
|
227
|
-
validateValue(record[String(field.id)], field.valueType, `${path}.${field.id}`, state, context);
|
|
228
|
-
}
|
|
229
|
-
return;
|
|
230
|
-
}
|
|
231
|
-
default:
|
|
173
|
+
const problems = validateValueAgainstType(value, type, {
|
|
174
|
+
path,
|
|
175
|
+
getEntity: (id) => context.semantics.getEntity(id),
|
|
176
|
+
allowIncomplete: state.draft === true,
|
|
177
|
+
});
|
|
178
|
+
for (const problem of problems) {
|
|
179
|
+
context.errors.push({ ...problem, nodeId: state.id });
|
|
232
180
|
}
|
|
233
181
|
}
|
|
234
182
|
function validateAction(action, context) {
|
|
183
|
+
if (action.authorization) {
|
|
184
|
+
validateExpression(action.authorization, action.id, context, new Set());
|
|
185
|
+
}
|
|
235
186
|
const local = emptyScope(new Set((action.parameters ?? []).map((parameter) => parameter.id)));
|
|
236
187
|
for (const parameter of action.parameters ?? []) {
|
|
237
188
|
if (parameter.valueType) {
|
|
@@ -498,6 +449,13 @@ function validateUiNode(node, context) {
|
|
|
498
449
|
if (node.submitActionId) {
|
|
499
450
|
requireKind(node.submitActionId, 'action', node.id, context, VALIDATION_CODES.invalidActionRef);
|
|
500
451
|
}
|
|
452
|
+
if (!node.submitButtonId && node.submitActionId) {
|
|
453
|
+
// A generated submit button carries no arguments, so an action needing any of them
|
|
454
|
+
// could never be satisfied. Silent omission is what produced ARGUMENT_TYPE_MISMATCH
|
|
455
|
+
// at run time; it is rejected here instead.
|
|
456
|
+
requireArguments(node.submitActionId, {}, node.id, context, `Form ${node.id} submits ${node.submitActionId}, whose generated button cannot supply arguments;` +
|
|
457
|
+
' declare a submit button with submitButtonId and give it');
|
|
458
|
+
}
|
|
501
459
|
if (node.submitButtonId) {
|
|
502
460
|
const button = context.nodes.get(node.submitButtonId);
|
|
503
461
|
if (button?.kind !== 'button') {
|
|
@@ -546,6 +504,7 @@ function validateUiNode(node, context) {
|
|
|
546
504
|
return;
|
|
547
505
|
case 'button': {
|
|
548
506
|
requireKind(node.actionId, 'action', node.id, context, VALIDATION_CODES.invalidActionRef);
|
|
507
|
+
requireArguments(node.actionId, node.arguments ?? {}, node.id, context, `Button ${node.id}`);
|
|
549
508
|
if (typeof node.label !== 'string') {
|
|
550
509
|
validateExpression(node.label, node.id, context, new Set());
|
|
551
510
|
}
|
|
@@ -571,6 +530,28 @@ function validateUiNode(node, context) {
|
|
|
571
530
|
default:
|
|
572
531
|
}
|
|
573
532
|
}
|
|
533
|
+
/**
|
|
534
|
+
* Every required parameter of an action must have an argument. A missing one is statically
|
|
535
|
+
* knowable, and would otherwise surface as a refusal at invocation time — on the authority,
|
|
536
|
+
* for a remote action.
|
|
537
|
+
*/
|
|
538
|
+
function requireArguments(actionId, args, ownerId, context, where) {
|
|
539
|
+
const action = context.nodes.get(actionId);
|
|
540
|
+
if (action?.kind !== 'action') {
|
|
541
|
+
return;
|
|
542
|
+
}
|
|
543
|
+
const missing = (action.parameters ?? [])
|
|
544
|
+
.filter((parameter) => parameter.required && !(String(parameter.id) in args))
|
|
545
|
+
.map((parameter) => String(parameter.id));
|
|
546
|
+
if (missing.length > 0) {
|
|
547
|
+
context.errors.push({
|
|
548
|
+
code: VALIDATION_CODES.missingActionArgument,
|
|
549
|
+
message: `${where} ${missing.length === 1 ? 'no argument for' : 'no arguments for'} ${missing.join(', ')}`,
|
|
550
|
+
nodeId: ownerId,
|
|
551
|
+
details: { actionId, missing },
|
|
552
|
+
});
|
|
553
|
+
}
|
|
554
|
+
}
|
|
574
555
|
/** Every UI node beneath this one, for checks that must know what a subtree contains. */
|
|
575
556
|
function uiDescendants(id, context) {
|
|
576
557
|
const found = new Set();
|