@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/sdk.ts
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import type { CollectionEntry, ConfigDescription, OpEntry, WorkflowEntry } from "./describe.ts";
|
|
4
|
+
import { isObject } from "./resource.ts";
|
|
5
|
+
import { type CedarSchemaJson, SCHEMA_FILE, schemaText } from "./schema.ts";
|
|
6
|
+
import { apiNameOf, DATA_VENDOR, problemLine } from "./shared.ts";
|
|
7
|
+
|
|
8
|
+
// A company's generated SDK, `.sanoma/api.ts`: the types of a site's `api` from `sanoma/site`,
|
|
9
|
+
// written from `describeConfig` (types and JSDoc only: `api` itself is one generic client). It
|
|
10
|
+
// augments `sanoma/site`'s `Register`, so it is type-checked only through the sites' generated
|
|
11
|
+
// tsconfig, never the company's root one (Node, no DOM).
|
|
12
|
+
|
|
13
|
+
/** Where `writeApi` writes, under a company's root. */
|
|
14
|
+
export const API_FILE = join(".sanoma", "api.ts");
|
|
15
|
+
|
|
16
|
+
const API_NAME_PROBLEM = "A site calls this workflow api.";
|
|
17
|
+
|
|
18
|
+
/** `describeConfig`'s problem on a workflow whose api name is taken: `taken` says by what. */
|
|
19
|
+
export const apiNameProblem = (api: string, taken: string) => `${API_NAME_PROBLEM}${api}, ${taken}: rename it`;
|
|
20
|
+
|
|
21
|
+
type Schema = Record<string, unknown>;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* `.sanoma/api.ts` for a description: one record type per collection, and `CompanyApi` (not `Api`,
|
|
25
|
+
* which inside `declare module "sanoma/site"` is the module's own), one member per
|
|
26
|
+
* workflow (`apiNameOf`), per vendor of the operations a person may call directly (`OpEntry.direct`,
|
|
27
|
+
* by resource) and `data` for the collections, each with its JSDoc; then `Register`'s
|
|
28
|
+
* augmentation. Pure: the same description gives the same text. A workflow whose api name another
|
|
29
|
+
* has, `describeConfig`'s problem on its entry, is left out, and its problem is in `problems`.
|
|
30
|
+
*/
|
|
31
|
+
export function generateApi(description: ConfigDescription): { source: string; problems: string[] } {
|
|
32
|
+
const problems: string[] = [];
|
|
33
|
+
const members: string[] = [];
|
|
34
|
+
for (const wf of description.workflows) {
|
|
35
|
+
const taken = wf.problems.filter((p) => p.message.startsWith(API_NAME_PROBLEM));
|
|
36
|
+
if (taken.length) problems.push(...taken.map((p) => problemLine({ file: wf.file, ...p })));
|
|
37
|
+
else members.push(workflowMember(wf));
|
|
38
|
+
}
|
|
39
|
+
// The ops are sorted by id, so by vendor and then resource.
|
|
40
|
+
const direct = description.ops.filter((op) => op.direct && op.vendor !== DATA_VENDOR);
|
|
41
|
+
for (const [vendor, ops] of Map.groupBy(direct, (op) => op.vendor)) {
|
|
42
|
+
const title = description.vendors[vendor]?.title ?? vendor;
|
|
43
|
+
const resources = [...Map.groupBy(ops, (op) => op.resource)].map(([resource, own]) =>
|
|
44
|
+
member(undefined, resource, block(own.map(opMember))),
|
|
45
|
+
);
|
|
46
|
+
members.push(member(`${title}: the operations a person may call directly.`, vendor, block(resources)));
|
|
47
|
+
}
|
|
48
|
+
if (description.collections.length) {
|
|
49
|
+
const data = description.collections.map((c) =>
|
|
50
|
+
member(collectionDoc(c), c.name, `CollectionApi<${recordName(c)}>`),
|
|
51
|
+
);
|
|
52
|
+
members.push(member("The company's collections.", DATA_VENDOR, block(data)));
|
|
53
|
+
}
|
|
54
|
+
const source = [
|
|
55
|
+
"// Generated by sanoma from sanoma.config.ts and the connectors it names; do not edit.",
|
|
56
|
+
"// The types of `api` from sanoma/site: what a site may call, as the person on the page.",
|
|
57
|
+
'import type { CollectionApi, OpFn, WorkflowFn } from "sanoma/site";',
|
|
58
|
+
...description.collections.map(
|
|
59
|
+
(c) => `\n${jsdoc(`A record of ${c.title ?? c.name}.`)}type ${recordName(c)} = ${typeOf(c.record)};`,
|
|
60
|
+
),
|
|
61
|
+
"",
|
|
62
|
+
`interface CompanyApi ${block(members)}`,
|
|
63
|
+
"",
|
|
64
|
+
'declare module "sanoma/site" {',
|
|
65
|
+
" interface Register {",
|
|
66
|
+
" api: CompanyApi;",
|
|
67
|
+
" }",
|
|
68
|
+
"}",
|
|
69
|
+
"",
|
|
70
|
+
"export {};",
|
|
71
|
+
"",
|
|
72
|
+
].join("\n");
|
|
73
|
+
return { source, problems };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Writes `<root>/.sanoma/api.ts` (`generateApi`) when its text differs from the file's, so an
|
|
78
|
+
* editor or a dev server sees no change when there is none; `wrote` says whether it did.
|
|
79
|
+
*/
|
|
80
|
+
export async function writeApi(
|
|
81
|
+
root: string,
|
|
82
|
+
description: ConfigDescription,
|
|
83
|
+
): Promise<{ wrote: boolean; file: string }> {
|
|
84
|
+
const file = join(root, API_FILE);
|
|
85
|
+
return { wrote: await writeChanged(file, generateApi(description).source), file };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The generated files, each written when its text changed: `.sanoma/api.ts` (`writeApi`) and the
|
|
90
|
+
* Cedar schema's text, `.sanoma/schema.cedarschema` (`schemaText` of `schemaOf`). `wrote` lists
|
|
91
|
+
* those written, relative to the root.
|
|
92
|
+
*/
|
|
93
|
+
export async function writeGenerated(
|
|
94
|
+
root: string,
|
|
95
|
+
description: ConfigDescription,
|
|
96
|
+
schema: CedarSchemaJson,
|
|
97
|
+
): Promise<{ wrote: string[] }> {
|
|
98
|
+
const [api, cedar] = await Promise.all([
|
|
99
|
+
writeApi(root, description),
|
|
100
|
+
writeChanged(join(root, SCHEMA_FILE), schemaText(schema)),
|
|
101
|
+
]);
|
|
102
|
+
return { wrote: [...(api.wrote ? [API_FILE] : []), ...(cedar ? [SCHEMA_FILE] : [])] };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Writes `text` to `file` unless the file holds it already; true when it wrote. */
|
|
106
|
+
async function writeChanged(file: string, text: string): Promise<boolean> {
|
|
107
|
+
if ((await readFile(file, "utf8").catch(() => undefined)) === text) return false;
|
|
108
|
+
await mkdir(dirname(file), { recursive: true });
|
|
109
|
+
await writeFile(file, text);
|
|
110
|
+
return true;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const workflowMember = (wf: WorkflowEntry) =>
|
|
114
|
+
member(
|
|
115
|
+
`${wf.title ?? wf.name}: the workflow \`${wf.name}\`${wf.builtin ? ", built in" : ""}.`,
|
|
116
|
+
apiNameOf(wf.name),
|
|
117
|
+
`WorkflowFn<${typeOf(wf.input)}, unknown>`,
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
const opMember = (op: OpEntry) =>
|
|
121
|
+
member(
|
|
122
|
+
`${op.description ? `${op.description}\n\n` : ""}\`${op.id}\`, a ${op.effect}.`,
|
|
123
|
+
op.name,
|
|
124
|
+
`OpFn<${typeOf(op.input)}, unknown>`,
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
const collectionDoc = (c: CollectionEntry) =>
|
|
128
|
+
`${c.title ?? c.name}: the collection \`${c.name}\`, keyed by \`${c.key}\`.`;
|
|
129
|
+
|
|
130
|
+
/** A collection's record type's name: `outbound` gives `OutboundRecord`, never `CompanyApi` nor an imported type's. */
|
|
131
|
+
const recordName = (c: CollectionEntry) => `${c.name[0]!.toUpperCase()}${c.name.slice(1)}Record`;
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* A JSON Schema as a TypeScript type, for the kinds a start form draws (`fieldsOf` in the app):
|
|
135
|
+
* strings (a date-time among them), numbers, integers, booleans, enums and constants, objects and
|
|
136
|
+
* arrays, each property with its description, and unions of those, `null` among them (a
|
|
137
|
+
* collection's date, a date or a date-time; a nullable field); anything else is `unknown`.
|
|
138
|
+
*/
|
|
139
|
+
const typeOf = (schema: unknown): string => union(alternatives(schema));
|
|
140
|
+
|
|
141
|
+
/** One type of several: `unknown` when any is. */
|
|
142
|
+
function union(types: string[]): string {
|
|
143
|
+
const distinct = new Set(types);
|
|
144
|
+
return distinct.has("unknown") ? "unknown" : [...distinct].join(" | ");
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** The types a schema allows, one per alternative. */
|
|
148
|
+
function alternatives(schema: unknown): string[] {
|
|
149
|
+
if (!isObject(schema)) return ["unknown"];
|
|
150
|
+
if ("const" in schema && !isObject(schema.const) && !Array.isArray(schema.const)) {
|
|
151
|
+
return [JSON.stringify(schema.const)];
|
|
152
|
+
}
|
|
153
|
+
if (Array.isArray(schema.enum) && schema.enum.length && schema.enum.every((v) => v === null || !isObject(v))) {
|
|
154
|
+
return schema.enum.map((v) => JSON.stringify(v));
|
|
155
|
+
}
|
|
156
|
+
if (Array.isArray(schema.anyOf) && schema.anyOf.length) return schema.anyOf.flatMap(alternatives);
|
|
157
|
+
if (Array.isArray(schema.type) && schema.type.length) {
|
|
158
|
+
return schema.type.flatMap((type) => alternatives({ ...schema, type }));
|
|
159
|
+
}
|
|
160
|
+
switch (schema.type) {
|
|
161
|
+
case "string":
|
|
162
|
+
return ["string"];
|
|
163
|
+
case "number":
|
|
164
|
+
case "integer":
|
|
165
|
+
return ["number"];
|
|
166
|
+
case "boolean":
|
|
167
|
+
return ["boolean"];
|
|
168
|
+
case "null":
|
|
169
|
+
return ["null"];
|
|
170
|
+
case "array": {
|
|
171
|
+
const item = typeOf(schema.items);
|
|
172
|
+
return [new Set(alternatives(schema.items)).size > 1 && item !== "unknown" ? `(${item})[]` : `${item}[]`];
|
|
173
|
+
}
|
|
174
|
+
case "object":
|
|
175
|
+
return [isObject(schema.properties) ? objectType(schema) : "Record<string, unknown>"];
|
|
176
|
+
default:
|
|
177
|
+
return ["unknown"];
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function objectType(schema: Schema): string {
|
|
182
|
+
const required = new Set(Array.isArray(schema.required) ? schema.required : []);
|
|
183
|
+
const lines = Object.entries(schema.properties as Schema).map(([name, prop]) => {
|
|
184
|
+
const doc = isObject(prop) && typeof prop.description === "string" ? jsdoc(prop.description) : "";
|
|
185
|
+
return `${doc}${key(name)}${required.has(name) ? "" : "?"}: ${typeOf(prop)};`;
|
|
186
|
+
});
|
|
187
|
+
// An object that takes other keys too.
|
|
188
|
+
if (schema.additionalProperties !== undefined && schema.additionalProperties !== false) {
|
|
189
|
+
lines.push("[key: string]: unknown;");
|
|
190
|
+
}
|
|
191
|
+
return block(lines);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** A property's name, quoted when it is not an identifier. */
|
|
195
|
+
const key = (name: string) => (/^[A-Za-z_$][\w$]*$/.test(name) ? name : JSON.stringify(name));
|
|
196
|
+
|
|
197
|
+
/** A member, under its JSDoc when there is one. */
|
|
198
|
+
const member = (doc: string | undefined, name: string, type: string) =>
|
|
199
|
+
`${doc ? jsdoc(doc) : ""}${key(name)}: ${type};`;
|
|
200
|
+
|
|
201
|
+
/** `{ … }` around lines, each indented a level (a nested block's lines with it); `{}` for none. */
|
|
202
|
+
const block = (lines: string[]) =>
|
|
203
|
+
lines.length ? `{\n${lines.map((l) => l.replace(/^(?=.)/gm, " ")).join("\n")}\n}` : "{}";
|
|
204
|
+
|
|
205
|
+
/** A JSDoc comment and the line break after it; `*\/` in the text cannot end it. */
|
|
206
|
+
function jsdoc(text: string): string {
|
|
207
|
+
const lines = text.replaceAll("*/", "*\\/").split("\n");
|
|
208
|
+
return lines.length === 1
|
|
209
|
+
? `/** ${lines[0]} */\n`
|
|
210
|
+
: `/**\n${lines.map((l) => ` *${l ? ` ${l}` : ""}`).join("\n")}\n */\n`;
|
|
211
|
+
}
|
package/src/shared.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
|
+
import type { RunSummary } from "./client.ts";
|
|
1
2
|
import type { CredentialStatus } from "./env.ts";
|
|
2
3
|
import type { ApprovalState, Approver, Principal } from "./define.ts";
|
|
3
|
-
import type {
|
|
4
|
+
import type { LedgerRecord } from "./ledger.ts";
|
|
5
|
+
import type { Effect } from "./op.ts";
|
|
6
|
+
import type { DeclaredResource, ResourceProblem } from "./resources.ts";
|
|
4
7
|
import type { OutlineNode } from "./outline.ts";
|
|
5
8
|
|
|
6
9
|
// Types only: resource types as a UI reads them. Erased from the bundle.
|
|
@@ -37,8 +40,117 @@ export const DRIFT_WORKFLOW = "drift";
|
|
|
37
40
|
*/
|
|
38
41
|
export const APPLY_WORKFLOW = "apply";
|
|
39
42
|
|
|
40
|
-
/**
|
|
41
|
-
|
|
43
|
+
/**
|
|
44
|
+
* The vendor of the collections' operations, `data.<collection>.<op>`: a collection's `get`,
|
|
45
|
+
* `list`, `save` and `delete`. No connector may take it.
|
|
46
|
+
*/
|
|
47
|
+
export const DATA_VENDOR = "data";
|
|
48
|
+
|
|
49
|
+
/** Every effect an operation may have, each an action group (`effect:<effect>`) of the Cedar schema. */
|
|
50
|
+
export const EFFECTS = ["read", "write", "publish", "send", "money", "access"] as const satisfies readonly Effect[];
|
|
51
|
+
|
|
52
|
+
/** What a workflow start's action id starts with, as a policy names it: `workflow:<name>`. */
|
|
53
|
+
export const WORKFLOW_ACTION = "workflow:";
|
|
54
|
+
|
|
55
|
+
/** A site's folder name under `sites/`, its URL segment: what the app mounts and `readSites` reads. */
|
|
56
|
+
export const SITE_NAME = /^[a-z][a-z0-9-]*$/;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* A record of a collection as `get`, `list` and `save` answer it: its key, the record, and when
|
|
60
|
+
* and by whom it was last saved (`updatedBy`: a person's id, or `run:<id>` for a run). A record
|
|
61
|
+
* saved under an older declaration comes back as it is stored, with `issues` saying where the
|
|
62
|
+
* fields no longer accept it.
|
|
63
|
+
*/
|
|
64
|
+
export type Saved<R = Record<string, unknown>> = {
|
|
65
|
+
key: string;
|
|
66
|
+
record: R;
|
|
67
|
+
/** ISO 8601. */
|
|
68
|
+
updatedAt: string;
|
|
69
|
+
updatedBy: string;
|
|
70
|
+
issues?: { path: (string | number)[]; message: string }[];
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* What a collection's `list` takes: fields equal to a value or within a range (`where`), the
|
|
75
|
+
* field to sort by (`-field` for descending; by key otherwise), and how many at most (1 to
|
|
76
|
+
* 1000, default 100).
|
|
77
|
+
*/
|
|
78
|
+
export type ListQuery<R> = {
|
|
79
|
+
where?: { [F in keyof R]?: Exclude<R[F], undefined> | Range<Exclude<R[F], undefined>> };
|
|
80
|
+
order?: (keyof R & string) | `-${keyof R & string}`;
|
|
81
|
+
limit?: number;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
type Range<V> = { gt?: V; gte?: V; lt?: V; lte?: V };
|
|
85
|
+
|
|
86
|
+
/** How many records a collection's `list` answers: `default` when the query names no `limit`, `max` at most. */
|
|
87
|
+
export const LIST_LIMIT = { default: 100, max: 1000 } as const;
|
|
88
|
+
|
|
89
|
+
/** The kinds of value a field holds: text, a number, true or false, a date (ISO 8601) or a person's id. */
|
|
90
|
+
export const FIELD_TYPES = ["text", "number", "boolean", "date", "person"] as const;
|
|
91
|
+
|
|
92
|
+
const COLLECTION_NAME = /^[a-z][a-zA-Z0-9]*$/;
|
|
93
|
+
const FIELD_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
94
|
+
const KINDS = `${FIELD_TYPES.map((t) => `"${t}"`).join(", ")}, { type, optional: true } or { enum: [...] }`;
|
|
95
|
+
const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Throws unless `collection()` takes this declaration: the name a lowercase letter then letters
|
|
99
|
+
* and digits, at least one field, each a type, the type marked optional or an enum of distinct
|
|
100
|
+
* strings, the key a required text field, the title a string. `collection()` and the reader of
|
|
101
|
+
* `resources/data.ts` both ask it, so `sanoma check` refuses what the runtime would.
|
|
102
|
+
*/
|
|
103
|
+
export function checkCollection(declaration: { name?: unknown; key?: unknown; fields?: unknown; title?: unknown }) {
|
|
104
|
+
const { name, key, fields, title } = declaration;
|
|
105
|
+
const where = `collection("${String(name)}")`;
|
|
106
|
+
if (typeof name !== "string" || !COLLECTION_NAME.test(name)) {
|
|
107
|
+
throw new Error(`${where}: the name must be a lowercase letter, then letters and digits, such as "outbound"`);
|
|
108
|
+
}
|
|
109
|
+
if (!isRecord(fields) || !Object.keys(fields).length) {
|
|
110
|
+
throw new Error(`${where}: it needs fields, by name: each one of ${KINDS}`);
|
|
111
|
+
}
|
|
112
|
+
for (const [field, kind] of Object.entries(fields)) checkField(where, field, kind);
|
|
113
|
+
const keyKind: unknown = typeof key === "string" && Object.hasOwn(fields, key) ? fields[key] : undefined;
|
|
114
|
+
if (!(keyKind === "text" || (isRecord(keyKind) && keyKind.type === "text" && keyKind.optional === undefined))) {
|
|
115
|
+
throw new Error(`${where}: the key "${String(key)}" must be one of its fields, a required "text" one`);
|
|
116
|
+
}
|
|
117
|
+
if (title !== undefined && typeof title !== "string") throw new Error(`${where}: the title must be a string`);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Throws unless `kind` is a field `collection()` takes: a type, the type marked optional, or an enum of distinct strings. */
|
|
121
|
+
function checkField(where: string, field: string, kind: unknown): void {
|
|
122
|
+
const refused = (why: string) => new Error(`${where}: the field "${field}" ${why}`);
|
|
123
|
+
if (!FIELD_NAME.test(field)) throw refused("must be named with letters, digits and _, not a digit first");
|
|
124
|
+
const types: readonly unknown[] = FIELD_TYPES;
|
|
125
|
+
const kinds = `is ${JSON.stringify(kind)}; a field is one of ${KINDS}`;
|
|
126
|
+
if (typeof kind === "string") {
|
|
127
|
+
if (!types.includes(kind)) throw refused(kinds);
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
if (!isRecord(kind)) throw refused(kinds);
|
|
131
|
+
const { type, enum: values, optional, ...rest } = kind;
|
|
132
|
+
if (Object.keys(rest).length || (type === undefined) === (values === undefined)) throw refused(kinds);
|
|
133
|
+
if (optional !== undefined && optional !== true) throw refused("may only be marked `optional: true`");
|
|
134
|
+
if (type !== undefined && !types.includes(type)) throw refused(kinds);
|
|
135
|
+
if (
|
|
136
|
+
values !== undefined &&
|
|
137
|
+
(!Array.isArray(values) ||
|
|
138
|
+
!values.length ||
|
|
139
|
+
values.some((v) => typeof v !== "string") ||
|
|
140
|
+
new Set(values).size < values.length)
|
|
141
|
+
) {
|
|
142
|
+
throw refused("needs an enum of distinct strings, at least one");
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* The built-in call workflow's name: one operation that may be called directly (`isDirect`),
|
|
148
|
+
* made a run when the policy holds a direct call for an approval.
|
|
149
|
+
*/
|
|
150
|
+
export const CALL_WORKFLOW = "call";
|
|
151
|
+
|
|
152
|
+
/** The workflows the runtime adds itself, which a config's own may not be named: drift, apply and call. */
|
|
153
|
+
export const BUILT_IN_WORKFLOWS: ReadonlySet<string> = new Set([DRIFT_WORKFLOW, APPLY_WORKFLOW, CALL_WORKFLOW]);
|
|
42
154
|
|
|
43
155
|
/**
|
|
44
156
|
* Every run status, in the order a run may pass through them: waiting on the queue, running,
|
|
@@ -56,12 +168,124 @@ export const ENDED_STATUSES = ["finished", "failed", "cancelled"] as const satis
|
|
|
56
168
|
export const isEnded = (status: RunStatus): boolean => (ENDED_STATUSES as readonly RunStatus[]).includes(status);
|
|
57
169
|
|
|
58
170
|
/**
|
|
59
|
-
*
|
|
60
|
-
*
|
|
171
|
+
* Why the run's own actor may not decide an approval on it: separation of duties (`mayDecide`),
|
|
172
|
+
* while someone else could decide it.
|
|
173
|
+
*/
|
|
174
|
+
export const OWN_CHANGE = "own change";
|
|
175
|
+
|
|
176
|
+
/** A run as `mayDecide` reads it: who started it, its own actor. */
|
|
177
|
+
type DecidedRun = { actor?: Pick<Principal, "id"> | undefined };
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* True when `by` may decide the approval on `run`: `by.id` is the approver, or `by.groups` holds
|
|
181
|
+
* the approver group. The run's own actor is refused only while someone else could decide (separation
|
|
182
|
+
* of duties): the approver is a group with another declared person in `people` (an agent does not
|
|
183
|
+
* count). Pure: everything it reads is passed in.
|
|
184
|
+
*/
|
|
185
|
+
export const mayDecide = (
|
|
186
|
+
approval: Pick<ApprovalState, "approver">,
|
|
187
|
+
by: Principal,
|
|
188
|
+
run: DecidedRun,
|
|
189
|
+
people: readonly PeopleResource[],
|
|
190
|
+
): boolean => refusalOf(approval, by, run, people) === undefined;
|
|
191
|
+
|
|
192
|
+
/** Why `by` may not decide the approval on `run` (`mayDecide`), naming who may, or `OWN_CHANGE`; undefined when they may. */
|
|
193
|
+
export function refusalOf(
|
|
194
|
+
{ approver }: Pick<ApprovalState, "approver">,
|
|
195
|
+
by: Principal,
|
|
196
|
+
run: DecidedRun,
|
|
197
|
+
people: readonly PeopleResource[],
|
|
198
|
+
): string | undefined {
|
|
199
|
+
if (typeof approver === "string") {
|
|
200
|
+
return approver === by.id ? undefined : `${by.id} is not the approver; ${approverLabel(approver)} is`;
|
|
201
|
+
}
|
|
202
|
+
if (!by.groups?.includes(approver.group)) return `${by.id} is not in ${approverLabel(approver)}`;
|
|
203
|
+
if (by.id !== run.actor?.id) return undefined;
|
|
204
|
+
const someoneElse = personsOf(people).some(
|
|
205
|
+
({ id }) => id !== by.id && principalOf(id, people)?.groups?.includes(approver.group),
|
|
206
|
+
);
|
|
207
|
+
return someoneElse ? OWN_CHANGE : undefined;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** A declared resource as the people functions read it: only the runtime's people types count. */
|
|
211
|
+
export type PeopleResource = Pick<DeclaredResource, "vendor" | "type" | "desired">;
|
|
212
|
+
|
|
213
|
+
/** The group every agent is in, the runtime's own among them: no data file declares it. */
|
|
214
|
+
export const AGENTS = "agents";
|
|
215
|
+
|
|
216
|
+
/** The runtime's own agents (scenarios, the host check, scheduled runs), declared whether a data file names them or not. */
|
|
217
|
+
export const RUNTIME_AGENTS: readonly string[] = ["scenarios", "host-check", "schedule"];
|
|
218
|
+
|
|
219
|
+
/** The declared resources of one of the runtime's people types, by `desired`. */
|
|
220
|
+
const peopleOfType = <T>(resources: readonly PeopleResource[], type: "person" | "agent" | "group") =>
|
|
221
|
+
resources.flatMap((r) => (r.vendor === "sanoma" && r.type === type ? [r.desired as T] : []));
|
|
222
|
+
|
|
223
|
+
type PersonFields = { id: string; name?: string; email?: string; github?: string };
|
|
224
|
+
type AgentFields = { id: string; onBehalfOf?: string };
|
|
225
|
+
type GroupFields = { name: string; members: string[] };
|
|
226
|
+
|
|
227
|
+
/** The groups a policy may name and a principal may be in: the declared ones and `agents`, sorted. */
|
|
228
|
+
export const groupNamesOf = (resources: readonly PeopleResource[]): string[] =>
|
|
229
|
+
[...new Set([...peopleOfType<GroupFields>(resources, "group").map((g) => g.name), AGENTS])].toSorted();
|
|
230
|
+
|
|
231
|
+
/** The declared people (`sanoma.person`), in the order declared: not agents, who never sign in. */
|
|
232
|
+
export const personsOf = (resources: readonly PeopleResource[]): PersonFields[] =>
|
|
233
|
+
peopleOfType<PersonFields>(resources, "person");
|
|
234
|
+
|
|
235
|
+
/** The ids of the declared people and agents (not the runtime's own). */
|
|
236
|
+
export const principalIdsOf = (resources: readonly PeopleResource[]): string[] =>
|
|
237
|
+
[...personsOf(resources), ...peopleOfType<AgentFields>(resources, "agent")].map((p) => p.id);
|
|
238
|
+
|
|
239
|
+
/** True when the data files declare a person, agent or group: then only a declared principal is admitted. */
|
|
240
|
+
export const declaresPeople = (resources: readonly PeopleResource[]): boolean =>
|
|
241
|
+
resources.some((r) => r.vendor === "sanoma");
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* The principal a declared person or agent (or one of the runtime's own agents) acts as, read
|
|
245
|
+
* from the data files' resources: its id, every group that lists it (and `agents` for an agent),
|
|
246
|
+
* sorted, and an agent's `onBehalfOf`. Undefined for an id nobody declares.
|
|
61
247
|
*/
|
|
62
|
-
export function
|
|
63
|
-
const
|
|
64
|
-
|
|
248
|
+
export function principalOf(id: string, resources: readonly PeopleResource[]): Principal | undefined {
|
|
249
|
+
const agent = peopleOfType<AgentFields>(resources, "agent").find((a) => a.id === id);
|
|
250
|
+
const isAgent = agent !== undefined || RUNTIME_AGENTS.includes(id);
|
|
251
|
+
if (!isAgent && !peopleOfType<PersonFields>(resources, "person").some((p) => p.id === id)) return undefined;
|
|
252
|
+
const groups = peopleOfType<GroupFields>(resources, "group")
|
|
253
|
+
.filter((g) => g.members.includes(id))
|
|
254
|
+
.map((g) => g.name);
|
|
255
|
+
return {
|
|
256
|
+
id,
|
|
257
|
+
groups: [...new Set(isAgent ? [...groups, AGENTS] : groups)].toSorted(),
|
|
258
|
+
...(agent?.onBehalfOf !== undefined && { onBehalfOf: agent.onBehalfOf }),
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Who `name` is as an approver: a declared group's name (or `agents`) as `{ group }`, else a
|
|
264
|
+
* declared person's or agent's id; undefined when it is neither.
|
|
265
|
+
*/
|
|
266
|
+
export function approverOf(name: string, resources: readonly PeopleResource[]): Approver | undefined {
|
|
267
|
+
if (groupNamesOf(resources).includes(name)) return { group: name };
|
|
268
|
+
return principalOf(name, resources) && name;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** True for two strings the same but for case: an email or a GitHub login. */
|
|
272
|
+
const same = (a: string | undefined, b: string | undefined) =>
|
|
273
|
+
a !== undefined && b !== undefined && a.toLowerCase() === b.toLowerCase();
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* The id of the declared person a sign-in vouches for, or undefined: by email first, then by
|
|
277
|
+
* GitHub login (each ignoring case), then by id. Never an agent's: agents do not sign in.
|
|
278
|
+
*/
|
|
279
|
+
export function personOf(
|
|
280
|
+
login: { email?: string; github?: string; id?: string },
|
|
281
|
+
resources: readonly PeopleResource[],
|
|
282
|
+
): string | undefined {
|
|
283
|
+
const people = personsOf(resources);
|
|
284
|
+
return (
|
|
285
|
+
people.find((p) => same(p.email, login.email)) ??
|
|
286
|
+
people.find((p) => same(p.github, login.github)) ??
|
|
287
|
+
people.find((p) => login.id !== undefined && p.id === login.id)
|
|
288
|
+
)?.id;
|
|
65
289
|
}
|
|
66
290
|
|
|
67
291
|
/** Who may decide, as text: the person's id, or "group <name>". */
|
|
@@ -124,3 +348,42 @@ export const isLoopback = (host: string) =>
|
|
|
124
348
|
/** Whether a driver's environment variable lets it run: set, or unset and optional. */
|
|
125
349
|
export const credentialReady = ({ status, optional }: CredentialStatus): boolean =>
|
|
126
350
|
status === "set" || (status === "missing" && optional);
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* A workflow's name in a site's `api`: kebab-case made camelCase, `outbound-next` as
|
|
354
|
+
* `api.outboundNext`. `describeConfig` reports two workflows that come out the same, or one that
|
|
355
|
+
* comes out as a vendor's id or `data`, as a problem on the workflow.
|
|
356
|
+
*/
|
|
357
|
+
export const apiNameOf = (name: string): string => name.replace(/-([a-z0-9])/g, (_, c: string) => c.toUpperCase());
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* A workflow as a site's `api` has it (`api.<apiNameOf(name)>`), started as the person on the
|
|
361
|
+
* page. Called, it starts a run and resolves with its output once the run ends, or rejects with
|
|
362
|
+
* its error; `start` gives the run's id at once, and the rest read runs of this workflow.
|
|
363
|
+
*/
|
|
364
|
+
export interface WorkflowFn<I, O> {
|
|
365
|
+
(input: I): Promise<O>;
|
|
366
|
+
/** Starts a run with this input, and answers with its id. */
|
|
367
|
+
start(input: I): Promise<{ runId: string }>;
|
|
368
|
+
/** The run's output once it ends, or its error; `run_running` when it has not ended within `waitMs` (at most 20 s). */
|
|
369
|
+
result(runId: string, waitMs?: number): Promise<O>;
|
|
370
|
+
/** The newest finished run's output, and when it finished (ms since the epoch); null when none has. */
|
|
371
|
+
latest(): Promise<{ runId: string; output: O; at: number } | null>;
|
|
372
|
+
/** Its latest runs, newest first. */
|
|
373
|
+
runs(query?: { limit?: number }): Promise<RunSummary[]>;
|
|
374
|
+
/** The run's approvals, decided and waiting. */
|
|
375
|
+
approvals(runId: string): Promise<ApprovalState[]>;
|
|
376
|
+
/** The run's ledger records, in order. */
|
|
377
|
+
ledger(runId: string): Promise<LedgerRecord[]>;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/** An operation a site may call directly (a read, or a collection's write), as the person on the page. */
|
|
381
|
+
export type OpFn<I, O> = (input: I) => Promise<O>;
|
|
382
|
+
|
|
383
|
+
/** A collection as a site's `api.data.<name>` has it: its four operations (`Saved`, `ListQuery`). */
|
|
384
|
+
export interface CollectionApi<R> {
|
|
385
|
+
get: OpFn<{ key: string }, Saved<R> | null>;
|
|
386
|
+
list(query?: ListQuery<R>): Promise<Saved<R>[]>;
|
|
387
|
+
save: OpFn<{ record: R }, Saved<R>>;
|
|
388
|
+
delete: OpFn<{ key: string }, { deleted: boolean }>;
|
|
389
|
+
}
|