@sanoma-ai/workflows 0.9.1 → 0.10.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +193 -50
- package/SECRET-SUPPORT.md +33 -0
- package/dist/apply-steps.js +5 -2
- package/dist/apply-steps.js.map +1 -1
- package/dist/apply.d.ts +3 -0
- package/dist/apply.js +27 -13
- package/dist/apply.js.map +1 -1
- package/dist/approvals.d.ts +1 -0
- package/dist/approvals.js +6 -1
- package/dist/approvals.js.map +1 -1
- package/dist/apps.d.ts +13 -24
- package/dist/apps.js +69 -68
- package/dist/apps.js.map +1 -1
- package/dist/ast.d.ts +9 -7
- package/dist/ast.js +2 -0
- package/dist/ast.js.map +1 -1
- package/dist/authentication.d.ts +54 -0
- package/dist/authentication.js +138 -0
- package/dist/authentication.js.map +1 -0
- package/dist/call-workflow.d.ts +5 -0
- package/dist/call-workflow.js +4 -3
- package/dist/call-workflow.js.map +1 -1
- package/dist/call.d.ts +3 -2
- package/dist/call.js +36 -7
- package/dist/call.js.map +1 -1
- package/dist/cedar.js +14 -1
- package/dist/cedar.js.map +1 -1
- package/dist/change-steps.js +7 -5
- package/dist/change-steps.js.map +1 -1
- package/dist/change.d.ts +7 -0
- package/dist/change.js +7 -0
- package/dist/change.js.map +1 -1
- package/dist/client.d.ts +62 -10
- package/dist/client.js +272 -47
- package/dist/client.js.map +1 -1
- package/dist/collection.d.ts +7 -0
- package/dist/collection.js +5 -2
- package/dist/collection.js.map +1 -1
- package/dist/config.d.ts +16 -4
- package/dist/config.js +89 -21
- package/dist/config.js.map +1 -1
- package/dist/connection.d.ts +389 -0
- package/dist/connection.js +657 -0
- package/dist/connection.js.map +1 -0
- package/dist/credentials.d.ts +7 -53
- package/dist/credentials.js +84 -247
- package/dist/credentials.js.map +1 -1
- package/dist/db.js +34 -2
- package/dist/db.js.map +1 -1
- package/dist/define.d.ts +9 -2
- package/dist/define.js.map +1 -1
- package/dist/definitions.d.ts +20 -0
- package/dist/definitions.js +16 -0
- package/dist/definitions.js.map +1 -0
- package/dist/deployment-key.d.ts +2 -0
- package/dist/deployment-key.js +22 -0
- package/dist/deployment-key.js.map +1 -0
- package/dist/describe.d.ts +15 -175
- package/dist/describe.js +122 -35
- package/dist/describe.js.map +1 -1
- package/dist/description.d.ts +532 -0
- package/dist/description.js +381 -0
- package/dist/description.js.map +1 -0
- package/dist/direct.js +7 -0
- package/dist/direct.js.map +1 -1
- package/dist/drift.d.ts +1 -1
- package/dist/drift.js +1 -1
- package/dist/drift.js.map +1 -1
- package/dist/env.d.ts +2 -0
- package/dist/env.js +2 -2
- package/dist/env.js.map +1 -1
- package/dist/fake.d.ts +13 -1
- package/dist/fake.js +70 -0
- package/dist/fake.js.map +1 -1
- package/dist/index.d.ts +11 -3
- package/dist/index.js +10 -2
- package/dist/index.js.map +1 -1
- package/dist/ledger.d.ts +2 -0
- package/dist/ledger.js +1 -1
- package/dist/ledger.js.map +1 -1
- package/dist/lint.d.ts +0 -9
- package/dist/lint.js +2 -17
- package/dist/lint.js.map +1 -1
- package/dist/load.d.ts +85 -0
- package/dist/load.js +391 -0
- package/dist/load.js.map +1 -0
- package/dist/lockfile.d.ts +2 -0
- package/dist/lockfile.js +8 -3
- package/dist/lockfile.js.map +1 -1
- package/dist/op.d.ts +22 -5
- package/dist/op.js +20 -2
- package/dist/op.js.map +1 -1
- package/dist/outline.d.ts +5 -0
- package/dist/outline.js +46 -5
- package/dist/outline.js.map +1 -1
- package/dist/paths.d.ts +18 -2
- package/dist/paths.js +19 -7
- package/dist/paths.js.map +1 -1
- package/dist/plugin.d.ts +0 -11
- package/dist/plugin.js +3 -18
- package/dist/plugin.js.map +1 -1
- package/dist/policy.d.ts +2 -0
- package/dist/policy.js.map +1 -1
- package/dist/resource.d.ts +10 -4
- package/dist/resource.js +11 -4
- package/dist/resource.js.map +1 -1
- package/dist/resources.d.ts +33 -76
- package/dist/resources.js +219 -227
- package/dist/resources.js.map +1 -1
- package/dist/run.d.ts +4 -0
- package/dist/run.js.map +1 -1
- package/dist/sandbox.d.ts +5 -2
- package/dist/sandbox.js +56 -7
- package/dist/sandbox.js.map +1 -1
- package/dist/scenario.d.ts +5 -0
- package/dist/scenario.js +3 -2
- package/dist/scenario.js.map +1 -1
- package/dist/scenarios.js +7 -1
- package/dist/scenarios.js.map +1 -1
- package/dist/schema.js +8 -0
- package/dist/schema.js.map +1 -1
- package/dist/secret-store-check.d.ts +11 -0
- package/dist/secret-store-check.js +76 -0
- package/dist/secret-store-check.js.map +1 -0
- package/dist/secret-store.d.ts +106 -0
- package/dist/secret-store.js +228 -0
- package/dist/secret-store.js.map +1 -0
- package/dist/secret.d.ts +2 -0
- package/dist/secret.js +32 -0
- package/dist/secret.js.map +1 -1
- package/dist/shared.d.ts +21 -11
- package/dist/shared.js +41 -7
- package/dist/shared.js.map +1 -1
- package/dist/sites.d.ts +4 -93
- package/dist/sites.js +76 -84
- package/dist/sites.js.map +1 -1
- package/dist/steps.d.ts +3 -0
- package/dist/steps.js.map +1 -1
- package/dist/version.d.ts +2 -1
- package/dist/version.js +2 -1
- package/dist/version.js.map +1 -1
- package/dist/wakeup-driver.js +15 -6
- package/dist/wakeup-driver.js.map +1 -1
- package/dist/worker.js +12 -8
- package/dist/worker.js.map +1 -1
- package/dist/writer.d.ts +53 -14
- package/dist/writer.js +113 -43
- package/dist/writer.js.map +1 -1
- package/oxlint.json +1 -5
- package/package.json +6 -1
- package/src/apply-steps.ts +5 -2
- package/src/apply.ts +54 -21
- package/src/approvals.ts +7 -1
- package/src/apps.ts +71 -86
- package/src/ast.ts +6 -6
- package/src/authentication.ts +191 -0
- package/src/call-workflow.ts +5 -4
- package/src/call.ts +52 -9
- package/src/cedar.ts +14 -1
- package/src/change-steps.ts +7 -4
- package/src/change.ts +10 -0
- package/src/client.ts +346 -45
- package/src/collection.ts +10 -2
- package/src/config.ts +107 -19
- package/src/connection.ts +912 -0
- package/src/credentials.ts +94 -281
- package/src/db.ts +34 -2
- package/src/define.ts +7 -2
- package/src/definitions.ts +31 -0
- package/src/deployment-key.ts +23 -0
- package/src/describe.ts +150 -227
- package/src/description.ts +426 -0
- package/src/direct.ts +7 -0
- package/src/drift.ts +10 -2
- package/src/env.ts +4 -2
- package/src/fake.ts +71 -0
- package/src/index.ts +53 -1
- package/src/ledger.ts +3 -1
- package/src/lint.ts +2 -17
- package/src/load.ts +466 -0
- package/src/lockfile.ts +10 -3
- package/src/op.ts +41 -7
- package/src/outline.ts +53 -7
- package/src/paths.ts +21 -6
- package/src/plugin.ts +3 -24
- package/src/policy.ts +2 -0
- package/src/resource.ts +24 -6
- package/src/resources.ts +246 -301
- package/src/run.ts +4 -0
- package/src/sandbox.ts +61 -7
- package/src/scenario.ts +24 -6
- package/src/scenarios.ts +7 -1
- package/src/schema.ts +10 -0
- package/src/secret-store-check.ts +93 -0
- package/src/secret-store.ts +314 -0
- package/src/secret.ts +29 -0
- package/src/shared.ts +44 -10
- package/src/sites.ts +85 -179
- package/src/steps.ts +3 -2
- package/src/version.ts +2 -1
- package/src/wakeup-driver.ts +22 -6
- package/src/worker.ts +12 -8
- package/src/writer.ts +145 -56
- package/dist/datafile.d.ts +0 -97
- package/dist/datafile.js +0 -479
- package/dist/datafile.js.map +0 -1
- package/src/datafile.ts +0 -601
package/src/apps.ts
CHANGED
|
@@ -1,118 +1,103 @@
|
|
|
1
1
|
import { existsSync, readdirSync } from "node:fs";
|
|
2
|
-
import { dirname, join,
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
2
|
+
import { dirname, join, resolve } from "node:path";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { childrenOf, type Node, readSource } from "./ast.ts";
|
|
5
|
+
import type { AppEntry } from "./description.ts";
|
|
6
|
+
import { exportsOf, type Loaded, loadModules } from "./load.ts";
|
|
7
|
+
import { stringValue } from "./outline.ts";
|
|
8
|
+
import { APPS_DIR, filesUnder, inside, isDirectory, RELATIVE, relPath } from "./paths.ts";
|
|
9
|
+
import type { ResourceProblem } from "./resources.ts";
|
|
10
|
+
import { APP_NAME, problemLine } from "./shared.ts";
|
|
8
11
|
|
|
9
12
|
// A company's apps: each folder of `apps/` holds the kinds the root does (workflows, resources,
|
|
10
13
|
// policies, scenarios, sites), merged from a source repository whose URL its `app.ts` names, or
|
|
11
14
|
// made here. What a folder declares is named with the folder (`qualified`): never written, read
|
|
12
15
|
// from where the declaration's file is (`appOfFile`).
|
|
13
16
|
|
|
14
|
-
|
|
15
|
-
export interface AppEntry {
|
|
16
|
-
/** The folder's name, which prefixes what it declares (`crm.contacts`) and is the app's title. */
|
|
17
|
-
name: string;
|
|
18
|
-
/** The folder, relative to the root, with `/`: `apps/crm`. */
|
|
19
|
-
dir: string;
|
|
20
|
-
/** `defineApp({ source })` in its `app.ts`: the repository it was taken from; absent for the company's own app. */
|
|
21
|
-
source?: string;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
/** What `app.ts` declares: the source repository's URL, when the app was taken from one. */
|
|
25
|
-
export interface AppDefinition {
|
|
26
|
-
source?: string;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
/**
|
|
30
|
-
* Declares an app in its folder's `app.ts`, read without running it: `defineApp({ source:
|
|
31
|
-
* "https://github.com/acme/crm" })`, the repository it was taken from; `defineApp({})` for an app
|
|
32
|
-
* the company made itself.
|
|
33
|
-
*/
|
|
34
|
-
export function defineApp(definition: AppDefinition): AppDefinition {
|
|
35
|
-
return Object.freeze({ ...definition });
|
|
36
|
-
}
|
|
17
|
+
export type { AppEntry };
|
|
37
18
|
|
|
38
19
|
/** What is wrong with a company's apps, at a file relative to the root. */
|
|
39
|
-
export type AppProblem =
|
|
20
|
+
export type AppProblem = ResourceProblem & { file: string };
|
|
40
21
|
|
|
41
22
|
/** The apps under `root` (`appsAt`), without what is wrong with them. */
|
|
42
23
|
export const readApps = (root: string | undefined): AppEntry[] => appsAt(root).apps;
|
|
43
24
|
|
|
44
25
|
/**
|
|
45
26
|
* The apps under `root`: each folder of `apps/` whose name is an `APP_NAME`, sorted, with what is
|
|
46
|
-
* wrong in its `app.ts` and each folder refused for its name as `problems`. `app.ts` is
|
|
47
|
-
*
|
|
48
|
-
*
|
|
27
|
+
* wrong in its `app.ts` and each folder refused for its name as `problems`. Each `app.ts` is
|
|
28
|
+
* loaded (`loadModules`, `defineApp` served from `sanoma`): its default export is
|
|
29
|
+
* `defineApp({ source })`, `source` a string when given.
|
|
49
30
|
*/
|
|
50
31
|
export function appsAt(root: string | undefined): { apps: AppEntry[]; problems: AppProblem[] } {
|
|
51
|
-
|
|
52
|
-
|
|
32
|
+
if (root === undefined) return { apps: [], problems: [] };
|
|
33
|
+
const dir = join(root, APPS_DIR);
|
|
34
|
+
if (!isDirectory(dir)) return { apps: [], problems: [] };
|
|
53
35
|
const apps: AppEntry[] = [];
|
|
54
36
|
const problems: AppProblem[] = [];
|
|
55
37
|
const folders = readdirSync(dir, { withFileTypes: true })
|
|
56
38
|
.filter((d) => d.isDirectory())
|
|
57
39
|
.map((d) => d.name)
|
|
58
40
|
.toSorted();
|
|
59
|
-
|
|
41
|
+
const named = folders.filter((folder) => APP_NAME.test(folder));
|
|
42
|
+
for (const name of folders.filter((folder) => !APP_NAME.test(folder))) {
|
|
60
43
|
const at = `${APPS_DIR}/${name}`;
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
});
|
|
68
|
-
continue;
|
|
69
|
-
}
|
|
70
|
-
const read = appOptionsOf(join(dir, name, "app.ts"));
|
|
71
|
-
problems.push(...read.problems.map((p) => ({ file: `${at}/app.ts`, ...p })));
|
|
72
|
-
apps.push({ name, dir: at, ...(read.source !== undefined && { source: read.source }) });
|
|
44
|
+
problems.push({
|
|
45
|
+
file: at,
|
|
46
|
+
line: 1,
|
|
47
|
+
column: 1,
|
|
48
|
+
message: `${at} is not an app: an app's folder name is a lowercase letter, then lowercase letters and digits`,
|
|
49
|
+
});
|
|
73
50
|
}
|
|
51
|
+
const files = named.map((name) => join(dir, name, "app.ts")).filter((path) => existsSync(path));
|
|
52
|
+
const loaded = files.length ? loadModules(root, files, []) : new Map<string, Loaded>();
|
|
53
|
+
for (const name of named) {
|
|
54
|
+
const file = `${APPS_DIR}/${name}/app.ts`;
|
|
55
|
+
const read = appDefinitionOf(file, loaded);
|
|
56
|
+
problems.push(...read.problems);
|
|
57
|
+
apps.push({
|
|
58
|
+
name,
|
|
59
|
+
dir: `${APPS_DIR}/${name}`,
|
|
60
|
+
...(read.source !== undefined && { source: read.source }),
|
|
61
|
+
...(loaded.has(file) && { at: { file, export: "default" } }),
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
problems.sort((a, b) => a.file.localeCompare(b.file));
|
|
74
65
|
return { apps, problems };
|
|
75
66
|
}
|
|
76
67
|
|
|
77
|
-
/**
|
|
78
|
-
|
|
68
|
+
/**
|
|
69
|
+
* The source URL the app `folder` under `root` is taken from, as its `app.ts` names it; an error
|
|
70
|
+
* saying why there is none: no such app, its `app.ts` does not load or is not `defineApp({ source })`
|
|
71
|
+
* (what is wrong, where), or it names none (the company's own app).
|
|
72
|
+
*/
|
|
73
|
+
export function appSourceOf(root: string, folder: string): string {
|
|
74
|
+
if (!APP_NAME.test(folder) || !isDirectory(join(root, APPS_DIR, folder)))
|
|
75
|
+
throw new Error(`There is no app ${APPS_DIR}/${folder}/`);
|
|
76
|
+
const file = `${APPS_DIR}/${folder}/app.ts`;
|
|
77
|
+
const path = join(root, file);
|
|
78
|
+
const read = existsSync(path) ? appDefinitionOf(file, loadModules(root, [path], [])) : { problems: [] };
|
|
79
|
+
if (read.problems.length) throw new Error(read.problems.map(problemLine).join("; "));
|
|
80
|
+
if (read.source === undefined) throw new Error(`${file} names no source: the app is the company's own`);
|
|
81
|
+
return read.source;
|
|
82
|
+
}
|
|
79
83
|
|
|
80
|
-
/** `app.ts`'s
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
const
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
const
|
|
90
|
-
const
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
if (statement.type !== "ImportDeclaration" || !APP_SOURCES.has(statement.source.value)) continue;
|
|
96
|
-
for (const s of statement.specifiers) {
|
|
97
|
-
if (s.type === "ImportSpecifier" && keyName(s.imported) === "defineApp") defineApps.add(s.local.name);
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
const written =
|
|
101
|
-
call?.type === "CallExpression" &&
|
|
102
|
-
callee?.type === "Identifier" &&
|
|
103
|
-
defineApps.has(callee.name) &&
|
|
104
|
-
(options === undefined || options.type === "ObjectExpression");
|
|
105
|
-
if (!written) {
|
|
106
|
-
problem(exported ?? file.program, unread);
|
|
107
|
-
return { problems };
|
|
84
|
+
/** What `app.ts`'s default export, `defineApp({ … })`, may say. */
|
|
85
|
+
const AppDefinitionSchema = z.strictObject({ source: z.string().optional() });
|
|
86
|
+
|
|
87
|
+
/** `app.ts`'s `source`, as loading it gave, and what is wrong there; nothing for no file. */
|
|
88
|
+
function appDefinitionOf(file: string, answer: Map<string, Loaded>): { source?: string; problems: AppProblem[] } {
|
|
89
|
+
// A helper it imports threw: said at the helper.
|
|
90
|
+
const loaded = exportsOf(answer, file);
|
|
91
|
+
if (loaded === undefined) return { problems: [] };
|
|
92
|
+
if ("problem" in loaded) return { problems: [loaded.problem] };
|
|
93
|
+
const value = loaded.exports.find(([name]) => name === "default")?.[1];
|
|
94
|
+
const parsed = AppDefinitionSchema.safeParse(value);
|
|
95
|
+
if (!parsed.success) {
|
|
96
|
+
const why = z.prettifyError(parsed.error).replaceAll(/\s*\n\s*/g, " ");
|
|
97
|
+
const message = `its default export is \`defineApp({ source })\` from "sanoma", \`source\` a string: ${why}`;
|
|
98
|
+
return { problems: [{ file, export: "default", message }] };
|
|
108
99
|
}
|
|
109
|
-
|
|
110
|
-
(p: Node) => p.type === "Property" && !p.computed && keyName(p.key) === "source",
|
|
111
|
-
);
|
|
112
|
-
const source = property && stringValue(property.value);
|
|
113
|
-
if (property && source === undefined)
|
|
114
|
-
problem(property, "`source` is read without running app.ts: write it as a string");
|
|
115
|
-
return { ...(source !== undefined && { source }), problems };
|
|
100
|
+
return { ...(parsed.data.source !== undefined && { source: parsed.data.source }), problems: [] };
|
|
116
101
|
}
|
|
117
102
|
|
|
118
103
|
/** A source file under an app: `.ts` or `.tsx`, but a declaration. */
|
|
@@ -131,7 +116,7 @@ export function crossAppImports(root: string | undefined, apps: readonly AppEntr
|
|
|
131
116
|
for (const path of filesUnder(own, isSource)) {
|
|
132
117
|
const file = readSource(path);
|
|
133
118
|
if (typeof file === "string") continue;
|
|
134
|
-
const rel =
|
|
119
|
+
const rel = relPath(root, path);
|
|
135
120
|
const visit = (node: Node) => {
|
|
136
121
|
if (!node) return;
|
|
137
122
|
const specifier =
|
package/src/ast.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
2
|
import { parseSync, visitorKeys } from "oxc-parser";
|
|
3
|
+
import type { LintProblem } from "./description.ts";
|
|
3
4
|
import { errorMessage } from "./shared.ts";
|
|
4
5
|
|
|
5
6
|
// What the lints, the outline and the data-file reader share: one way to parse a file, walk it
|
|
@@ -30,6 +31,10 @@ const CODE_KEYS = new Map(
|
|
|
30
31
|
Object.entries(visitorKeys).map(([type, keys]) => [type, keys.filter((key) => !TYPE_KEYS.has(key))]),
|
|
31
32
|
);
|
|
32
33
|
|
|
34
|
+
/** A property's name: an identifier's, or a literal's as a string; undefined for any other key. */
|
|
35
|
+
export const keyName = (key: Node): string | undefined =>
|
|
36
|
+
key.type === "Identifier" ? key.name : key.type === "Literal" ? String(key.value) : undefined;
|
|
37
|
+
|
|
33
38
|
/** A node's children in source order, by oxc-parser's `visitorKeys`, without its types. A hole in a list is `null`. */
|
|
34
39
|
export const childrenOf = (node: Node): Node[] => (CODE_KEYS.get(node.type) ?? []).flatMap((key) => node[key] ?? []);
|
|
35
40
|
|
|
@@ -68,12 +73,7 @@ export function offsetOf(lineStarts: readonly number[], line: number, column: nu
|
|
|
68
73
|
return offset < next - 1 ? offset : undefined;
|
|
69
74
|
}
|
|
70
75
|
|
|
71
|
-
|
|
72
|
-
export interface LintProblem {
|
|
73
|
-
line: number;
|
|
74
|
-
column: number;
|
|
75
|
-
message: string;
|
|
76
|
-
}
|
|
76
|
+
export type { LintProblem };
|
|
77
77
|
|
|
78
78
|
/** A file parsed, its syntax errors as problems, and offsets as lines and columns from 1. */
|
|
79
79
|
export function parsed(source: string, filename: string) {
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import type { z } from "zod";
|
|
2
|
+
import type { CallContext } from "./op.ts";
|
|
3
|
+
import { SecretStoreError, type SecretStoreCapabilities } from "./secret-store.ts";
|
|
4
|
+
|
|
5
|
+
/** Facts verified with the provider, not account claims supplied by the caller. */
|
|
6
|
+
export interface AuthenticationIdentity {
|
|
7
|
+
account: string;
|
|
8
|
+
scopes: readonly string[];
|
|
9
|
+
/** Unix time in milliseconds. Missing means the provider gives no expiry. */
|
|
10
|
+
expiresAt?: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export type AuthenticationProblem =
|
|
14
|
+
| "missing"
|
|
15
|
+
| "expired"
|
|
16
|
+
| "revoked"
|
|
17
|
+
| "rejected"
|
|
18
|
+
| "wrong_account"
|
|
19
|
+
| "scope_changed"
|
|
20
|
+
| "uncertain"
|
|
21
|
+
| "unavailable"
|
|
22
|
+
| "unsupported";
|
|
23
|
+
|
|
24
|
+
/** Safe lifecycle information only: never a provider response, cause or credential. */
|
|
25
|
+
export class AuthenticationError extends Error {
|
|
26
|
+
readonly code = "authentication_failed";
|
|
27
|
+
readonly retryable: boolean;
|
|
28
|
+
readonly problem: AuthenticationProblem;
|
|
29
|
+
constructor(problem: AuthenticationProblem) {
|
|
30
|
+
super(`Authentication: ${problem}`);
|
|
31
|
+
this.problem = problem;
|
|
32
|
+
this.name = "AuthenticationError";
|
|
33
|
+
this.retryable = problem === "unavailable";
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const authenticationProblems = new Set<AuthenticationProblem>([
|
|
38
|
+
"missing",
|
|
39
|
+
"expired",
|
|
40
|
+
"revoked",
|
|
41
|
+
"rejected",
|
|
42
|
+
"wrong_account",
|
|
43
|
+
"scope_changed",
|
|
44
|
+
"uncertain",
|
|
45
|
+
"unavailable",
|
|
46
|
+
"unsupported",
|
|
47
|
+
]);
|
|
48
|
+
|
|
49
|
+
export function authenticationProblem(error: unknown): AuthenticationProblem | undefined {
|
|
50
|
+
const failure = error as Partial<AuthenticationError> | null;
|
|
51
|
+
return failure?.name === "AuthenticationError" && authenticationProblems.has(failure.problem!)
|
|
52
|
+
? failure.problem
|
|
53
|
+
: undefined;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Shared by credential intake and execution; unknown provider failures never imply revocation. */
|
|
57
|
+
export async function verifyAuthentication(
|
|
58
|
+
method: Authentication,
|
|
59
|
+
credentials?: unknown,
|
|
60
|
+
): Promise<AuthenticationIdentity> {
|
|
61
|
+
if (!method.verify) throw new AuthenticationError("unsupported");
|
|
62
|
+
try {
|
|
63
|
+
const identity = await method.verify(credentials);
|
|
64
|
+
if (
|
|
65
|
+
typeof identity?.account !== "string" ||
|
|
66
|
+
!identity.account.trim() ||
|
|
67
|
+
!Array.isArray(identity.scopes) ||
|
|
68
|
+
identity.scopes.some((scope) => typeof scope !== "string") ||
|
|
69
|
+
(identity.expiresAt !== undefined && !Number.isFinite(identity.expiresAt))
|
|
70
|
+
)
|
|
71
|
+
throw new AuthenticationError("unavailable");
|
|
72
|
+
if (identity.expiresAt !== undefined && identity.expiresAt <= Date.now()) throw new AuthenticationError("expired");
|
|
73
|
+
return {
|
|
74
|
+
account: identity.account,
|
|
75
|
+
scopes: [...new Set(identity.scopes)].toSorted(),
|
|
76
|
+
...(identity.expiresAt !== undefined && { expiresAt: identity.expiresAt }),
|
|
77
|
+
};
|
|
78
|
+
} catch (error) {
|
|
79
|
+
// Structural recognition works across installed copies. Rebuild even recognized errors so
|
|
80
|
+
// an SDK message, stack or cause attached by a verifier cannot cross this boundary.
|
|
81
|
+
throw new AuthenticationError(authenticationProblem(error) ?? "unavailable");
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Ephemeral material for one authorized driver invocation; never checkpoint this object. */
|
|
86
|
+
export interface CallAuthentication {
|
|
87
|
+
method: string;
|
|
88
|
+
kind: Authentication["kind"];
|
|
89
|
+
credentialVersion?: string;
|
|
90
|
+
credentials?: unknown;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Credential schemas and lifecycle are owned by authentication, never by the store provider. */
|
|
94
|
+
export type Authentication<S extends z.ZodType = z.ZodType> =
|
|
95
|
+
| {
|
|
96
|
+
kind: "api-key" | "fields" | "oauth2" | "signed";
|
|
97
|
+
version: number;
|
|
98
|
+
schema: S;
|
|
99
|
+
storage: { writable: boolean; maxBytes: number; conditionalWrites: boolean };
|
|
100
|
+
verify?(credentials: z.output<S>): Promise<AuthenticationIdentity>;
|
|
101
|
+
}
|
|
102
|
+
| {
|
|
103
|
+
kind: "workload-identity";
|
|
104
|
+
storage?: never;
|
|
105
|
+
version?: never;
|
|
106
|
+
schema?: never;
|
|
107
|
+
verify?(): Promise<AuthenticationIdentity>;
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
export function defineAuthentication<S extends z.ZodType>(definition: Authentication<S>): Authentication<S> {
|
|
111
|
+
if (definition.verify !== undefined && typeof definition.verify !== "function")
|
|
112
|
+
throw new SecretStoreError("invalid_configuration");
|
|
113
|
+
if (definition.kind === "workload-identity") {
|
|
114
|
+
if (definition.storage !== undefined || definition.schema !== undefined || definition.version !== undefined)
|
|
115
|
+
throw new SecretStoreError("invalid_configuration");
|
|
116
|
+
return Object.freeze({ ...definition });
|
|
117
|
+
}
|
|
118
|
+
if (
|
|
119
|
+
!["api-key", "fields", "oauth2", "signed"].includes(definition.kind) ||
|
|
120
|
+
!Number.isSafeInteger(definition.version) ||
|
|
121
|
+
definition.version < 1 ||
|
|
122
|
+
typeof definition.schema?.safeParse !== "function" ||
|
|
123
|
+
!definition.storage ||
|
|
124
|
+
!Number.isSafeInteger(definition.storage.maxBytes) ||
|
|
125
|
+
definition.storage.maxBytes < 1 ||
|
|
126
|
+
typeof definition.storage.writable !== "boolean" ||
|
|
127
|
+
typeof definition.storage.conditionalWrites !== "boolean"
|
|
128
|
+
) {
|
|
129
|
+
throw new SecretStoreError("invalid_configuration");
|
|
130
|
+
}
|
|
131
|
+
return Object.freeze({ ...definition, storage: Object.freeze({ ...definition.storage }) });
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Typed access inside a driver. A missing or different method never falls back to the environment. */
|
|
135
|
+
export function authenticationCredentials<S extends z.ZodType>(
|
|
136
|
+
method: string,
|
|
137
|
+
definition: Authentication<S>,
|
|
138
|
+
call: CallContext,
|
|
139
|
+
): z.output<S> {
|
|
140
|
+
if (
|
|
141
|
+
definition.kind === "workload-identity" ||
|
|
142
|
+
call.authentication?.method !== method ||
|
|
143
|
+
call.authentication.kind !== definition.kind
|
|
144
|
+
)
|
|
145
|
+
throw new SecretStoreError("unsupported");
|
|
146
|
+
try {
|
|
147
|
+
return definition.schema.parse(call.authentication.credentials);
|
|
148
|
+
} catch {
|
|
149
|
+
throw new SecretStoreError("invalid_value");
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Compatibility check before consent, including externally renewed (read-only) OAuth bundles. */
|
|
154
|
+
export function checkAuthenticationStore(
|
|
155
|
+
auth: Authentication,
|
|
156
|
+
store: SecretStoreCapabilities,
|
|
157
|
+
management: "sanoma" | "external",
|
|
158
|
+
): void {
|
|
159
|
+
if (auth.kind === "workload-identity") return;
|
|
160
|
+
if (auth.storage.maxBytes > store.maxBytes) throw new SecretStoreError("too_large");
|
|
161
|
+
if (auth.storage.writable && (management === "external" || !store.writable)) throw new SecretStoreError("read_only");
|
|
162
|
+
if (auth.storage.conditionalWrites && store.conditionalWrites === "none") throw new SecretStoreError("unsupported");
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Serialized only at the trusted store boundary, never returned by an operation to a workflow. */
|
|
166
|
+
export function encodeCredentials<S extends z.ZodType>(auth: Authentication<S>, value: z.input<S>): string {
|
|
167
|
+
if (auth.kind === "workload-identity") throw new SecretStoreError("unsupported");
|
|
168
|
+
try {
|
|
169
|
+
const parsed = auth.schema.safeParse(value);
|
|
170
|
+
if (!parsed.success) throw new SecretStoreError("invalid_value");
|
|
171
|
+
const encoded = JSON.stringify({ version: auth.version, value: parsed.data });
|
|
172
|
+
if (Buffer.byteLength(encoded) > auth.storage.maxBytes) throw new SecretStoreError("too_large");
|
|
173
|
+
return encoded;
|
|
174
|
+
} catch (error) {
|
|
175
|
+
if (error instanceof SecretStoreError) throw new SecretStoreError(error.code);
|
|
176
|
+
throw new SecretStoreError("invalid_value");
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export function decodeCredentials<S extends z.ZodType>(auth: Authentication<S>, encoded: string): z.output<S> {
|
|
181
|
+
if (auth.kind === "workload-identity") throw new SecretStoreError("unsupported");
|
|
182
|
+
try {
|
|
183
|
+
if (Buffer.byteLength(encoded) > auth.storage.maxBytes) throw new SecretStoreError("too_large");
|
|
184
|
+
const bundle = JSON.parse(encoded);
|
|
185
|
+
if (bundle?.version !== auth.version) throw new SecretStoreError("invalid_value");
|
|
186
|
+
return auth.schema.parse(bundle.value);
|
|
187
|
+
} catch (error) {
|
|
188
|
+
if (error instanceof SecretStoreError) throw new SecretStoreError(error.code);
|
|
189
|
+
throw new SecretStoreError("invalid_value");
|
|
190
|
+
}
|
|
191
|
+
}
|
package/src/call-workflow.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { connectionRefSchema, type ConnectionRef } from "./connection.ts";
|
|
1
2
|
import { z } from "zod";
|
|
2
3
|
import { defineWorkflow, type WorkflowDefinition } from "./define.ts";
|
|
3
4
|
import { isDirect } from "./direct.ts";
|
|
@@ -12,10 +13,10 @@ export { CALL_WORKFLOW } from "./shared.ts";
|
|
|
12
13
|
// held to the same lint: the call goes through `ctx`, under the policy, in the ledger.
|
|
13
14
|
|
|
14
15
|
/** An operation on `ctx`. */
|
|
15
|
-
type OpCall = (input: unknown) => Promise<unknown>;
|
|
16
|
+
type OpCall = (input: unknown, options?: { connection?: ConnectionRef }) => Promise<unknown>;
|
|
16
17
|
|
|
17
18
|
/** The call workflow's input: the operation's id, and its input. */
|
|
18
|
-
export const CallInput = z.object({ op: z.string(), input: z.unknown() });
|
|
19
|
+
export const CallInput = z.object({ op: z.string(), input: z.unknown(), connection: connectionRefSchema.optional() });
|
|
19
20
|
|
|
20
21
|
/**
|
|
21
22
|
* The call workflow for a config: it `uses` every operation that may be called directly and has
|
|
@@ -43,10 +44,10 @@ export function callWorkflow(
|
|
|
43
44
|
// Four segments for an app's collection: `data.<app>.<collection>.<op>`.
|
|
44
45
|
const [vendor = "", a = "", b = "", c] = input.op.split(".");
|
|
45
46
|
return c === undefined
|
|
46
|
-
? ctx[vendor]![a]![b]!(input.input)
|
|
47
|
+
? ctx[vendor]![a]![b]!(input.input, { connection: input.connection })
|
|
47
48
|
: (ctx as unknown as Record<string, Record<string, Record<string, Record<string, OpCall>>>>)[vendor]![a]![b]![
|
|
48
49
|
c
|
|
49
|
-
]!(input.input);
|
|
50
|
+
]!(input.input, { connection: input.connection });
|
|
50
51
|
},
|
|
51
52
|
});
|
|
52
53
|
}
|
package/src/call.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { ConnectionRef, ConnectionSnapshot } from "./connection.ts";
|
|
1
2
|
import { DBOS, DBOSWorkflowConflictError, Error as DBOSErrors } from "@dbos-inc/dbos-sdk";
|
|
2
3
|
import { awaitApproval, type CheckedApproval } from "./approvals.ts";
|
|
3
4
|
import { offsetOf } from "./ast.ts";
|
|
@@ -161,8 +162,10 @@ export function buildCtx(wf: WorkflowDefinition<any, any>, run: Run): any {
|
|
|
161
162
|
.split(".")
|
|
162
163
|
.slice(0, -1)
|
|
163
164
|
.reduce<Record<string, any>>((at, key) => (at[key] ??= {}), members);
|
|
164
|
-
group[op.name] = placed(op.id, (node, input) =>
|
|
165
|
-
serial(run, () =>
|
|
165
|
+
group[op.name] = placed(op.id, (node, input, options?: { connection?: ConnectionRef }) =>
|
|
166
|
+
serial(run, () =>
|
|
167
|
+
callOp(run, op.id, input, { node: node.path, name: named(node) ?? op.id }, options?.connection),
|
|
168
|
+
),
|
|
166
169
|
);
|
|
167
170
|
}
|
|
168
171
|
members.runId = run.id;
|
|
@@ -284,8 +287,19 @@ function targetOf(op: Op, input: unknown): { target?: string } {
|
|
|
284
287
|
* the running instance's people): while it holds or denies, the company's policy is not asked.
|
|
285
288
|
* Runs inside a step, so it must have no side effects of its own.
|
|
286
289
|
*/
|
|
287
|
-
export async function decide(
|
|
288
|
-
|
|
290
|
+
export async function decide(
|
|
291
|
+
run: Run,
|
|
292
|
+
op: Op,
|
|
293
|
+
input: unknown,
|
|
294
|
+
connection?: ConnectionSnapshot,
|
|
295
|
+
): Promise<RecordedDecision> {
|
|
296
|
+
const seen = {
|
|
297
|
+
id: run.id,
|
|
298
|
+
workflow: run.workflow,
|
|
299
|
+
approvals: structuredClone(run.approvals).filter(
|
|
300
|
+
(a) => a.requestedBy !== "policy" || a.connection === connection?.revision,
|
|
301
|
+
),
|
|
302
|
+
};
|
|
289
303
|
// Only a merge is the rule's: no other call reads the people's files.
|
|
290
304
|
const held =
|
|
291
305
|
op.id === CHANGE_MERGE ? runtimeHold(op.id, input, seen.approvals, readPeople(run.state.root).people) : undefined;
|
|
@@ -298,7 +312,7 @@ export async function decide(run: Run, op: Op, input: unknown): Promise<Recorded
|
|
|
298
312
|
{ idempotencyKey: `${run.id}:${run.seq}:before`, runId: run.id, opId: get, attempt: 1, secrets: secretsOf(run) },
|
|
299
313
|
),
|
|
300
314
|
);
|
|
301
|
-
const call = { ...callOf(op, input, run.actor, seen), ...before };
|
|
315
|
+
const call = { ...callOf(op, input, run.actor, seen), ...before, ...(connection && { connection }) };
|
|
302
316
|
return ask(policy, op.id, () => policy(call), run.state);
|
|
303
317
|
}
|
|
304
318
|
|
|
@@ -357,7 +371,7 @@ export function decideCall(
|
|
|
357
371
|
input: unknown,
|
|
358
372
|
actor: Principal,
|
|
359
373
|
run: PolicyCall["run"],
|
|
360
|
-
before: Pick<PolicyCall, "before"> = {},
|
|
374
|
+
before: Pick<PolicyCall, "before" | "connection"> = {},
|
|
361
375
|
): RecordedDecision {
|
|
362
376
|
const call = { ...callOf(op, input, actor, run), ...before };
|
|
363
377
|
return ask(policy, op.id, () => policy(call));
|
|
@@ -411,7 +425,7 @@ function ask(
|
|
|
411
425
|
return policy.version === undefined ? decision : { ...decision, policyVersion: policy.version };
|
|
412
426
|
}
|
|
413
427
|
|
|
414
|
-
async function callOp(run: Run, id: string, input: unknown, place: Placed) {
|
|
428
|
+
async function callOp(run: Run, id: string, input: unknown, place: Placed, ref?: ConnectionRef) {
|
|
415
429
|
// The worker's declaration, never the workflow's: its effect, schemas and retry setting.
|
|
416
430
|
const op = run.state.ops.get(id);
|
|
417
431
|
// A sandbox run calls its own fakes, never a vendor.
|
|
@@ -441,10 +455,35 @@ async function callOp(run: Run, id: string, input: unknown, place: Placed) {
|
|
|
441
455
|
// The decision is recorded as a step, so a replay reuses it instead of asking a policy
|
|
442
456
|
// that may have changed since. The policy must still be deterministic: a run that fails
|
|
443
457
|
// before the step is recorded asks again.
|
|
444
|
-
const
|
|
458
|
+
const connection = await DBOS.runStep(
|
|
459
|
+
async () => {
|
|
460
|
+
if (run.sandbox !== undefined) {
|
|
461
|
+
if (!run.fakeConnection) throw new SanomaError("invalid_input", "Sandbox account state is unavailable");
|
|
462
|
+
return run.fakeConnection(op.vendor, ref);
|
|
463
|
+
}
|
|
464
|
+
return run.state.connections?.select(op.vendor, ref, run.actor.id);
|
|
465
|
+
},
|
|
466
|
+
{ name: `connection:${op.id}` },
|
|
467
|
+
);
|
|
468
|
+
const decideStep = () =>
|
|
469
|
+
DBOS.runStep(
|
|
470
|
+
async () => {
|
|
471
|
+
if (run.sandbox === undefined) await run.state.connections?.assertCurrent(connection, run.actor.id);
|
|
472
|
+
return decide(run, op, parsed, connection);
|
|
473
|
+
},
|
|
474
|
+
{ name: `policy:${op.id}` },
|
|
475
|
+
);
|
|
445
476
|
let decision = await decideStep();
|
|
446
477
|
const logged = recorded(op, parsed);
|
|
447
|
-
const call = {
|
|
478
|
+
const call = {
|
|
479
|
+
type: "op.called",
|
|
480
|
+
op: op.id,
|
|
481
|
+
...place,
|
|
482
|
+
effect: op.effect,
|
|
483
|
+
input: logged,
|
|
484
|
+
decision,
|
|
485
|
+
...(connection && { connection }),
|
|
486
|
+
} as const;
|
|
448
487
|
|
|
449
488
|
// Each hold is lifted in turn: once one is approved, the policy is asked again, with it.
|
|
450
489
|
for (;;) {
|
|
@@ -462,6 +501,7 @@ async function callOp(run: Run, id: string, input: unknown, place: Placed) {
|
|
|
462
501
|
seq,
|
|
463
502
|
input: logged,
|
|
464
503
|
reasons: decision.reasons,
|
|
504
|
+
connection: connection?.revision,
|
|
465
505
|
});
|
|
466
506
|
} catch (err) {
|
|
467
507
|
// A rejection or a withdrawal stops the call: it is recorded as failed, naming the approval.
|
|
@@ -486,6 +526,7 @@ async function callOp(run: Run, id: string, input: unknown, place: Placed) {
|
|
|
486
526
|
async () => {
|
|
487
527
|
const at = Date.now();
|
|
488
528
|
attempt = DBOS.stepStatus?.currentAttempt ?? 1;
|
|
529
|
+
if (run.sandbox === undefined) await run.state.connections?.assertCurrent(connection, run.actor.id);
|
|
489
530
|
const secrets = secretsOf(run);
|
|
490
531
|
const context: CallContext = {
|
|
491
532
|
idempotencyKey: `${run.id}:${seq}`,
|
|
@@ -493,6 +534,8 @@ async function callOp(run: Run, id: string, input: unknown, place: Placed) {
|
|
|
493
534
|
opId: op.id,
|
|
494
535
|
attempt,
|
|
495
536
|
secrets,
|
|
537
|
+
principal: run.actor,
|
|
538
|
+
...(connection && { connection }),
|
|
496
539
|
// A call run is the person's direct call held for an approval: made as them, as it would have been.
|
|
497
540
|
by: run.workflow === CALL_WORKFLOW ? run.actor.id : `run:${run.id}`,
|
|
498
541
|
};
|
package/src/cedar.ts
CHANGED
|
@@ -403,7 +403,20 @@ export function cedarPolicy(
|
|
|
403
403
|
call.actor,
|
|
404
404
|
call.op.id,
|
|
405
405
|
call.target,
|
|
406
|
-
{
|
|
406
|
+
{
|
|
407
|
+
input: call.input,
|
|
408
|
+
...(call.before !== undefined && { before: call.before }),
|
|
409
|
+
...(call.connection && {
|
|
410
|
+
connection: {
|
|
411
|
+
id: call.connection.id,
|
|
412
|
+
vendor: call.connection.vendor,
|
|
413
|
+
method: call.connection.method,
|
|
414
|
+
account: call.connection.account,
|
|
415
|
+
revision: call.connection.revision,
|
|
416
|
+
scopes: call.connection.scopes,
|
|
417
|
+
},
|
|
418
|
+
}),
|
|
419
|
+
},
|
|
407
420
|
call.run.approvals,
|
|
408
421
|
call.run.workflow === CALL_WORKFLOW,
|
|
409
422
|
call.entity,
|
package/src/change-steps.ts
CHANGED
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
followBase,
|
|
16
16
|
repositoryOf,
|
|
17
17
|
isRules,
|
|
18
|
+
mayDeclarePeople,
|
|
18
19
|
sourceOfRun,
|
|
19
20
|
touchesOf,
|
|
20
21
|
} from "./change.ts";
|
|
@@ -23,7 +24,7 @@ import type { DriftReport } from "./drift.ts";
|
|
|
23
24
|
import { isRunControlError } from "./errors.ts";
|
|
24
25
|
import { warn } from "./log.ts";
|
|
25
26
|
import { inside } from "./paths.ts";
|
|
26
|
-
import {
|
|
27
|
+
import { peopleSources } from "./resources.ts";
|
|
27
28
|
import { currentRun, type Run } from "./run.ts";
|
|
28
29
|
import {
|
|
29
30
|
AGENTS,
|
|
@@ -48,8 +49,8 @@ const runNow = (): Run => {
|
|
|
48
49
|
return run;
|
|
49
50
|
};
|
|
50
51
|
|
|
51
|
-
/** True when the file at `path` is a people file (`
|
|
52
|
-
const fileDeclaresPeople = async (path: string) =>
|
|
52
|
+
/** True when the file at `path` is a people file (`mayDeclarePeople`), one `readPeople` would load; false when there is none. */
|
|
53
|
+
const fileDeclaresPeople = async (path: string) => mayDeclarePeople(await readFile(path, "utf8").catch(() => ""));
|
|
53
54
|
|
|
54
55
|
/** Which events win when several wait: one that ends the change over one that plans it again. */
|
|
55
56
|
const WEIGHT: Record<ChangeEvent["kind"], number> = { commits: 0, close: 1, closed: 2, merged: 3 };
|
|
@@ -188,12 +189,14 @@ export function changeSteps({ remotes, hosts }: ChangeStepsOptions): ChangeSteps
|
|
|
188
189
|
.filter((file) => file.startsWith("resources/"))
|
|
189
190
|
.flatMap((file) => [join(source.root, file), join(draft.root, file)].map(fileDeclaresPeople)),
|
|
190
191
|
);
|
|
192
|
+
const sources = peopleSources(source.root);
|
|
191
193
|
return {
|
|
192
194
|
touches,
|
|
193
195
|
strandedRuns: stranded.length,
|
|
194
196
|
stranded,
|
|
195
197
|
checksOk: check.ok,
|
|
196
|
-
|
|
198
|
+
// Or a file main's people are read from: a helper a people file imports.
|
|
199
|
+
rules: people.includes(true) || touches.some(isRules) || files.some((file) => sources.has(file)),
|
|
197
200
|
};
|
|
198
201
|
},
|
|
199
202
|
{ name: "change:plan" },
|