@sanoma-ai/workflows 0.1.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 +186 -76
- package/dist/apply-steps.d.ts +6 -0
- package/dist/apply-steps.js +93 -0
- package/dist/apply-steps.js.map +1 -0
- package/dist/apply.d.ts +197 -0
- package/dist/apply.js +470 -0
- package/dist/apply.js.map +1 -0
- 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 +35 -2
- package/dist/call.js +123 -43
- 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 +123 -9
- package/dist/client.js +355 -34
- 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 +109 -28
- 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/credentials.d.ts +25 -0
- package/dist/credentials.js +73 -5
- package/dist/credentials.js.map +1 -1
- 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 +38 -3
- package/dist/datafile.js +166 -13
- 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 +54 -8
- package/dist/describe.js +84 -11
- 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/drift.d.ts +64 -12
- package/dist/drift.js +118 -41
- package/dist/drift.js.map +1 -1
- 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 +4 -4
- package/dist/expectations.js +43 -9
- package/dist/expectations.js.map +1 -1
- package/dist/host.d.ts +56 -0
- package/dist/host.js +45 -0
- package/dist/host.js.map +1 -0
- package/dist/index.d.ts +13 -5
- package/dist/index.js +15 -5
- 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/lockfile.d.ts +36 -0
- package/dist/lockfile.js +69 -0
- package/dist/lockfile.js.map +1 -0
- package/dist/op.d.ts +9 -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 +207 -25
- package/dist/resource.js +184 -30
- package/dist/resource.js.map +1 -1
- package/dist/resources.d.ts +76 -9
- package/dist/resources.js +170 -30
- package/dist/resources.js.map +1 -1
- package/dist/run.d.ts +31 -1
- package/dist/run.js +6 -1
- package/dist/run.js.map +1 -1
- package/dist/sandbox.js +5 -1
- package/dist/sandbox.js.map +1 -1
- package/dist/scenario.d.ts +25 -9
- package/dist/scenario.js +125 -27
- 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/secret.d.ts +42 -0
- package/dist/secret.js +58 -0
- package/dist/secret.js.map +1 -0
- package/dist/shared.d.ts +188 -4
- package/dist/shared.js +165 -5
- 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/source.d.ts +58 -0
- package/dist/source.js +54 -0
- package/dist/source.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 +6 -2
- package/dist/worker.js +37 -8
- package/dist/worker.js.map +1 -1
- package/dist/writer.d.ts +35 -0
- package/dist/writer.js +201 -0
- package/dist/writer.js.map +1 -0
- package/oxlint.json +5 -16
- package/package.json +11 -1
- package/src/apply-steps.ts +110 -0
- package/src/apply.ts +626 -0
- package/src/approvals.ts +49 -13
- package/src/call-workflow.ts +44 -0
- package/src/call.ts +173 -46
- package/src/cedar.ts +493 -0
- package/src/client.ts +450 -49
- package/src/collection.ts +431 -0
- package/src/config.ts +168 -48
- package/src/constructors.ts +4 -0
- package/src/credentials.ts +90 -5
- package/src/data.ts +10 -0
- package/src/datafile.ts +207 -15
- package/src/db.ts +90 -22
- package/src/define.ts +11 -9
- package/src/describe.ts +146 -23
- package/src/direct.ts +15 -0
- package/src/drift.ts +155 -45
- package/src/errors.ts +36 -1
- package/src/expectations.ts +46 -11
- package/src/host.ts +96 -0
- package/src/index.ts +56 -2
- package/src/ledger.ts +35 -17
- package/src/lint.ts +29 -12
- package/src/lockfile.ts +95 -0
- package/src/op.ts +9 -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 +302 -52
- package/src/resources.ts +226 -30
- package/src/run.ts +33 -1
- package/src/sandbox.ts +5 -1
- package/src/scenario.ts +175 -43
- package/src/scenarios.ts +15 -8
- package/src/schema.ts +261 -0
- package/src/sdk.ts +211 -0
- package/src/secret.ts +92 -0
- package/src/shared.ts +279 -6
- package/src/sites.ts +220 -0
- package/src/source.ts +82 -0
- package/src/version.ts +3 -2
- package/src/worker.ts +45 -11
- package/src/writer.ts +225 -0
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
|
+
}
|
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/secret.ts
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { DriverError } from "./op.ts";
|
|
3
|
+
|
|
4
|
+
// Secrets are named, never written: `secret("NAME")` in a data file is a marker for a value in
|
|
5
|
+
// the company's secrets store (the credentials table), allowed only at a write-only field. The
|
|
6
|
+
// marker is what declarations, checkpoints and the ledger hold; a driver resolves it
|
|
7
|
+
// (`resolveSecrets`) just before the vendor call. The `./resources` entry, which data files import
|
|
8
|
+
// `secret` from (`sanoma/resources` re-exports it).
|
|
9
|
+
|
|
10
|
+
/** What `secret()` makes: a plain object, so it reads the same as JSON and across copies of this package. */
|
|
11
|
+
export interface Secret {
|
|
12
|
+
readonly $secret: string;
|
|
13
|
+
/** When none is stored, apply makes the value (`generateSecret`) and stores it before the vendor call. */
|
|
14
|
+
readonly generate?: true;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** A secret's name: an environment variable's, upper case. */
|
|
18
|
+
export const SECRET_NAME = /^[A-Z][A-Z0-9_]*$/;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* True for one of sanoma's own secrets (`SANOMA_…`, its key and database URLs among them): only a
|
|
22
|
+
* host, which runs the company, is given them, at its `secrets` input (`HostSpec`); never a vendor.
|
|
23
|
+
*/
|
|
24
|
+
export const isSanomaSecret = (name: string) => name.startsWith("SANOMA_");
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Names a stored secret for a write-only field of a data file: `secret("SUPABASE_DB_PASSWORD",
|
|
28
|
+
* { generate: true })`. With `generate`, apply makes the value when none is stored.
|
|
29
|
+
*/
|
|
30
|
+
export function secret(name: string, options?: { generate: true }): Secret {
|
|
31
|
+
if (!SECRET_NAME.test(name)) {
|
|
32
|
+
throw new Error(`secret("${name}"): a secret's name is an environment variable's, upper case: A-Z, 0-9 and _`);
|
|
33
|
+
}
|
|
34
|
+
return Object.freeze(options?.generate ? { $secret: name, generate: true as const } : { $secret: name });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** True for a secret marker, `{ $secret: "NAME" }`. */
|
|
38
|
+
export const isSecret = (value: unknown): value is Secret =>
|
|
39
|
+
typeof value === "object" && value !== null && typeof (value as { $secret?: unknown }).$secret === "string";
|
|
40
|
+
|
|
41
|
+
/** The secrets store, as a driver sees it (`CallContext.secrets`). */
|
|
42
|
+
export interface Secrets {
|
|
43
|
+
/** The stored value, or undefined when there is none. */
|
|
44
|
+
get(name: string): Promise<string | undefined>;
|
|
45
|
+
/** The stored value; when there is none, `make()`'s, stored first. */
|
|
46
|
+
ensure(name: string, make: () => string): Promise<string>;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** A value for a secret with `generate`: 32 random bytes, base64url. */
|
|
50
|
+
export const generateSecret = (): string => randomBytes(32).toString("base64url");
|
|
51
|
+
|
|
52
|
+
/** `T` with each secret marker's place holding its value: what `resolveSecrets` returns. */
|
|
53
|
+
export type Resolved<T> = T extends Secret
|
|
54
|
+
? string
|
|
55
|
+
: T extends readonly (infer I)[]
|
|
56
|
+
? Resolved<I>[]
|
|
57
|
+
: T extends object
|
|
58
|
+
? { [K in keyof T]: Resolved<T[K]> }
|
|
59
|
+
: T;
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* `value` with each secret marker in it replaced by the stored value: generated and stored first
|
|
63
|
+
* for one with `generate` and none stored. A marker naming nothing stored fails the call, for good.
|
|
64
|
+
*/
|
|
65
|
+
export async function resolveSecrets<T>(value: T, secrets: Secrets): Promise<Resolved<T>> {
|
|
66
|
+
const resolve = async (v: unknown): Promise<unknown> => {
|
|
67
|
+
if (isSecret(v)) {
|
|
68
|
+
const stored = v.generate ? await secrets.ensure(v.$secret, generateSecret) : await secrets.get(v.$secret);
|
|
69
|
+
if (stored === undefined) {
|
|
70
|
+
throw new DriverError(`The secret ${v.$secret} is not set: store it before applying`, { retryable: false });
|
|
71
|
+
}
|
|
72
|
+
return stored;
|
|
73
|
+
}
|
|
74
|
+
if (Array.isArray(v)) return Promise.all(v.map(resolve));
|
|
75
|
+
if (typeof v !== "object" || v === null) return v;
|
|
76
|
+
const entries = await Promise.all(Object.entries(v).map(async ([k, item]) => [k, await resolve(item)] as const));
|
|
77
|
+
return Object.fromEntries(entries);
|
|
78
|
+
};
|
|
79
|
+
return (await resolve(value)) as Resolved<T>;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** A secrets store in memory, for fakes, sandbox runs and tests. */
|
|
83
|
+
export function memorySecrets(initial: Record<string, string> = {}): Secrets {
|
|
84
|
+
const values = new Map(Object.entries(initial));
|
|
85
|
+
return {
|
|
86
|
+
get: async (name) => values.get(name),
|
|
87
|
+
async ensure(name, make) {
|
|
88
|
+
if (!values.has(name)) values.set(name, make());
|
|
89
|
+
return values.get(name)!;
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
}
|