@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.
Files changed (207) hide show
  1. package/README.md +193 -50
  2. package/SECRET-SUPPORT.md +33 -0
  3. package/dist/apply-steps.js +5 -2
  4. package/dist/apply-steps.js.map +1 -1
  5. package/dist/apply.d.ts +3 -0
  6. package/dist/apply.js +27 -13
  7. package/dist/apply.js.map +1 -1
  8. package/dist/approvals.d.ts +1 -0
  9. package/dist/approvals.js +6 -1
  10. package/dist/approvals.js.map +1 -1
  11. package/dist/apps.d.ts +13 -24
  12. package/dist/apps.js +69 -68
  13. package/dist/apps.js.map +1 -1
  14. package/dist/ast.d.ts +9 -7
  15. package/dist/ast.js +2 -0
  16. package/dist/ast.js.map +1 -1
  17. package/dist/authentication.d.ts +54 -0
  18. package/dist/authentication.js +138 -0
  19. package/dist/authentication.js.map +1 -0
  20. package/dist/call-workflow.d.ts +5 -0
  21. package/dist/call-workflow.js +4 -3
  22. package/dist/call-workflow.js.map +1 -1
  23. package/dist/call.d.ts +3 -2
  24. package/dist/call.js +36 -7
  25. package/dist/call.js.map +1 -1
  26. package/dist/cedar.js +14 -1
  27. package/dist/cedar.js.map +1 -1
  28. package/dist/change-steps.js +7 -5
  29. package/dist/change-steps.js.map +1 -1
  30. package/dist/change.d.ts +7 -0
  31. package/dist/change.js +7 -0
  32. package/dist/change.js.map +1 -1
  33. package/dist/client.d.ts +62 -10
  34. package/dist/client.js +272 -47
  35. package/dist/client.js.map +1 -1
  36. package/dist/collection.d.ts +7 -0
  37. package/dist/collection.js +5 -2
  38. package/dist/collection.js.map +1 -1
  39. package/dist/config.d.ts +16 -4
  40. package/dist/config.js +89 -21
  41. package/dist/config.js.map +1 -1
  42. package/dist/connection.d.ts +389 -0
  43. package/dist/connection.js +657 -0
  44. package/dist/connection.js.map +1 -0
  45. package/dist/credentials.d.ts +7 -53
  46. package/dist/credentials.js +84 -247
  47. package/dist/credentials.js.map +1 -1
  48. package/dist/db.js +34 -2
  49. package/dist/db.js.map +1 -1
  50. package/dist/define.d.ts +9 -2
  51. package/dist/define.js.map +1 -1
  52. package/dist/definitions.d.ts +20 -0
  53. package/dist/definitions.js +16 -0
  54. package/dist/definitions.js.map +1 -0
  55. package/dist/deployment-key.d.ts +2 -0
  56. package/dist/deployment-key.js +22 -0
  57. package/dist/deployment-key.js.map +1 -0
  58. package/dist/describe.d.ts +15 -175
  59. package/dist/describe.js +122 -35
  60. package/dist/describe.js.map +1 -1
  61. package/dist/description.d.ts +532 -0
  62. package/dist/description.js +381 -0
  63. package/dist/description.js.map +1 -0
  64. package/dist/direct.js +7 -0
  65. package/dist/direct.js.map +1 -1
  66. package/dist/drift.d.ts +1 -1
  67. package/dist/drift.js +1 -1
  68. package/dist/drift.js.map +1 -1
  69. package/dist/env.d.ts +2 -0
  70. package/dist/env.js +2 -2
  71. package/dist/env.js.map +1 -1
  72. package/dist/fake.d.ts +13 -1
  73. package/dist/fake.js +70 -0
  74. package/dist/fake.js.map +1 -1
  75. package/dist/index.d.ts +11 -3
  76. package/dist/index.js +10 -2
  77. package/dist/index.js.map +1 -1
  78. package/dist/ledger.d.ts +2 -0
  79. package/dist/ledger.js +1 -1
  80. package/dist/ledger.js.map +1 -1
  81. package/dist/lint.d.ts +0 -9
  82. package/dist/lint.js +2 -17
  83. package/dist/lint.js.map +1 -1
  84. package/dist/load.d.ts +85 -0
  85. package/dist/load.js +391 -0
  86. package/dist/load.js.map +1 -0
  87. package/dist/lockfile.d.ts +2 -0
  88. package/dist/lockfile.js +8 -3
  89. package/dist/lockfile.js.map +1 -1
  90. package/dist/op.d.ts +22 -5
  91. package/dist/op.js +20 -2
  92. package/dist/op.js.map +1 -1
  93. package/dist/outline.d.ts +5 -0
  94. package/dist/outline.js +46 -5
  95. package/dist/outline.js.map +1 -1
  96. package/dist/paths.d.ts +18 -2
  97. package/dist/paths.js +19 -7
  98. package/dist/paths.js.map +1 -1
  99. package/dist/plugin.d.ts +0 -11
  100. package/dist/plugin.js +3 -18
  101. package/dist/plugin.js.map +1 -1
  102. package/dist/policy.d.ts +2 -0
  103. package/dist/policy.js.map +1 -1
  104. package/dist/resource.d.ts +10 -4
  105. package/dist/resource.js +11 -4
  106. package/dist/resource.js.map +1 -1
  107. package/dist/resources.d.ts +33 -76
  108. package/dist/resources.js +219 -227
  109. package/dist/resources.js.map +1 -1
  110. package/dist/run.d.ts +4 -0
  111. package/dist/run.js.map +1 -1
  112. package/dist/sandbox.d.ts +5 -2
  113. package/dist/sandbox.js +56 -7
  114. package/dist/sandbox.js.map +1 -1
  115. package/dist/scenario.d.ts +5 -0
  116. package/dist/scenario.js +3 -2
  117. package/dist/scenario.js.map +1 -1
  118. package/dist/scenarios.js +7 -1
  119. package/dist/scenarios.js.map +1 -1
  120. package/dist/schema.js +8 -0
  121. package/dist/schema.js.map +1 -1
  122. package/dist/secret-store-check.d.ts +11 -0
  123. package/dist/secret-store-check.js +76 -0
  124. package/dist/secret-store-check.js.map +1 -0
  125. package/dist/secret-store.d.ts +106 -0
  126. package/dist/secret-store.js +228 -0
  127. package/dist/secret-store.js.map +1 -0
  128. package/dist/secret.d.ts +2 -0
  129. package/dist/secret.js +32 -0
  130. package/dist/secret.js.map +1 -1
  131. package/dist/shared.d.ts +21 -11
  132. package/dist/shared.js +41 -7
  133. package/dist/shared.js.map +1 -1
  134. package/dist/sites.d.ts +4 -93
  135. package/dist/sites.js +76 -84
  136. package/dist/sites.js.map +1 -1
  137. package/dist/steps.d.ts +3 -0
  138. package/dist/steps.js.map +1 -1
  139. package/dist/version.d.ts +2 -1
  140. package/dist/version.js +2 -1
  141. package/dist/version.js.map +1 -1
  142. package/dist/wakeup-driver.js +15 -6
  143. package/dist/wakeup-driver.js.map +1 -1
  144. package/dist/worker.js +12 -8
  145. package/dist/worker.js.map +1 -1
  146. package/dist/writer.d.ts +53 -14
  147. package/dist/writer.js +113 -43
  148. package/dist/writer.js.map +1 -1
  149. package/oxlint.json +1 -5
  150. package/package.json +6 -1
  151. package/src/apply-steps.ts +5 -2
  152. package/src/apply.ts +54 -21
  153. package/src/approvals.ts +7 -1
  154. package/src/apps.ts +71 -86
  155. package/src/ast.ts +6 -6
  156. package/src/authentication.ts +191 -0
  157. package/src/call-workflow.ts +5 -4
  158. package/src/call.ts +52 -9
  159. package/src/cedar.ts +14 -1
  160. package/src/change-steps.ts +7 -4
  161. package/src/change.ts +10 -0
  162. package/src/client.ts +346 -45
  163. package/src/collection.ts +10 -2
  164. package/src/config.ts +107 -19
  165. package/src/connection.ts +912 -0
  166. package/src/credentials.ts +94 -281
  167. package/src/db.ts +34 -2
  168. package/src/define.ts +7 -2
  169. package/src/definitions.ts +31 -0
  170. package/src/deployment-key.ts +23 -0
  171. package/src/describe.ts +150 -227
  172. package/src/description.ts +426 -0
  173. package/src/direct.ts +7 -0
  174. package/src/drift.ts +10 -2
  175. package/src/env.ts +4 -2
  176. package/src/fake.ts +71 -0
  177. package/src/index.ts +53 -1
  178. package/src/ledger.ts +3 -1
  179. package/src/lint.ts +2 -17
  180. package/src/load.ts +466 -0
  181. package/src/lockfile.ts +10 -3
  182. package/src/op.ts +41 -7
  183. package/src/outline.ts +53 -7
  184. package/src/paths.ts +21 -6
  185. package/src/plugin.ts +3 -24
  186. package/src/policy.ts +2 -0
  187. package/src/resource.ts +24 -6
  188. package/src/resources.ts +246 -301
  189. package/src/run.ts +4 -0
  190. package/src/sandbox.ts +61 -7
  191. package/src/scenario.ts +24 -6
  192. package/src/scenarios.ts +7 -1
  193. package/src/schema.ts +10 -0
  194. package/src/secret-store-check.ts +93 -0
  195. package/src/secret-store.ts +314 -0
  196. package/src/secret.ts +29 -0
  197. package/src/shared.ts +44 -10
  198. package/src/sites.ts +85 -179
  199. package/src/steps.ts +3 -2
  200. package/src/version.ts +2 -1
  201. package/src/wakeup-driver.ts +22 -6
  202. package/src/worker.ts +12 -8
  203. package/src/writer.ts +145 -56
  204. package/dist/datafile.d.ts +0 -97
  205. package/dist/datafile.js +0 -479
  206. package/dist/datafile.js.map +0 -1
  207. 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, relative, resolve, sep } from "node:path";
