@sanoma-ai/workflows 0.9.1 → 0.10.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.
Files changed (190) hide show
  1. package/README.md +167 -50
  2. package/dist/apply-steps.js +5 -2
  3. package/dist/apply-steps.js.map +1 -1
  4. package/dist/apply.d.ts +3 -0
  5. package/dist/apply.js +27 -13
  6. package/dist/apply.js.map +1 -1
  7. package/dist/approvals.d.ts +1 -0
  8. package/dist/approvals.js +6 -1
  9. package/dist/approvals.js.map +1 -1
  10. package/dist/apps.d.ts +7 -24
  11. package/dist/apps.js +51 -67
  12. package/dist/apps.js.map +1 -1
  13. package/dist/ast.d.ts +9 -7
  14. package/dist/ast.js +2 -0
  15. package/dist/ast.js.map +1 -1
  16. package/dist/authentication.d.ts +24 -0
  17. package/dist/authentication.js +68 -0
  18. package/dist/authentication.js.map +1 -0
  19. package/dist/call-workflow.d.ts +5 -0
  20. package/dist/call-workflow.js +4 -3
  21. package/dist/call-workflow.js.map +1 -1
  22. package/dist/call.d.ts +3 -2
  23. package/dist/call.js +34 -7
  24. package/dist/call.js.map +1 -1
  25. package/dist/cedar.js +14 -1
  26. package/dist/cedar.js.map +1 -1
  27. package/dist/change-steps.js +7 -5
  28. package/dist/change-steps.js.map +1 -1
  29. package/dist/change.d.ts +7 -0
  30. package/dist/change.js +7 -0
  31. package/dist/change.js.map +1 -1
  32. package/dist/client.d.ts +10 -6
  33. package/dist/client.js +96 -43
  34. package/dist/client.js.map +1 -1
  35. package/dist/collection.d.ts +7 -0
  36. package/dist/collection.js +5 -2
  37. package/dist/collection.js.map +1 -1
  38. package/dist/config.d.ts +9 -0
  39. package/dist/config.js +45 -9
  40. package/dist/config.js.map +1 -1
  41. package/dist/connection.d.ts +168 -0
  42. package/dist/connection.js +327 -0
  43. package/dist/connection.js.map +1 -0
  44. package/dist/credentials.d.ts +7 -53
  45. package/dist/credentials.js +84 -247
  46. package/dist/credentials.js.map +1 -1
  47. package/dist/db.js +21 -2
  48. package/dist/db.js.map +1 -1
  49. package/dist/define.d.ts +9 -2
  50. package/dist/define.js.map +1 -1
  51. package/dist/definitions.d.ts +20 -0
  52. package/dist/definitions.js +16 -0
  53. package/dist/definitions.js.map +1 -0
  54. package/dist/deployment-key.d.ts +2 -0
  55. package/dist/deployment-key.js +22 -0
  56. package/dist/deployment-key.js.map +1 -0
  57. package/dist/describe.d.ts +9 -177
  58. package/dist/describe.js +71 -34
  59. package/dist/describe.js.map +1 -1
  60. package/dist/description.d.ts +532 -0
  61. package/dist/description.js +381 -0
  62. package/dist/description.js.map +1 -0
  63. package/dist/direct.js +3 -0
  64. package/dist/direct.js.map +1 -1
  65. package/dist/drift.d.ts +1 -1
  66. package/dist/drift.js +1 -1
  67. package/dist/drift.js.map +1 -1
  68. package/dist/env.d.ts +2 -0
  69. package/dist/env.js +2 -2
  70. package/dist/env.js.map +1 -1
  71. package/dist/index.d.ts +10 -2
  72. package/dist/index.js +10 -2
  73. package/dist/index.js.map +1 -1
  74. package/dist/ledger.d.ts +2 -0
  75. package/dist/ledger.js +1 -1
  76. package/dist/ledger.js.map +1 -1
  77. package/dist/lint.d.ts +0 -9
  78. package/dist/lint.js +1 -16
  79. package/dist/lint.js.map +1 -1
  80. package/dist/load.d.ts +74 -0
  81. package/dist/load.js +357 -0
  82. package/dist/load.js.map +1 -0
  83. package/dist/lockfile.d.ts +2 -0
  84. package/dist/lockfile.js +8 -3
  85. package/dist/lockfile.js.map +1 -1
  86. package/dist/op.d.ts +10 -1
  87. package/dist/op.js +1 -1
  88. package/dist/op.js.map +1 -1
  89. package/dist/outline.js +13 -4
  90. package/dist/outline.js.map +1 -1
  91. package/dist/paths.d.ts +18 -0
  92. package/dist/paths.js +21 -3
  93. package/dist/paths.js.map +1 -1
  94. package/dist/plugin.d.ts +0 -11
  95. package/dist/plugin.js +3 -18
  96. package/dist/plugin.js.map +1 -1
  97. package/dist/policy.d.ts +2 -0
  98. package/dist/policy.js.map +1 -1
  99. package/dist/resource.d.ts +10 -4
  100. package/dist/resource.js +11 -4
  101. package/dist/resource.js.map +1 -1
  102. package/dist/resources.d.ts +33 -76
  103. package/dist/resources.js +219 -227
  104. package/dist/resources.js.map +1 -1
  105. package/dist/run.d.ts +2 -0
  106. package/dist/run.js.map +1 -1
  107. package/dist/sandbox.d.ts +1 -1
  108. package/dist/sandbox.js +5 -2
  109. package/dist/sandbox.js.map +1 -1
  110. package/dist/scenarios.js +7 -1
  111. package/dist/scenarios.js.map +1 -1
  112. package/dist/schema.js +8 -0
  113. package/dist/schema.js.map +1 -1
  114. package/dist/secret-store-check.d.ts +11 -0
  115. package/dist/secret-store-check.js +76 -0
  116. package/dist/secret-store-check.js.map +1 -0
  117. package/dist/secret-store.d.ts +106 -0
  118. package/dist/secret-store.js +228 -0
  119. package/dist/secret-store.js.map +1 -0
  120. package/dist/shared.d.ts +17 -11
  121. package/dist/shared.js +34 -7
  122. package/dist/shared.js.map +1 -1
  123. package/dist/sites.d.ts +4 -93
  124. package/dist/sites.js +61 -84
  125. package/dist/sites.js.map +1 -1
  126. package/dist/version.d.ts +2 -1
  127. package/dist/version.js +2 -1
  128. package/dist/version.js.map +1 -1
  129. package/dist/wakeup-driver.js +15 -6
  130. package/dist/wakeup-driver.js.map +1 -1
  131. package/dist/worker.js +12 -8
  132. package/dist/worker.js.map +1 -1
  133. package/dist/writer.d.ts +53 -14
  134. package/dist/writer.js +113 -43
  135. package/dist/writer.js.map +1 -1
  136. package/oxlint.json +0 -5
  137. package/package.json +5 -1
  138. package/src/apply-steps.ts +5 -2
  139. package/src/apply.ts +54 -21
  140. package/src/approvals.ts +7 -1
  141. package/src/apps.ts +54 -85
  142. package/src/ast.ts +6 -6
  143. package/src/authentication.ts +74 -0
  144. package/src/call-workflow.ts +5 -4
  145. package/src/call.ts +53 -9
  146. package/src/cedar.ts +14 -1
  147. package/src/change-steps.ts +7 -4
  148. package/src/change.ts +10 -0
  149. package/src/client.ts +108 -41
  150. package/src/collection.ts +10 -2
  151. package/src/config.ts +62 -8
  152. package/src/connection.ts +446 -0
  153. package/src/credentials.ts +94 -281
  154. package/src/db.ts +21 -2
  155. package/src/define.ts +7 -2
  156. package/src/definitions.ts +31 -0
  157. package/src/deployment-key.ts +23 -0
  158. package/src/describe.ts +102 -230
  159. package/src/description.ts +426 -0
  160. package/src/direct.ts +3 -0
  161. package/src/drift.ts +10 -2
  162. package/src/env.ts +4 -2
  163. package/src/index.ts +42 -1
  164. package/src/ledger.ts +3 -1
  165. package/src/lint.ts +1 -16
  166. package/src/load.ts +437 -0
  167. package/src/lockfile.ts +10 -3
  168. package/src/op.ts +10 -1
  169. package/src/outline.ts +18 -5
  170. package/src/paths.ts +24 -2
  171. package/src/plugin.ts +3 -24
  172. package/src/policy.ts +2 -0
  173. package/src/resource.ts +24 -6
  174. package/src/resources.ts +246 -301
  175. package/src/run.ts +2 -0
  176. package/src/sandbox.ts +6 -2
  177. package/src/scenarios.ts +7 -1
  178. package/src/schema.ts +10 -0
  179. package/src/secret-store-check.ts +93 -0
  180. package/src/secret-store.ts +314 -0
  181. package/src/shared.ts +35 -10
  182. package/src/sites.ts +70 -180
  183. package/src/version.ts +2 -1
  184. package/src/wakeup-driver.ts +22 -6
  185. package/src/worker.ts +12 -8
  186. package/src/writer.ts +145 -56
  187. package/dist/datafile.d.ts +0 -97
  188. package/dist/datafile.js +0 -479
  189. package/dist/datafile.js.map +0 -1
  190. package/src/datafile.ts +0 -601
