@sanoma-ai/workflows 0.2.0 → 0.4.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/README.md +121 -62
- package/dist/approvals.d.ts +11 -6
- package/dist/approvals.js +38 -10
- package/dist/approvals.js.map +1 -1
- package/dist/call-workflow.d.ts +16 -0
- package/dist/call-workflow.js +35 -0
- package/dist/call-workflow.js.map +1 -0
- package/dist/call.d.ts +33 -4
- package/dist/call.js +107 -42
- package/dist/call.js.map +1 -1
- package/dist/cedar.d.ts +87 -0
- package/dist/cedar.js +365 -0
- package/dist/cedar.js.map +1 -0
- package/dist/client.d.ts +85 -7
- package/dist/client.js +289 -25
- package/dist/client.js.map +1 -1
- package/dist/collection.d.ts +107 -0
- package/dist/collection.js +306 -0
- package/dist/collection.js.map +1 -0
- package/dist/config.d.ts +42 -14
- package/dist/config.js +82 -18
- package/dist/config.js.map +1 -1
- package/dist/constructors.d.ts +2 -0
- package/dist/constructors.js +5 -0
- package/dist/constructors.js.map +1 -0
- package/dist/data.d.ts +2 -0
- package/dist/data.js +3 -0
- package/dist/data.js.map +1 -0
- package/dist/datafile.d.ts +28 -2
- package/dist/datafile.js +118 -19
- package/dist/datafile.js.map +1 -1
- package/dist/db.d.ts +5 -3
- package/dist/db.js +88 -21
- package/dist/db.js.map +1 -1
- package/dist/define.d.ts +10 -4
- package/dist/define.js +1 -5
- package/dist/define.js.map +1 -1
- package/dist/describe.d.ts +45 -5
- package/dist/describe.js +72 -7
- package/dist/describe.js.map +1 -1
- package/dist/direct.d.ts +9 -0
- package/dist/direct.js +11 -0
- package/dist/direct.js.map +1 -0
- package/dist/errors.d.ts +16 -1
- package/dist/errors.js +30 -1
- package/dist/errors.js.map +1 -1
- package/dist/expectations.d.ts +3 -3
- package/dist/expectations.js +16 -7
- package/dist/expectations.js.map +1 -1
- package/dist/index.d.ts +8 -4
- package/dist/index.js +10 -4
- package/dist/index.js.map +1 -1
- package/dist/ledger.d.ts +12 -1
- package/dist/ledger.js +18 -11
- package/dist/ledger.js.map +1 -1
- package/dist/lint.d.ts +5 -4
- package/dist/lint.js +24 -7
- package/dist/lint.js.map +1 -1
- package/dist/op.d.ts +6 -0
- package/dist/op.js.map +1 -1
- package/dist/outline.d.ts +2 -0
- package/dist/outline.js +1 -1
- package/dist/outline.js.map +1 -1
- package/dist/paths.d.ts +4 -0
- package/dist/paths.js +17 -3
- package/dist/paths.js.map +1 -1
- package/dist/person.d.ts +61 -0
- package/dist/person.js +69 -0
- package/dist/person.js.map +1 -0
- package/dist/plugin.js +1 -1
- package/dist/plugin.js.map +1 -1
- package/dist/policy.d.ts +51 -55
- package/dist/policy.js +14 -36
- package/dist/policy.js.map +1 -1
- package/dist/resource.d.ts +21 -5
- package/dist/resource.js +12 -3
- package/dist/resource.js.map +1 -1
- package/dist/resources.d.ts +58 -6
- package/dist/resources.js +119 -26
- package/dist/resources.js.map +1 -1
- package/dist/run.d.ts +17 -1
- package/dist/run.js.map +1 -1
- package/dist/scenario.d.ts +16 -9
- package/dist/scenario.js +93 -20
- package/dist/scenario.js.map +1 -1
- package/dist/scenarios.d.ts +10 -6
- package/dist/scenarios.js +8 -4
- package/dist/scenarios.js.map +1 -1
- package/dist/schema.d.ts +32 -0
- package/dist/schema.js +225 -0
- package/dist/schema.js.map +1 -0
- package/dist/sdk.d.ts +34 -0
- package/dist/sdk.js +168 -0
- package/dist/sdk.js.map +1 -0
- package/dist/shared.d.ts +181 -5
- package/dist/shared.js +160 -7
- package/dist/shared.js.map +1 -1
- package/dist/sites.d.ts +52 -0
- package/dist/sites.js +174 -0
- package/dist/sites.js.map +1 -0
- package/dist/version.d.ts +3 -2
- package/dist/version.js +3 -2
- package/dist/version.js.map +1 -1
- package/dist/worker.d.ts +3 -2
- package/dist/worker.js +24 -4
- package/dist/worker.js.map +1 -1
- package/oxlint.json +5 -16
- package/package.json +8 -3
- package/src/approvals.ts +49 -13
- package/src/call-workflow.ts +44 -0
- package/src/call.ts +157 -45
- package/src/cedar.ts +493 -0
- package/src/client.ts +368 -37
- package/src/collection.ts +431 -0
- package/src/config.ts +131 -34
- package/src/constructors.ts +4 -0
- package/src/data.ts +10 -0
- package/src/datafile.ts +141 -21
- package/src/db.ts +90 -22
- package/src/define.ts +11 -9
- package/src/describe.ts +113 -10
- package/src/direct.ts +15 -0
- package/src/errors.ts +36 -1
- package/src/expectations.ts +17 -8
- package/src/index.ts +33 -2
- package/src/ledger.ts +35 -17
- package/src/lint.ts +29 -12
- package/src/op.ts +6 -0
- package/src/outline.ts +1 -1
- package/src/paths.ts +18 -3
- package/src/person.ts +75 -0
- package/src/plugin.ts +1 -1
- package/src/policy.ts +59 -63
- package/src/resource.ts +35 -8
- package/src/resources.ts +165 -24
- package/src/run.ts +18 -1
- package/src/scenario.ts +128 -33
- package/src/scenarios.ts +15 -8
- package/src/schema.ts +261 -0
- package/src/sdk.ts +211 -0
- package/src/shared.ts +271 -8
- package/src/sites.ts +220 -0
- package/src/version.ts +3 -2
- package/src/worker.ts +29 -6
package/src/scenario.ts
CHANGED
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
} from "@cucumber/messages";
|
|
14
14
|
import type { z } from "zod";
|
|
15
15
|
import type { RunSummary, SanomaClient } from "./client.ts";
|
|
16
|
+
import type { Approver } from "./define.ts";
|
|
16
17
|
import type { ResolvedConfig } from "./config.ts";
|
|
17
18
|
import {
|
|
18
19
|
buildLibrary,
|
|
@@ -25,22 +26,29 @@ import {
|
|
|
25
26
|
stepResults,
|
|
26
27
|
StepStatus,
|
|
27
28
|
} from "./cucumber.ts";
|
|
28
|
-
import { errorInfo, SanomaError } from "./errors.ts";
|
|
29
|
+
import { errorCode, errorInfo, SanomaError } from "./errors.ts";
|
|
29
30
|
import { type CallRecord, contains, type CoreExpectation, detailOf, isCall } from "./expectations.ts";
|
|
30
31
|
import type { Fake } from "./fake.ts";
|
|
31
32
|
import { fill, isBug, seedFrom, shapeOf } from "./fill.ts";
|
|
32
33
|
import type { LedgerRecord } from "./ledger.ts";
|
|
33
34
|
import type { Driver, Op } from "./op.ts";
|
|
34
35
|
import { type OutlineNode, outlineWorkflow } from "./outline.ts";
|
|
36
|
+
import { admit, type DeclaredPeople, readPeople } from "./resources.ts";
|
|
35
37
|
import { memorySecrets } from "./secret.ts";
|
|
36
38
|
import {
|
|
37
39
|
APPLY_WORKFLOW,
|
|
38
40
|
BUILT_IN_WORKFLOWS,
|
|
41
|
+
DATA_VENDOR,
|
|
42
|
+
declaresPeople,
|
|
39
43
|
DRIFT_WORKFLOW,
|
|
40
44
|
errorMessage,
|
|
41
45
|
fitsOp,
|
|
42
46
|
flatten,
|
|
47
|
+
groupNamesOf,
|
|
43
48
|
isEnded,
|
|
49
|
+
principalIdsOf,
|
|
50
|
+
problemLine,
|
|
51
|
+
RUNTIME_AGENTS,
|
|
44
52
|
} from "./shared.ts";
|
|
45
53
|
import type { Call, RunView, Seed } from "./steps.ts";
|
|
46
54
|
|
|
@@ -98,9 +106,11 @@ export interface Decision {
|
|
|
98
106
|
/**
|
|
99
107
|
* What the run should do: call an operation (with at least this input) or not, run the step
|
|
100
108
|
* `name`d in the workflow's outline or not, or a number of `times`, have the policy deny an
|
|
101
|
-
* operation (`denied
|
|
102
|
-
*
|
|
103
|
-
*
|
|
109
|
+
* operation (`denied`, for a `reason` when the step names one) or hold one for an approver
|
|
110
|
+
* (`held`: a person's id or a group's name, or `{ group }` a group's alone), end a certain way,
|
|
111
|
+
* for a drift run, report the resource `declared` (`<file>#<export>`) clean, gone, absent, or
|
|
112
|
+
* drifted in a field, or for an apply run, create it, or leave it bound to an object (`bound`,
|
|
113
|
+
* its vendor's id).
|
|
104
114
|
* A step whose own code checks the run (a connector's or the company's, or an operation's own
|
|
105
115
|
* test's) is `custom`, with what it is about.
|
|
106
116
|
*/
|
|
@@ -108,8 +118,8 @@ export type Expectation =
|
|
|
108
118
|
| { step: string; op: string; input?: unknown; called: boolean }
|
|
109
119
|
| { step: string; name: string; input?: unknown; called: boolean }
|
|
110
120
|
| { step: string; name: string; times: number }
|
|
111
|
-
| { step: string; op: string; decision: "denied" }
|
|
112
|
-
| { step: string; op: string; decision: "held"; approver:
|
|
121
|
+
| { step: string; op: string; decision: "denied"; reason?: string }
|
|
122
|
+
| { step: string; op: string; decision: "held"; approver: Approver }
|
|
113
123
|
| { step: string; outcome: "finished" | "failed"; code?: string }
|
|
114
124
|
| { step: string; declared: string; status: "clean" | "gone" | "absent" }
|
|
115
125
|
| { step: string; declared: string; status: "drifted"; field: string }
|
|
@@ -132,6 +142,8 @@ export interface Scenario {
|
|
|
132
142
|
text: string;
|
|
133
143
|
/** Every step in order, for a list and for marking graph nodes. */
|
|
134
144
|
steps: ScenarioStep[];
|
|
145
|
+
/** Who starts the run, by id, when a `Given <who> is acting` says: a declared person or agent. */
|
|
146
|
+
startedBy?: string;
|
|
135
147
|
given: Given[];
|
|
136
148
|
input: unknown;
|
|
137
149
|
decisions: Decision[];
|
|
@@ -152,10 +164,14 @@ export interface Check {
|
|
|
152
164
|
}
|
|
153
165
|
|
|
154
166
|
/**
|
|
155
|
-
* What steps can name: the config's operations, workflows
|
|
156
|
-
* its connectors (and the company) ship,
|
|
167
|
+
* What steps can name: the config's operations, workflows, resource types and collections by id,
|
|
168
|
+
* the steps its connectors (and the company) ship, where the feature files are, and the root
|
|
169
|
+
* whose data files declare its people.
|
|
157
170
|
*/
|
|
158
|
-
export type Scope = Pick<
|
|
171
|
+
export type Scope = Pick<
|
|
172
|
+
ResolvedConfig,
|
|
173
|
+
"ops" | "workflows" | "scenarios" | "types" | "collections" | "steps" | "root"
|
|
174
|
+
>;
|
|
159
175
|
|
|
160
176
|
type Kind = ScenarioStep["kind"];
|
|
161
177
|
type Fault = Extract<Seed, { fault: unknown }>["fault"];
|
|
@@ -170,9 +186,13 @@ interface Then {
|
|
|
170
186
|
verify(run: RunView): string | undefined;
|
|
171
187
|
}
|
|
172
188
|
|
|
173
|
-
/**
|
|
189
|
+
/**
|
|
190
|
+
* What a step of each kind reads into; `any` is a decision, which a step of any keyword may make,
|
|
191
|
+
* and `actor` a `Given` saying who starts the run (`{who} is acting`), by id.
|
|
192
|
+
*/
|
|
174
193
|
interface Items {
|
|
175
194
|
given: Unstepped<Given>[];
|
|
195
|
+
actor: string;
|
|
176
196
|
when: When;
|
|
177
197
|
then: Then;
|
|
178
198
|
any: Unstepped<Decision>;
|
|
@@ -314,7 +334,9 @@ function builtFor(scope: Scope): Built {
|
|
|
314
334
|
cached &&
|
|
315
335
|
cached.scope.workflows === scope.workflows &&
|
|
316
336
|
cached.scope.types === scope.types &&
|
|
317
|
-
cached.scope.
|
|
337
|
+
cached.scope.collections === scope.collections &&
|
|
338
|
+
cached.scope.steps === scope.steps &&
|
|
339
|
+
cached.scope.root === scope.root
|
|
318
340
|
) {
|
|
319
341
|
return cached;
|
|
320
342
|
}
|
|
@@ -333,24 +355,59 @@ const expecting = (expect: CoreExpectation): Then => ({ expect, verify: (run) =>
|
|
|
333
355
|
|
|
334
356
|
/** The rules a scope has, and the parameter types their expressions use. */
|
|
335
357
|
function rulesOf(scope: Scope): { rules: Rule[]; parameterTypes: ParameterTypeSpec[] } {
|
|
336
|
-
const { types } = scope;
|
|
358
|
+
const { types, collections } = scope;
|
|
359
|
+
// A collection's records are seeded as a resource's objects are, by `data.<name>`, through its operations.
|
|
360
|
+
const collectionOf = (id: unknown) =>
|
|
361
|
+
(id as string).startsWith(`${DATA_VENDOR}.`)
|
|
362
|
+
? collections.get((id as string).slice(DATA_VENDOR.length + 1))
|
|
363
|
+
: undefined;
|
|
364
|
+
const seedable = new Map<string, unknown>([
|
|
365
|
+
...types,
|
|
366
|
+
...[...collections.keys()].map((n) => [`${DATA_VENDOR}.${n}`, n] as const),
|
|
367
|
+
]);
|
|
337
368
|
const parameterTypes: ParameterTypeSpec[] = [
|
|
338
369
|
{ name: "op", regexp: /\S+/, transformer: known("operation", scope.ops) },
|
|
339
370
|
{ name: "workflow", regexp: /\S+/, transformer: known("workflow", scope.workflows) },
|
|
340
371
|
{ name: "who", regexp: /\S+/, transformer: (s) => s },
|
|
341
|
-
{ name: "resource", regexp: /\S+/, transformer: known("resource type",
|
|
372
|
+
{ name: "resource", regexp: /\S+/, transformer: known("resource type", seedable) },
|
|
342
373
|
// A declared resource, as a drift report names it: `<file>#<export>`.
|
|
343
374
|
{ name: "declared", regexp: /[^\s#]+#\S+/, transformer: (s) => s },
|
|
344
375
|
{ name: "field", regexp: /\S+/, transformer: (s) => s },
|
|
345
376
|
];
|
|
346
377
|
const opOf = (id: unknown) => scope.ops.get(id as string)!;
|
|
347
378
|
const workflowOf = (name: unknown) => scope.workflows.get(name as string)!;
|
|
379
|
+
// The people as the root's data files declare them when a step names one (`readPeople`, read
|
|
380
|
+
// again only once a file changed): while they declare no one, any id and any group go.
|
|
381
|
+
/** `who`, as a run's start admits them (`admit`): a declared person or agent. */
|
|
382
|
+
const actorNamed = (who: string) => {
|
|
383
|
+
const declared = readPeople(scope.root);
|
|
384
|
+
try {
|
|
385
|
+
return admit({ id: who }, declared).id;
|
|
386
|
+
} catch (err) {
|
|
387
|
+
if (errorCode(err) !== "not_admitted" || declared.problems.length) throw err;
|
|
388
|
+
const ids = [...principalIdsOf(declared.people), ...RUNTIME_AGENTS].join(", ");
|
|
389
|
+
throw new Error(`${who} is not a declared person or agent; resources/ declares ${ids}`, { cause: err });
|
|
390
|
+
}
|
|
391
|
+
};
|
|
392
|
+
/** `group`, a declared group or `agents`, while the people's files have no problems. */
|
|
393
|
+
const groupNamed = (group: string) => {
|
|
394
|
+
const { people, problems } = readPeople(scope.root);
|
|
395
|
+
if (problems.length) {
|
|
396
|
+
throw new Error(
|
|
397
|
+
`no group can be told while the people's files have problems: ${problems.map(problemLine).join("; ")}`,
|
|
398
|
+
);
|
|
399
|
+
}
|
|
400
|
+
const groups = groupNamesOf(people);
|
|
401
|
+
if (!declaresPeople(people) || groups.includes(group)) return group;
|
|
402
|
+
throw new Error(`no group "${group}"; resources/ declares ${groups.join(", ")}`);
|
|
403
|
+
};
|
|
348
404
|
/** A resource step's object: its type's id, vendor and type, and its import id. */
|
|
349
405
|
const object = (id: unknown, name: unknown) => {
|
|
350
406
|
const { vendor, type } = types.get(id as string)!;
|
|
351
407
|
return { resource: id as string, vendor, type, name: name as string };
|
|
352
408
|
};
|
|
353
409
|
const rules: Rule[] = [
|
|
410
|
+
{ kind: "actor", source: "{who} is acting", read: ([who]) => actorNamed(who as string) },
|
|
354
411
|
{
|
|
355
412
|
kind: "given",
|
|
356
413
|
source: "{op} was called with",
|
|
@@ -368,21 +425,32 @@ function rulesOf(scope: Scope): { rules: Rule[]; parameterTypes: ParameterTypeSp
|
|
|
368
425
|
kind: "given",
|
|
369
426
|
source: "{resource} {string} has",
|
|
370
427
|
takes: TAKES_OBJECT,
|
|
371
|
-
read: ([resource, name], pickle) =>
|
|
372
|
-
{
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
)
|
|
379
|
-
|
|
380
|
-
|
|
428
|
+
read: ([resource, name], pickle) => {
|
|
429
|
+
const owner = `${resource as string} "${name as string}"`;
|
|
430
|
+
const c = collectionOf(resource);
|
|
431
|
+
if (c) {
|
|
432
|
+
// The record with its key, the fields the step gives, and the rest made up.
|
|
433
|
+
const record = shapeOf(opOf(c.save.id).input).record!;
|
|
434
|
+
const fields = fieldsFrom(pickle, record, owner);
|
|
435
|
+
if (Object.hasOwn(fields, c.key) && fields[c.key] !== name) {
|
|
436
|
+
throw new Error(
|
|
437
|
+
`${c.key} in ${owner} is ${JSON.stringify(fields[c.key])}: it is the key, "${name as string}"`,
|
|
438
|
+
);
|
|
439
|
+
}
|
|
440
|
+
return [{ op: c.save.id, input: { record: fill(record, { ...fields, [c.key]: name }) } }];
|
|
441
|
+
}
|
|
442
|
+
return [
|
|
443
|
+
{ ...object(resource, name), fields: fieldsFrom(pickle, types.get(resource as string)!.schema, owner) },
|
|
444
|
+
];
|
|
445
|
+
},
|
|
381
446
|
},
|
|
382
447
|
{
|
|
383
448
|
kind: "given",
|
|
384
449
|
source: "{resource} {string} is gone",
|
|
385
|
-
read: ([resource, name]) =>
|
|
450
|
+
read: ([resource, name]) => {
|
|
451
|
+
const c = collectionOf(resource);
|
|
452
|
+
return [c ? { op: c.delete.id, input: { key: name } } : { ...object(resource, name), gone: true }];
|
|
453
|
+
},
|
|
386
454
|
},
|
|
387
455
|
{
|
|
388
456
|
kind: "when",
|
|
@@ -448,11 +516,22 @@ function rulesOf(scope: Scope): { rules: Rule[]; parameterTypes: ParameterTypeSp
|
|
|
448
516
|
source: "{op} was denied",
|
|
449
517
|
read: ([op]) => expecting({ op: op as string, decision: "denied" }),
|
|
450
518
|
},
|
|
519
|
+
{
|
|
520
|
+
kind: "then",
|
|
521
|
+
source: "{op} was denied with {string}",
|
|
522
|
+
read: ([op, reason]) => expecting({ op: op as string, decision: "denied", reason: reason as string }),
|
|
523
|
+
},
|
|
451
524
|
{
|
|
452
525
|
kind: "then",
|
|
453
526
|
source: "{op} was held for {who}",
|
|
454
527
|
read: ([op, who]) => expecting({ op: op as string, decision: "held", approver: who as string }),
|
|
455
528
|
},
|
|
529
|
+
{
|
|
530
|
+
kind: "then",
|
|
531
|
+
source: "{op} was held for group {string}",
|
|
532
|
+
read: ([op, group]) =>
|
|
533
|
+
expecting({ op: op as string, decision: "held", approver: { group: groupNamed(group as string) } }),
|
|
534
|
+
},
|
|
456
535
|
{
|
|
457
536
|
kind: "then",
|
|
458
537
|
source: "step {string} was called with",
|
|
@@ -650,7 +729,8 @@ function lastCall(records: readonly LedgerRecord[]): CallRecord | string {
|
|
|
650
729
|
|
|
651
730
|
/** Every step the scope knows, for an error: the generic ones by keyword, then each operation's phrases and each step library's steps under its name. */
|
|
652
731
|
function knownSteps(rules: Rule[]): string {
|
|
653
|
-
const keyword = (kind: Rule["kind"]) =>
|
|
732
|
+
const keyword = (kind: Rule["kind"]) =>
|
|
733
|
+
kind === "any" ? "Given/When/Then" : kind === "actor" ? "Given" : kind[0]!.toUpperCase() + kind.slice(1);
|
|
654
734
|
const line = (r: Rule) => `${keyword(r.kind)} ${r.source}${r.takes ? ` (and ${r.takes})` : ""}`;
|
|
655
735
|
const { generic = [], ...groups } = Object.groupBy(rules, (r) => r.group ?? "generic");
|
|
656
736
|
return [
|
|
@@ -804,7 +884,9 @@ const ambiguous = (text: string, sources: string[]) =>
|
|
|
804
884
|
function ruleFor(built: Built, expression: string, pickle: PickleStep): Rule {
|
|
805
885
|
const kind = pickle.type && KINDS[pickle.type];
|
|
806
886
|
if (!kind) throw new Error(`"${pickle.text}" needs a Given, When or Then before it`);
|
|
807
|
-
const rules = built.byExpression
|
|
887
|
+
const rules = built.byExpression
|
|
888
|
+
.get(expression)!
|
|
889
|
+
.filter((r) => r.kind === kind || r.kind === "any" || (r.kind === "actor" && kind === "given"));
|
|
808
890
|
if (rules.length === 0) throw new Error(noMatch(built, pickle.text));
|
|
809
891
|
if (rules.length > 1)
|
|
810
892
|
throw new Error(
|
|
@@ -837,6 +919,11 @@ function compileWorld(built: Built, specs: Map<string, Spec>) {
|
|
|
837
919
|
const { text } = pickle;
|
|
838
920
|
const step: ScenarioStep = { text, kind: KINDS[pickle.type!]! };
|
|
839
921
|
switch (rule.kind) {
|
|
922
|
+
case "actor":
|
|
923
|
+
if (spec.startedBy !== undefined)
|
|
924
|
+
throw new Error(`${spec.startedBy} is acting already: a run has one actor`);
|
|
925
|
+
spec.startedBy = rule.read(args, pickle);
|
|
926
|
+
break;
|
|
840
927
|
case "given": {
|
|
841
928
|
const items = rule.read(args, pickle);
|
|
842
929
|
spec.given.push(...items.map((item) => ({ step: text, ...item }) as Given));
|
|
@@ -988,6 +1075,8 @@ function testWorld(built: Built, { driver, fake }: FeatureTarget) {
|
|
|
988
1075
|
throw new Error(
|
|
989
1076
|
`"${text}" decides an approval: a step for a scenario in scenarios/, not an operation's own test`,
|
|
990
1077
|
);
|
|
1078
|
+
case "actor":
|
|
1079
|
+
throw new Error(`"${text}" says who starts a run: a step for a scenario in scenarios/`);
|
|
991
1080
|
case "then": {
|
|
992
1081
|
const detail = rule.read(args, pickle).verify(viewOf(records));
|
|
993
1082
|
if (detail !== undefined) throw new Error(detail);
|
|
@@ -1179,18 +1268,22 @@ async function compile(scope: Scope, dir: string, files: string[]): Promise<Load
|
|
|
1179
1268
|
}
|
|
1180
1269
|
|
|
1181
1270
|
/**
|
|
1182
|
-
* Each scope's scenarios as last read, with the stamp of the files they were read from
|
|
1183
|
-
*
|
|
1271
|
+
* Each scope's scenarios as last read, with the stamp of the files they were read from and the
|
|
1272
|
+
* people they were checked against (`readPeople`'s answer, the same object until a data file
|
|
1273
|
+
* changes). By scope, since what a file means depends on its operations and workflows.
|
|
1184
1274
|
*/
|
|
1185
|
-
const loadedByScope = new WeakMap<
|
|
1275
|
+
const loadedByScope = new WeakMap<
|
|
1276
|
+
Scope,
|
|
1277
|
+
{ stamp: string; people: DeclaredPeople | undefined; loaded: Promise<LoadedScenarios> }
|
|
1278
|
+
>();
|
|
1186
1279
|
|
|
1187
1280
|
/**
|
|
1188
1281
|
* Every scenario in the `.feature` files under `scenarios` (a `file:` URL to a directory),
|
|
1189
1282
|
* compiled by Cucumber, with an error for each file that does not compile and each name used
|
|
1190
1283
|
* twice. No directory, or none given, is no scenarios. Rejects when an operation's phrase names
|
|
1191
1284
|
* a field it lacks. The files are compiled again only once one is added, removed or written
|
|
1192
|
-
* (its name, `mtimeMs` or size changes)
|
|
1193
|
-
* callers must not change.
|
|
1285
|
+
* (its name, `mtimeMs` or size changes), or the people's data files change: until then each
|
|
1286
|
+
* call returns the same object, which callers must not change.
|
|
1194
1287
|
*/
|
|
1195
1288
|
export async function loadScenarios(scope: Scope): Promise<LoadedScenarios> {
|
|
1196
1289
|
if (!scope.scenarios) return { scenarios: [], errors: [] };
|
|
@@ -1206,10 +1299,12 @@ export async function loadScenarios(scope: Scope): Promise<LoadedScenarios> {
|
|
|
1206
1299
|
return [file, mtimeMs, size];
|
|
1207
1300
|
}),
|
|
1208
1301
|
]);
|
|
1302
|
+
// None without a root, which declares no one.
|
|
1303
|
+
const people = scope.root === undefined ? undefined : readPeople(scope.root);
|
|
1209
1304
|
const cached = loadedByScope.get(scope);
|
|
1210
|
-
if (cached?.stamp === stamp) return cached.loaded;
|
|
1305
|
+
if (cached?.stamp === stamp && cached.people === people) return cached.loaded;
|
|
1211
1306
|
const loaded = files.length ? compile(scope, dir, files) : Promise.resolve({ scenarios: [], errors: [] });
|
|
1212
|
-
loadedByScope.set(scope, { stamp, loaded });
|
|
1307
|
+
loadedByScope.set(scope, { stamp, people, loaded });
|
|
1213
1308
|
// A compile that throws (a bug) is not kept: the next call tries again.
|
|
1214
1309
|
loaded.catch(() => {
|
|
1215
1310
|
if (loadedByScope.get(scope)?.loaded === loaded) loadedByScope.delete(scope);
|
package/src/scenarios.ts
CHANGED
|
@@ -18,21 +18,28 @@ export interface RunScenarioOptions {
|
|
|
18
18
|
client: SanomaClient;
|
|
19
19
|
/** What the scenario was loaded from (`loadScenarios`), whose feature files its run is checked against: the resolved config. */
|
|
20
20
|
scope: Scope;
|
|
21
|
-
/**
|
|
22
|
-
|
|
21
|
+
/**
|
|
22
|
+
* Who starts the run, by id (`"ada"` or `{ id: "ada" }`), unless the scenario says who is acting.
|
|
23
|
+
* Defaults to `scenarios`, one of the runtime's own agents.
|
|
24
|
+
*/
|
|
25
|
+
startedBy?: Principal | string;
|
|
23
26
|
/** How long the run may take, passed to `drive`. */
|
|
24
27
|
timeoutMs?: number;
|
|
25
28
|
}
|
|
26
29
|
|
|
27
30
|
/**
|
|
28
|
-
* Runs a scenario as a sandbox run: starts its workflow with its input,
|
|
29
|
-
* as it says, and checks the run's ledger against it (`check`:
|
|
31
|
+
* Runs a scenario as a sandbox run: starts its workflow with its input, as whoever it says is
|
|
32
|
+
* acting, decides its approvals as it says, and checks the run's ledger against it (`check`:
|
|
33
|
+
* Cucumber runs its `Then` steps).
|
|
30
34
|
*/
|
|
31
35
|
export async function runScenario(
|
|
32
36
|
scenario: Scenario,
|
|
33
|
-
{ client, scope, startedBy =
|
|
37
|
+
{ client, scope, startedBy = "scenarios", timeoutMs }: RunScenarioOptions,
|
|
34
38
|
): Promise<{ runId: string; run: RunSummary; ledger: LedgerRecord[]; checks: Check[] }> {
|
|
35
|
-
const runId = await client.start(scenario.workflow, scenario.input, {
|
|
39
|
+
const runId = await client.start(scenario.workflow, scenario.input, {
|
|
40
|
+
startedBy: scenario.startedBy ?? startedBy,
|
|
41
|
+
sandbox: scenario.name,
|
|
42
|
+
});
|
|
36
43
|
const run = await drive(client, runId, scenario, { timeoutMs });
|
|
37
44
|
const ledger = await client.ledger(runId);
|
|
38
45
|
return { runId, run, ledger, checks: await check(scope, scenario, ledger) };
|
|
@@ -49,8 +56,8 @@ export interface ScenarioFailure {
|
|
|
49
56
|
export interface RunScenariosOptions {
|
|
50
57
|
/** The Postgres the worker and the client run on, instead of the config's: a database of its own. */
|
|
51
58
|
databaseUrl: string;
|
|
52
|
-
/** Who starts the runs
|
|
53
|
-
startedBy?: Principal;
|
|
59
|
+
/** Who starts the runs whose scenario does not say who is acting (`runScenario`'s). */
|
|
60
|
+
startedBy?: Principal | string;
|
|
54
61
|
/** How long each run may take (`drive`'s; 15 seconds by default). */
|
|
55
62
|
timeoutMs?: number;
|
|
56
63
|
/** Receives a line per scenario as it ends. */
|
package/src/schema.ts
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import type {
|
|
4
|
+
DetailedError,
|
|
5
|
+
EntityType,
|
|
6
|
+
RecordType,
|
|
7
|
+
SchemaJson,
|
|
8
|
+
Type,
|
|
9
|
+
TypeOfAttribute,
|
|
10
|
+
} from "@cedar-policy/cedar-wasm/nodejs";
|
|
11
|
+
import { recordSchemaOf } from "./collection.ts";
|
|
12
|
+
import type { ResolvedConfig } from "./config.ts";
|
|
13
|
+
import { jsonSchemaOf } from "./define.ts";
|
|
14
|
+
import { isObject } from "./resource.ts";
|
|
15
|
+
import { DATA_VENDOR, EFFECTS, groupNamesOf, type PeopleResource, WORKFLOW_ACTION } from "./shared.ts";
|
|
16
|
+
|
|
17
|
+
// The Cedar schema a company's policies are validated and decided against: made from the
|
|
18
|
+
// resolved config and the people, in memory for the evaluator, and written as text to
|
|
19
|
+
// `.sanoma/schema.cedarschema` for the policies' author.
|
|
20
|
+
|
|
21
|
+
const require = createRequire(import.meta.url);
|
|
22
|
+
let wasm: typeof import("@cedar-policy/cedar-wasm/nodejs") | undefined;
|
|
23
|
+
|
|
24
|
+
/** `@cedar-policy/cedar-wasm`'s Node build, loaded (synchronously, once) on first use, so a process that never decides with Cedar never loads it. */
|
|
25
|
+
export const cedarWasm = () => (wasm ??= require("@cedar-policy/cedar-wasm/nodejs") as typeof wasm)!;
|
|
26
|
+
|
|
27
|
+
/** The schema as Cedar's JSON format has it. */
|
|
28
|
+
export type CedarSchemaJson = SchemaJson<string>;
|
|
29
|
+
|
|
30
|
+
/** Where the schema's text is written, under a company's root. */
|
|
31
|
+
export const SCHEMA_FILE = join(".sanoma", "schema.cedarschema");
|
|
32
|
+
|
|
33
|
+
/** The schema's namespace: policies name `Sanoma::Group::"finance"`, `Sanoma::Action::"effect:money"`. */
|
|
34
|
+
export const NAMESPACE = "Sanoma";
|
|
35
|
+
|
|
36
|
+
/** The action group every workflow start (`workflow:<name>`) is in. */
|
|
37
|
+
export const START = "start";
|
|
38
|
+
|
|
39
|
+
const STRING = { type: "EntityOrCommon", name: "String" } as const;
|
|
40
|
+
const LONG = { type: "EntityOrCommon", name: "Long" } as const;
|
|
41
|
+
const BOOL = { type: "EntityOrCommon", name: "Bool" } as const;
|
|
42
|
+
const entity = (name: string) => ({ type: "Entity", name }) as const;
|
|
43
|
+
const record = (attributes: Record<string, TypeOfAttribute<string>>): RecordType<string> & { type: "Record" } => ({
|
|
44
|
+
type: "Record",
|
|
45
|
+
attributes,
|
|
46
|
+
});
|
|
47
|
+
const optional = (type: Type<string>): TypeOfAttribute<string> => ({ ...type, required: false });
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The Cedar type of a zod schema's parsed value (`io: "output"`: what the policy is given, defaults
|
|
51
|
+
* filled and transforms applied), through its JSON Schema; undefined when it has none.
|
|
52
|
+
*/
|
|
53
|
+
const typeOf = (schema: Parameters<typeof jsonSchemaOf>[0]) => {
|
|
54
|
+
const json = jsonSchemaOf(schema, "output");
|
|
55
|
+
return cedarType(json, { root: json, seen: new Set() });
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/** A context's `input`, typed from a zod schema: none when it has no Cedar type. */
|
|
59
|
+
const input = (schema: Parameters<typeof jsonSchemaOf>[0]): Record<string, TypeOfAttribute<string>> => {
|
|
60
|
+
const type = typeOf(schema);
|
|
61
|
+
return type ? { input: type } : {};
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/** A collection's operations that change a record, whose context has the record as it was (`before`). */
|
|
65
|
+
const WRITES = new Set(["save", "delete"]);
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The Cedar schema for a resolved config and its people, namespace `Sanoma`:
|
|
69
|
+
*
|
|
70
|
+
* - `entity Group enum [...]`, the declared groups and `agents`, so a policy naming another fails
|
|
71
|
+
* validation; `entity Principal in [Group] { onBehalfOf?: Principal }`; `Collection`;
|
|
72
|
+
* `Target in [Collection]`; `Workflow`;
|
|
73
|
+
* - an action per operation id, in its effect's group (`effect:money`), on a `Target`, with
|
|
74
|
+
* `context` `{ input, before? }`: `input` from the operation's input schema, `before` the record
|
|
75
|
+
* as it was for a collection's `save` and `delete`;
|
|
76
|
+
* - `workflow:<name>` per workflow, in the group `start`, on a `Workflow`, with its `input`.
|
|
77
|
+
*
|
|
78
|
+
* Zod to Cedar, through the JSON Schema of the parsed value: string and enum `String`, integer `Long`, boolean `Bool`,
|
|
79
|
+
* object a record (its optional and nullable fields optional), array a `Set`; a decimal number
|
|
80
|
+
* is a `String`; a field with no Cedar type (a union of types, a map, a recursive type) is left
|
|
81
|
+
* out, so a policy naming it fails validation. Pure: the same config and people give the same schema.
|
|
82
|
+
*/
|
|
83
|
+
function buildSchema(
|
|
84
|
+
resolved: Pick<ResolvedConfig, "ops" | "workflows" | "collections">,
|
|
85
|
+
people: readonly PeopleResource[],
|
|
86
|
+
): CedarSchemaJson {
|
|
87
|
+
const applies = (resource: string, context: Record<string, TypeOfAttribute<string>>) => ({
|
|
88
|
+
principalTypes: ["Principal"],
|
|
89
|
+
resourceTypes: [resource],
|
|
90
|
+
context: record(context),
|
|
91
|
+
});
|
|
92
|
+
const actions: CedarSchemaJson[string]["actions"] = { [START]: {} };
|
|
93
|
+
for (const effect of EFFECTS) actions[`effect:${effect}`] = {};
|
|
94
|
+
for (const op of [...resolved.ops.values()].toSorted((a, b) => a.id.localeCompare(b.id))) {
|
|
95
|
+
const collection =
|
|
96
|
+
op.vendor === DATA_VENDOR && WRITES.has(op.name) ? resolved.collections.get(op.resource) : undefined;
|
|
97
|
+
const before = collection && typeOf(recordSchemaOf(collection));
|
|
98
|
+
actions[op.id] = {
|
|
99
|
+
memberOf: [{ id: `effect:${op.effect}` }],
|
|
100
|
+
appliesTo: applies("Target", { ...input(op.input), ...(before && { before: optional(before) }) }),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
for (const name of [...resolved.workflows.keys()].toSorted()) {
|
|
104
|
+
actions[`${WORKFLOW_ACTION}${name}`] = {
|
|
105
|
+
memberOf: [{ id: START }],
|
|
106
|
+
appliesTo: applies("Workflow", input(resolved.workflows.get(name)!.input)),
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
const entityTypes: Record<string, EntityType<string>> = {
|
|
110
|
+
Group: { enum: groupNamesOf(people) },
|
|
111
|
+
Principal: { memberOfTypes: ["Group"], shape: record({ onBehalfOf: optional(entity("Principal")) }) },
|
|
112
|
+
Collection: {},
|
|
113
|
+
Target: { memberOfTypes: ["Collection"] },
|
|
114
|
+
Workflow: {},
|
|
115
|
+
};
|
|
116
|
+
return { [NAMESPACE]: { entityTypes, actions } };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const schemas = new WeakMap<object, WeakMap<readonly PeopleResource[], CedarSchemaJson>>();
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* The Cedar schema for a resolved config and its people (`buildSchema`), made once for each pair:
|
|
123
|
+
* validating, deciding and writing it share one.
|
|
124
|
+
*/
|
|
125
|
+
export function schemaOf(
|
|
126
|
+
resolved: Pick<ResolvedConfig, "ops" | "workflows" | "collections">,
|
|
127
|
+
people: readonly PeopleResource[],
|
|
128
|
+
): CedarSchemaJson {
|
|
129
|
+
let byPeople = schemas.get(resolved.ops);
|
|
130
|
+
if (!byPeople) schemas.set(resolved.ops, (byPeople = new WeakMap()));
|
|
131
|
+
let schema = byPeople.get(people);
|
|
132
|
+
if (!schema) byPeople.set(people, (schema = buildSchema(resolved, people)));
|
|
133
|
+
return schema;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** The schema as Cedar's text, through the package's own `schemaToText`: what `.sanoma/schema.cedarschema` holds. */
|
|
137
|
+
export function schemaText(schema: CedarSchemaJson): string {
|
|
138
|
+
const answer = cedarWasm().schemaToText(schema);
|
|
139
|
+
if (answer.type === "failure") {
|
|
140
|
+
throw new Error(`The Cedar schema does not print: ${answer.errors.map(messageOf).join("; ")}`);
|
|
141
|
+
}
|
|
142
|
+
return answer.text;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
interface Typing {
|
|
146
|
+
root: Record<string, unknown>;
|
|
147
|
+
/** The schemas being typed, to leave a recursive one out. */
|
|
148
|
+
seen: Set<unknown>;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** The Cedar type of a JSON Schema (`jsonSchemaOf`'s, draft 2020-12), or undefined when it has none. */
|
|
152
|
+
function cedarType(schema: unknown, typing: Typing): Type<string> | undefined {
|
|
153
|
+
if (!isObject(schema) || typing.seen.has(schema)) return undefined;
|
|
154
|
+
if (typeof schema.$ref === "string") {
|
|
155
|
+
const defs = typing.root.$defs as Record<string, unknown> | undefined;
|
|
156
|
+
const target = schema.$ref.startsWith("#/$defs/") ? defs?.[schema.$ref.slice("#/$defs/".length)] : undefined;
|
|
157
|
+
return within(schema, typing, () => cedarType(target, typing));
|
|
158
|
+
}
|
|
159
|
+
const variants = schema.anyOf ?? schema.oneOf;
|
|
160
|
+
if (Array.isArray(variants)) {
|
|
161
|
+
const each = within(schema, typing, () => variants.filter((v) => !isNull(v)).map((v) => cedarType(v, typing)));
|
|
162
|
+
const first = each[0];
|
|
163
|
+
return first && each.every((t) => t && JSON.stringify(t) === JSON.stringify(first)) ? first : undefined;
|
|
164
|
+
}
|
|
165
|
+
const values = Array.isArray(schema.enum) ? schema.enum : "const" in schema ? [schema.const] : undefined;
|
|
166
|
+
if (values) {
|
|
167
|
+
if (values.every((v) => typeof v === "string")) return STRING;
|
|
168
|
+
if (values.every((v) => Number.isSafeInteger(v))) return LONG;
|
|
169
|
+
return values.every((v) => typeof v === "boolean") ? BOOL : undefined;
|
|
170
|
+
}
|
|
171
|
+
const types = Array.isArray(schema.type) ? schema.type.filter((t) => t !== "null") : [schema.type];
|
|
172
|
+
if (types.length !== 1) return undefined;
|
|
173
|
+
switch (types[0]) {
|
|
174
|
+
case "string":
|
|
175
|
+
case "number":
|
|
176
|
+
// Cedar has no decimal number: one is compared as text.
|
|
177
|
+
return STRING;
|
|
178
|
+
case "integer":
|
|
179
|
+
return LONG;
|
|
180
|
+
case "boolean":
|
|
181
|
+
return BOOL;
|
|
182
|
+
case "array": {
|
|
183
|
+
const element = within(schema, typing, () => cedarType(schema.items, typing));
|
|
184
|
+
return element && { type: "Set", element };
|
|
185
|
+
}
|
|
186
|
+
case "object": {
|
|
187
|
+
// A map's keys are not known: it has no Cedar type.
|
|
188
|
+
if (!isObject(schema.properties)) return schema.additionalProperties ? undefined : record({});
|
|
189
|
+
const required = new Set(Array.isArray(schema.required) ? schema.required : []);
|
|
190
|
+
const attributes: Record<string, TypeOfAttribute<string>> = {};
|
|
191
|
+
for (const [name, property] of Object.entries(schema.properties)) {
|
|
192
|
+
const type = within(schema, typing, () => cedarType(property, typing));
|
|
193
|
+
if (type) attributes[name] = required.has(name) && !allowsNull(property) ? type : optional(type);
|
|
194
|
+
}
|
|
195
|
+
return record(attributes);
|
|
196
|
+
}
|
|
197
|
+
default:
|
|
198
|
+
return undefined;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** `type()` with `schema` marked as being typed, so a reference back to it is not followed. */
|
|
203
|
+
function within<T>(schema: unknown, typing: Typing, type: () => T): T {
|
|
204
|
+
typing.seen.add(schema);
|
|
205
|
+
try {
|
|
206
|
+
return type();
|
|
207
|
+
} finally {
|
|
208
|
+
typing.seen.delete(schema);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const isNull = (schema: unknown) => isObject(schema) && schema.type === "null";
|
|
213
|
+
|
|
214
|
+
/** True when a JSON Schema accepts `null`: its value is then optional in Cedar, which has no null. */
|
|
215
|
+
const allowsNull = (schema: unknown): boolean =>
|
|
216
|
+
isObject(schema) &&
|
|
217
|
+
(isNull(schema) ||
|
|
218
|
+
(Array.isArray(schema.type) && schema.type.includes("null")) ||
|
|
219
|
+
[schema.anyOf, schema.oneOf].some((v) => Array.isArray(v) && v.some(isNull)));
|
|
220
|
+
|
|
221
|
+
/** Cedar's message, with its help when it has one. */
|
|
222
|
+
export const messageOf = (e: DetailedError) => (e.help ? `${e.message} (${e.help})` : e.message);
|
|
223
|
+
|
|
224
|
+
/** An action's context type in the schema: a record, when the action is there. */
|
|
225
|
+
export const contextTypeOf = (schema: CedarSchemaJson, action: string) =>
|
|
226
|
+
schema[NAMESPACE]?.actions[action]?.appliesTo?.context as (RecordType<string> & { type: "Record" }) | undefined;
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* A value as Cedar takes it for a type `schemaOf` wrote (`cedarType`'s other side): a record's
|
|
230
|
+
* fields it declares only, a set's elements, a number where it is a `String` as text. An unknown
|
|
231
|
+
* (`{ __extn: unknown }`) passes as it is; what does not fit is left out (undefined), as `null` is.
|
|
232
|
+
*/
|
|
233
|
+
export function cedarValue(value: unknown, type: Type<string>): unknown {
|
|
234
|
+
if (value === null || value === undefined) return undefined;
|
|
235
|
+
if (isObject(value) && "__extn" in value) return value;
|
|
236
|
+
const t = type as { type: string; name?: string; element?: Type<string>; attributes?: Record<string, Type<string>> };
|
|
237
|
+
if (t.attributes) {
|
|
238
|
+
if (!isObject(value)) return undefined;
|
|
239
|
+
return Object.fromEntries(
|
|
240
|
+
Object.entries(t.attributes).flatMap(([name, field]) => {
|
|
241
|
+
const shaped = cedarValue(value[name], field);
|
|
242
|
+
return shaped === undefined ? [] : [[name, shaped]];
|
|
243
|
+
}),
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
if (t.type === "Set") {
|
|
247
|
+
return Array.isArray(value)
|
|
248
|
+
? value.map((v) => cedarValue(v, t.element!)).filter((v) => v !== undefined)
|
|
249
|
+
: undefined;
|
|
250
|
+
}
|
|
251
|
+
switch (t.name) {
|
|
252
|
+
case STRING.name:
|
|
253
|
+
return typeof value === "string" ? value : typeof value === "number" ? String(value) : undefined;
|
|
254
|
+
case LONG.name:
|
|
255
|
+
return Number.isSafeInteger(value) ? value : undefined;
|
|
256
|
+
case BOOL.name:
|
|
257
|
+
return typeof value === "boolean" ? value : undefined;
|
|
258
|
+
default:
|
|
259
|
+
return undefined;
|
|
260
|
+
}
|
|
261
|
+
}
|