3
- import { childrenOf, type LintProblem, type Node, readSource } from "./ast.ts";
4
- import { keyName } from "./datafile.ts";
5
- import { stringValue, unwrap } from "./outline.ts";
6
- import { APPS_DIR, filesUnder, inside, isDirectory, RELATIVE } from "./paths.ts";
7
- import { APP_NAME } from "./shared.ts";
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
- /** An app: a folder of `apps/`, by its name, and the source repository its `app.ts` names. */
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 = LintProblem & { file: string };
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 read
47
- * with oxc, never run: its default export must be `defineApp({ … })`, from `sanoma`, written out,
48
- * and `source` a string.
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
- const dir = root === undefined ? undefined : join(root, APPS_DIR);
52
- if (dir === undefined || !isDirectory(dir)) return { apps: [], problems: [] };
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
- for (const name of folders) {
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
- if (!APP_NAME.test(name)) {
62
- problems.push({
63
- file: at,
64
- line: 1,
65
- column: 1,
66
- message: `${at} is not an app: an app's folder name is a lowercase letter, then lowercase letters and digits`,
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
- /** Where `defineApp` comes from in `app.ts`. */
78
- const APP_SOURCES: ReadonlySet<string> = new Set(["sanoma", "@sanoma-ai/workflows"]);
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 `source`, read without running it, and what is wrong there; nothing for no file. */
81
- function appOptionsOf(path: string): { source?: string; problems: LintProblem[] } {
82
- if (!existsSync(path)) return { problems: [] };
83
- const file = readSource(path);
84
- if (typeof file === "string") return { problems: [{ line: 1, column: 1, message: file }] };
85
- const problems: LintProblem[] = [...file.problems];
86
- const problem = (at: Node, message: string) => problems.push({ ...file.at(at.start), message });
87
- const unread =
88
- 'app.ts is read without running it: its default export is `defineApp({ source })` from "sanoma", written out';
89
- const exported = file.program.body.find((s: Node) => s.type === "ExportDefaultDeclaration");
90
- const call = unwrap(exported?.declaration);
91
- const callee = unwrap(call?.callee);
92
- const options = unwrap(call?.arguments?.[0]);
93
- const defineApps = new Set<string>();
94
- for (const statement of file.program.body) {
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
- const property = options?.properties.findLast(
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 = relative(root, path).split(sep).join("/");
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
- /** Something wrong in a file, at a line and column from 1. */
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
+ }
@@ -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, () => callOp(run, op.id, input, { node: node.path, name: named(node) ?? op.id })),
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(run: Run, op: Op, input: unknown): Promise<RecordedDecision> {
288
- const seen = { id: run.id, workflow: run.workflow, approvals: structuredClone(run.approvals) };
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 decideStep = () => DBOS.runStep(() => decide(run, op, parsed), { name: `policy:${op.id}` });
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 = { type: "op.called", op: op.id, ...place, effect: op.effect, input: logged, decision } as const;
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
- { input: call.input, ...(call.before !== undefined && { before: call.before }) },
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,
@@ -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 { DECLARES_PEOPLE } from "./resources.ts";
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 (`DECLARES_PEOPLE`), one `readPeople` would read; false when there is none. */
52
- const fileDeclaresPeople = async (path: string) => DECLARES_PEOPLE.test(await readFile(path, "utf8").catch(() => ""));
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
- rules: people.includes(true) || touches.some(isRules),
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" },