package/src/apply.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { ConnectionRef } from "./connection.ts";
1
2
  import { z } from "zod";
2
3
  import { type Approver, defineWorkflow, type WorkflowDefinition } from "./define.ts";
3
4
  import {
@@ -63,7 +64,7 @@ export type ApplyInput = z.infer<typeof ApplyInput>;
63
64
  * object its identity `found` or the input `given`, ask about a `gone` object or an `orphan`ed
64
65
  * binding (unless `answer`ed), or `none`.
65
66
  */
66
- export type PlanStep = { id: string; vendor: string; type: string } & (
67
+ export type PlanStep = { id: string; vendor: string; type: string; connection?: ConnectionRef } & (
67
68
  | { action: "create"; planned: Record<string, unknown>; unknown: string[] }
68
69
  | { action: "update" | "replace"; binding: string; fields: DriftField[]; replace: string[] }
69
70
  | { action: "bind"; binding: string; source: "found" | "given" }
@@ -114,6 +115,7 @@ export interface ApplyDeclared extends DriftDeclared {
114
115
  export interface PlannedCall {
115
116
  op: string;
116
117
  input: unknown;
118
+ connection?: ConnectionRef;
117
119
  }
118
120
 
119
121
  /** What apply does that is no vendor call, each a step of its own (`apply-steps.ts`). */
@@ -155,7 +157,7 @@ type DeleteOp = Op<string, string, "delete", { id: string }, { id: string; gone:
155
157
  type ApplyUse = ImportOp | PlanOp | CreateOp | UpdateOp | DeleteOp | ResolveOp | "approval" | "sleep";
156
158
 
157
159
  /** An operation as `ctx` gives it, called by its id. */
158
- type Call = (input: unknown) => Promise<unknown>;
160
+ type Call = (input: unknown, options?: { connection?: ConnectionRef }) => Promise<unknown>;
159
161
 
160
162
  /** What a step's resource is called in a problem: its file, and its export. */
161
163
  const problemOf = (id: string, message: string): ResourceProblem => ({
@@ -369,13 +371,24 @@ function writesOf(
369
371
  const made = { id: `new:${at}`, export: exportOf(keyOf(step)) };
370
372
  const after = (types.get(at)?.afterCreate ?? [])
371
373
  .filter((hook) => ops.get(hook.op)?.effect === "write")
372
- .map((hook) => ({ op: hook.op, input: hook.input(made) }));
373
- return [{ op: `${at}.create`, input: { desired: desired.get(step.id) } }, ...after];
374
+ .map((hook) => ({
375
+ op: hook.op,
376
+ input: hook.input(made),
377
+ ...(hook.op.startsWith(`${step.vendor}.`) && step.connection && { connection: step.connection }),
378
+ }));
379
+ return [{ op: `${at}.create`, connection: step.connection, input: { desired: desired.get(step.id) } }, ...after];
374
380
  }
375
- if (run === "delete" && "binding" in step) return [{ op: `${at}.delete`, input: { id: step.binding } }];
381
+ if (run === "delete" && "binding" in step)
382
+ return [{ op: `${at}.delete`, connection: step.connection, input: { id: step.binding } }];
376
383
  if (run !== "update" || (step.action !== "update" && step.action !== "replace")) return [];
377
384
  const expect = { action: step.action, replace: step.replace };
378
- return [{ op: `${at}.update`, input: { id: step.binding, desired: desired.get(step.id), expect } }];
385
+ return [
386
+ {
387
+ op: `${at}.update`,
388
+ connection: step.connection,
389
+ input: { id: step.binding, desired: desired.get(step.id), expect },
390
+ },
391
+ ];
379
392
  });
380
393
  }
381
394
 
@@ -474,7 +487,12 @@ export function applyWorkflow(
474
487
  if (!("id" in by)) found.set(resource.id, by);
475
488
  else {
476
489
  try {
477
- found.set(resource.id, observed(await ctx[resource.vendor]![resource.type]!.import({ id: by.id })));
490
+ found.set(
491
+ resource.id,
492
+ observed(
493
+ await ctx[resource.vendor]![resource.type]!.import({ id: by.id }, { connection: resource.connection }),
494
+ ),
495
+ );
478
496
  } catch (err) {
479
497
  if (isRunControlError(err)) throw err;
480
498
  found.set(resource.id, { error: errorMessage(err) });
@@ -529,7 +547,11 @@ export function applyWorkflow(
529
547
  : decide(resource, result, found.get(resource.id)!, input, desired.get(resource.id)!, types, usable);
530
548
  if (next.ask !== undefined) {
531
549
  try {
532
- const answer = fromPlan(resource, result, await ctx[resource.vendor]![resource.type]!.plan(next.ask));
550
+ const answer = fromPlan(
551
+ resource,
552
+ result,
553
+ await ctx[resource.vendor]![resource.type]!.plan(next.ask, { connection: resource.connection }),
554
+ );
533
555
  if (answer.step) planned.push(answer.step);
534
556
  if (answer.problem) problems.push(answer.problem);
535
557
  } catch (err) {
@@ -547,6 +569,8 @@ export function applyWorkflow(
547
569
  steps: [...planned, ...orphaned.steps],
548
570
  };
549
571
 
572
+ for (const step of plan.steps)
573
+ step.connection = declarations.get(step.id)?.connection ?? locked[keyOf(step)]?.connection;
550
574
  const writes = writesOf(plan, desired, types, ops);
551
575
  const approver = writes.length ? await steps.holds(writes) : undefined;
552
576
  if (approver !== undefined) {
@@ -569,24 +593,31 @@ export function applyWorkflow(
569
593
  let state: State | undefined;
570
594
  try {
571
595
  if (todo === "create") {
572
- const made = await ctx[step.vendor]![step.type]!.create({
573
- desired: desiredOf(declared!, types, bindings, hosts, urls),
574
- });
575
- bindings[key] = { type, id: made.id };
596
+ const made = await ctx[step.vendor]![step.type]!.create(
597
+ {
598
+ desired: desiredOf(declared!, types, bindings, hosts, urls),
599
+ },
600
+ { connection: step.connection },
601
+ );
602
+ bindings[key] = { type, id: made.id, ...(step.connection && { connection: step.connection }) };
576
603
  state = made.state;
577
604
  } else if (todo === "update" && (step.action === "update" || step.action === "replace")) {
578
605
  const expect = { action: step.action, replace: step.replace };
579
- const changed = await ctx[step.vendor]![step.type]!.update({
580
- id: binding,
581
- desired: desiredOf(declared!, types, bindings, hosts, urls),
582
- expect,
583
- });
584
- bindings[key] = { type, id: changed.id };
606
+ const changed = await ctx[step.vendor]![step.type]!.update(
607
+ {
608
+ id: binding,
609
+ desired: desiredOf(declared!, types, bindings, hosts, urls),
610
+ expect,
611
+ },
612
+ { connection: step.connection },
613
+ );
614
+ bindings[key] = { type, id: changed.id, ...(step.connection && { connection: step.connection }) };
585
615
  state = changed.state;
586
616
  } else if (todo === "delete") {
587
- await ctx[step.vendor]![step.type]!.delete({ id: binding });
617
+ await ctx[step.vendor]![step.type]!.delete({ id: binding }, { connection: step.connection });
588
618
  delete bindings[key];
589
- } else if (todo === "bind") bindings[key] = { type, id: binding };
619
+ } else if (todo === "bind")
620
+ bindings[key] = { type, id: binding, ...(step.connection && { connection: step.connection }) };
590
621
  else if (todo === "forget") delete bindings[key];
591
622
  if (state && declared) answersAt(declared, state);
592
623
  const after = bindings[key];
@@ -615,7 +646,9 @@ export function applyWorkflow(
615
646
  ) {
616
647
  if (call > 0) await ctx.sleep({ seconds: AFTER_CREATE_SECONDS });
617
648
  try {
618
- output = await (ctx[vendor]![group] as unknown as Record<string, Call>)[name]!(given);
649
+ output = await (ctx[vendor]![group] as unknown as Record<string, Call>)[name]!(given, {
650
+ connection: vendor === step.vendor ? step.connection : undefined,
651
+ });
619
652
  failed = undefined;
620
653
  } catch (err) {
621
654
  if (isRunControlError(err) || !hook.until) throw err;
package/src/approvals.ts CHANGED
@@ -82,6 +82,7 @@ export interface HeldCall {
82
82
  op: string;
83
83
  seq?: number;
84
84
  input: unknown;
85
+ connection?: string;
85
86
  reasons?: string[] | undefined;
86
87
  }
87
88
 
@@ -109,7 +110,12 @@ export async function awaitApproval(
109
110
  ...(req.details === undefined ? {} : { details: req.details }),
110
111
  requestedBy: held ? "policy" : "workflow",
111
112
  covers,
112
- ...(held && { op: held.op, ...heldOf(held), input: held.input }),
113
+ ...(held && {
114
+ op: held.op,
115
+ ...heldOf(held),
116
+ input: held.input,
117
+ ...(held.connection && { connection: held.connection }),
118
+ }),
113
119
  status: "pending",
114
120
  requestedAt: 0,
115
121
  refused: [],
package/src/apps.ts CHANGED
@@ -1,9 +1,12 @@
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";
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";
7
10
  import { APP_NAME } from "./shared.ts";
8
11
 
9
12
  // A company's apps: each folder of `apps/` holds the kinds the root does (workflows, resources,
@@ -11,108 +14,74 @@ import { APP_NAME } from "./shared.ts";
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
+ });
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
+ });
73
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
+ /** What `app.ts`'s default export, `defineApp({ … })`, may say. */
69
+ const AppDefinitionSchema = z.strictObject({ source: z.string().optional() });
79
70
 
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 };
71
+ /** `app.ts`'s `source`, as loading it gave, and what is wrong there; nothing for no file. */
72
+ function appDefinitionOf(file: string, answer: Map<string, Loaded>): { source?: string; problems: AppProblem[] } {
73
+ // A helper it imports threw: said at the helper.
74
+ const loaded = exportsOf(answer, file);
75
+ if (loaded === undefined) return { problems: [] };
76
+ if ("problem" in loaded) return { problems: [loaded.problem] };
77
+ const value = loaded.exports.find(([name]) => name === "default")?.[1];
78
+ const parsed = AppDefinitionSchema.safeParse(value);
79
+ if (!parsed.success) {
80
+ const why = z.prettifyError(parsed.error).replaceAll(/\s*\n\s*/g, " ");
81
+ const message = `its default export is \`defineApp({ source })\` from "sanoma", \`source\` a string: ${why}`;
82
+ return { problems: [{ file, export: "default", message }] };
108
83
  }
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 };
84
+ return { ...(parsed.data.source !== undefined && { source: parsed.data.source }), problems: [] };
116
85
  }
117
86
 
118
87
  /** A source file under an app: `.ts` or `.tsx`, but a declaration. */
@@ -131,7 +100,7 @@ export function crossAppImports(root: string | undefined, apps: readonly AppEntr
131
100
  for (const path of filesUnder(own, isSource)) {
132
101
  const file = readSource(path);
133
102
  if (typeof file === "string") continue;
134
- const rel = relative(root, path).split(sep).join("/");
103
+ const rel = relPath(root, path);
135
104
  const visit = (node: Node) => {
136
105
  if (!node) return;
137
106
  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,74 @@
1
+ import type { z } from "zod";
2
+ import { SecretStoreError, type SecretStoreCapabilities } from "./secret-store.ts";
3
+
4
+ /** Credential schemas and lifecycle are owned by authentication, never by the store provider. */
5
+ export type Authentication<S extends z.ZodType = z.ZodType> =
6
+ | {
7
+ kind: "api-key" | "fields" | "oauth2" | "signed";
8
+ version: number;
9
+ schema: S;
10
+ storage: { writable: boolean; maxBytes: number; conditionalWrites: boolean };
11
+ }
12
+ | { kind: "workload-identity"; storage?: never; version?: never; schema?: never };
13
+
14
+ export function defineAuthentication<S extends z.ZodType>(definition: Authentication<S>): Authentication<S> {
15
+ if (definition.kind === "workload-identity") {
16
+ if (definition.storage !== undefined || definition.schema !== undefined || definition.version !== undefined)
17
+ throw new SecretStoreError("invalid_configuration");
18
+ return Object.freeze({ kind: "workload-identity" });
19
+ }
20
+ if (
21
+ !["api-key", "fields", "oauth2", "signed"].includes(definition.kind) ||
22
+ !Number.isSafeInteger(definition.version) ||
23
+ definition.version < 1 ||
24
+ typeof definition.schema?.safeParse !== "function" ||
25
+ !definition.storage ||
26
+ !Number.isSafeInteger(definition.storage.maxBytes) ||
27
+ definition.storage.maxBytes < 1 ||
28
+ typeof definition.storage.writable !== "boolean" ||
29
+ typeof definition.storage.conditionalWrites !== "boolean"
30
+ ) {
31
+ throw new SecretStoreError("invalid_configuration");
32
+ }
33
+ return Object.freeze({ ...definition, storage: Object.freeze({ ...definition.storage }) });
34
+ }
35
+
36
+ /** Compatibility check before consent, including externally renewed (read-only) OAuth bundles. */
37
+ export function checkAuthenticationStore(
38
+ auth: Authentication,
39
+ store: SecretStoreCapabilities,
40
+ management: "sanoma" | "external",
41
+ ): void {
42
+ if (auth.kind === "workload-identity") return;
43
+ if (auth.storage.maxBytes > store.maxBytes) throw new SecretStoreError("too_large");
44
+ if (auth.storage.writable && (management === "external" || !store.writable)) throw new SecretStoreError("read_only");
45
+ if (auth.storage.conditionalWrites && store.conditionalWrites === "none") throw new SecretStoreError("unsupported");
46
+ }
47
+
48
+ /** Serialized only at the trusted store boundary, never returned by an operation to a workflow. */
49
+ export function encodeCredentials<S extends z.ZodType>(auth: Authentication<S>, value: z.input<S>): string {
50
+ if (auth.kind === "workload-identity") throw new SecretStoreError("unsupported");
51
+ try {
52
+ const parsed = auth.schema.safeParse(value);
53
+ if (!parsed.success) throw new SecretStoreError("invalid_value");
54
+ const encoded = JSON.stringify({ version: auth.version, value: parsed.data });
55
+ if (Buffer.byteLength(encoded) > auth.storage.maxBytes) throw new SecretStoreError("too_large");
56
+ return encoded;
57
+ } catch (error) {
58
+ if (error instanceof SecretStoreError) throw new SecretStoreError(error.code);
59
+ throw new SecretStoreError("invalid_value");
60
+ }
61
+ }
62
+
63
+ export function decodeCredentials<S extends z.ZodType>(auth: Authentication<S>, encoded: string): z.output<S> {
64
+ if (auth.kind === "workload-identity") throw new SecretStoreError("unsupported");
65
+ try {
66
+ if (Buffer.byteLength(encoded) > auth.storage.maxBytes) throw new SecretStoreError("too_large");
67
+ const bundle = JSON.parse(encoded);
68
+ if (bundle?.version !== auth.version) throw new SecretStoreError("invalid_value");
69
+ return auth.schema.parse(bundle.value);
70
+ } catch (error) {
71
+ if (error instanceof SecretStoreError) throw new SecretStoreError(error.code);
72
+ throw new SecretStoreError("invalid_value");
73
+ }
74
+ }
@@ -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,36 @@ 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 (ref)
462
+ throw new SanomaError("invalid_input", "Named sandbox connections require explicit fake account state");
463
+ return undefined;
464
+ }
465
+ return run.state.connections?.select(op.vendor, ref, run.actor.id);
466
+ },
467
+ { name: `connection:${op.id}` },
468
+ );
469
+ const decideStep = () =>
470
+ DBOS.runStep(
471
+ async () => {
472
+ await run.state.connections?.assertCurrent(connection, run.actor.id);
473
+ return decide(run, op, parsed, connection);
474
+ },
475
+ { name: `policy:${op.id}` },
476
+ );
445
477
  let decision = await decideStep();
446
478
  const logged = recorded(op, parsed);
447
- const call = { type: "op.called", op: op.id, ...place, effect: op.effect, input: logged, decision } as const;
479
+ const call = {
480
+ type: "op.called",
481
+ op: op.id,
482
+ ...place,
483
+ effect: op.effect,
484
+ input: logged,
485
+ decision,
486
+ ...(connection && { connection }),
487
+ } as const;
448
488
 
449
489
  // Each hold is lifted in turn: once one is approved, the policy is asked again, with it.
450
490
  for (;;) {
@@ -462,6 +502,7 @@ async function callOp(run: Run, id: string, input: unknown, place: Placed) {
462
502
  seq,
463
503
  input: logged,
464
504
  reasons: decision.reasons,
505
+ connection: connection?.revision,
465
506
  });
466
507
  } catch (err) {
467
508
  // A rejection or a withdrawal stops the call: it is recorded as failed, naming the approval.
@@ -486,6 +527,7 @@ async function callOp(run: Run, id: string, input: unknown, place: Placed) {
486
527
  async () => {
487
528
  const at = Date.now();
488
529
  attempt = DBOS.stepStatus?.currentAttempt ?? 1;
530
+ await run.state.connections?.assertCurrent(connection, run.actor.id);
489
531
  const secrets = secretsOf(run);
490
532
  const context: CallContext = {
491
533
  idempotencyKey: `${run.id}:${seq}`,
@@ -493,6 +535,8 @@ async function callOp(run: Run, id: string, input: unknown, place: Placed) {
493
535
  opId: op.id,
494
536
  attempt,
495
537
  secrets,
538
+ principal: run.actor,
539
+ ...(connection && { connection }),
496
540
  // A call run is the person's direct call held for an approval: made as them, as it would have been.
497
541
  by: run.workflow === CALL_WORKFLOW ? run.actor.id : `run:${run.id}`,
498
542
  };
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,