@sanoma-ai/workflows 0.9.0 → 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 (205) hide show
  1. package/README.md +169 -52
  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 +35 -21
  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 +14 -3
  39. package/dist/config.js +55 -14
  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/cucumber.d.ts +4 -2
  48. package/dist/cucumber.js +8 -3
  49. package/dist/cucumber.js.map +1 -1
  50. package/dist/db.js +21 -2
  51. package/dist/db.js.map +1 -1
  52. package/dist/define.d.ts +9 -2
  53. package/dist/define.js.map +1 -1
  54. package/dist/definitions.d.ts +20 -0
  55. package/dist/definitions.js +16 -0
  56. package/dist/definitions.js.map +1 -0
  57. package/dist/deployment-key.d.ts +2 -0
  58. package/dist/deployment-key.js +22 -0
  59. package/dist/deployment-key.js.map +1 -0
  60. package/dist/describe.d.ts +9 -177
  61. package/dist/describe.js +71 -34
  62. package/dist/describe.js.map +1 -1
  63. package/dist/description.d.ts +532 -0
  64. package/dist/description.js +381 -0
  65. package/dist/description.js.map +1 -0
  66. package/dist/direct.js +3 -0
  67. package/dist/direct.js.map +1 -1
  68. package/dist/drift.d.ts +1 -1
  69. package/dist/drift.js +1 -1
  70. package/dist/drift.js.map +1 -1
  71. package/dist/env.d.ts +2 -0
  72. package/dist/env.js +2 -2
  73. package/dist/env.js.map +1 -1
  74. package/dist/index.d.ts +10 -2
  75. package/dist/index.js +10 -2
  76. package/dist/index.js.map +1 -1
  77. package/dist/ledger.d.ts +2 -0
  78. package/dist/ledger.js +1 -1
  79. package/dist/ledger.js.map +1 -1
  80. package/dist/lint.d.ts +0 -9
  81. package/dist/lint.js +1 -16
  82. package/dist/lint.js.map +1 -1
  83. package/dist/load.d.ts +74 -0
  84. package/dist/load.js +357 -0
  85. package/dist/load.js.map +1 -0
  86. package/dist/lockfile.d.ts +2 -0
  87. package/dist/lockfile.js +8 -3
  88. package/dist/lockfile.js.map +1 -1
  89. package/dist/op.d.ts +10 -1
  90. package/dist/op.js +1 -1
  91. package/dist/op.js.map +1 -1
  92. package/dist/outline.js +13 -4
  93. package/dist/outline.js.map +1 -1
  94. package/dist/paths.d.ts +18 -0
  95. package/dist/paths.js +21 -3
  96. package/dist/paths.js.map +1 -1
  97. package/dist/plugin.d.ts +0 -11
  98. package/dist/plugin.js +3 -18
  99. package/dist/plugin.js.map +1 -1
  100. package/dist/policy.d.ts +2 -0
  101. package/dist/policy.js.map +1 -1
  102. package/dist/resource.d.ts +27 -4
  103. package/dist/resource.js +49 -4
  104. package/dist/resource.js.map +1 -1
  105. package/dist/resources.d.ts +33 -76
  106. package/dist/resources.js +219 -227
  107. package/dist/resources.js.map +1 -1
  108. package/dist/run.d.ts +2 -0
  109. package/dist/run.js.map +1 -1
  110. package/dist/sandbox.d.ts +1 -1
  111. package/dist/sandbox.js +7 -3
  112. package/dist/sandbox.js.map +1 -1
  113. package/dist/scenario.d.ts +7 -0
  114. package/dist/scenario.js +33 -3
  115. package/dist/scenario.js.map +1 -1
  116. package/dist/scenarios.js +7 -1
  117. package/dist/scenarios.js.map +1 -1
  118. package/dist/schema.js +8 -0
  119. package/dist/schema.js.map +1 -1
  120. package/dist/secret-store-check.d.ts +11 -0
  121. package/dist/secret-store-check.js +76 -0
  122. package/dist/secret-store-check.js.map +1 -0
  123. package/dist/secret-store.d.ts +106 -0
  124. package/dist/secret-store.js +228 -0
  125. package/dist/secret-store.js.map +1 -0
  126. package/dist/shared.d.ts +17 -11
  127. package/dist/shared.js +34 -7
  128. package/dist/shared.js.map +1 -1
  129. package/dist/sites.d.ts +4 -93
  130. package/dist/sites.js +61 -84
  131. package/dist/sites.js.map +1 -1
  132. package/dist/source.d.ts +6 -0
  133. package/dist/source.js +13 -0
  134. package/dist/source.js.map +1 -1
  135. package/dist/steps.d.ts +3 -1
  136. package/dist/steps.js.map +1 -1
  137. package/dist/version.d.ts +2 -1
  138. package/dist/version.js +2 -1
  139. package/dist/version.js.map +1 -1
  140. package/dist/wakeup-driver.js +15 -6
  141. package/dist/wakeup-driver.js.map +1 -1
  142. package/dist/worker.js +12 -8
  143. package/dist/worker.js.map +1 -1
  144. package/dist/writer.d.ts +53 -14
  145. package/dist/writer.js +113 -43
  146. package/dist/writer.js.map +1 -1
  147. package/oxlint.json +0 -5
  148. package/package.json +6 -2
  149. package/src/apply-steps.ts +5 -2
  150. package/src/apply.ts +70 -29
  151. package/src/approvals.ts +7 -1
  152. package/src/apps.ts +54 -85
  153. package/src/ast.ts +6 -6
  154. package/src/authentication.ts +74 -0
  155. package/src/call-workflow.ts +5 -4
  156. package/src/call.ts +53 -9
  157. package/src/cedar.ts +14 -1
  158. package/src/change-steps.ts +7 -4
  159. package/src/change.ts +10 -0
  160. package/src/client.ts +108 -41
  161. package/src/collection.ts +10 -2
  162. package/src/config.ts +72 -13
  163. package/src/connection.ts +446 -0
  164. package/src/credentials.ts +94 -281
  165. package/src/cucumber.ts +16 -3
  166. package/src/db.ts +21 -2
  167. package/src/define.ts +7 -2
  168. package/src/definitions.ts +31 -0
  169. package/src/deployment-key.ts +23 -0
  170. package/src/describe.ts +102 -230
  171. package/src/description.ts +426 -0
  172. package/src/direct.ts +3 -0
  173. package/src/drift.ts +10 -2
  174. package/src/env.ts +4 -2
  175. package/src/index.ts +42 -1
  176. package/src/ledger.ts +3 -1
  177. package/src/lint.ts +1 -16
  178. package/src/load.ts +437 -0
  179. package/src/lockfile.ts +10 -3
  180. package/src/op.ts +10 -1
  181. package/src/outline.ts +18 -5
  182. package/src/paths.ts +24 -2
  183. package/src/plugin.ts +3 -24
  184. package/src/policy.ts +2 -0
  185. package/src/resource.ts +67 -6
  186. package/src/resources.ts +246 -301
  187. package/src/run.ts +2 -0
  188. package/src/sandbox.ts +11 -3
  189. package/src/scenario.ts +45 -3
  190. package/src/scenarios.ts +7 -1
  191. package/src/schema.ts +10 -0
  192. package/src/secret-store-check.ts +93 -0
  193. package/src/secret-store.ts +314 -0
  194. package/src/shared.ts +35 -10
  195. package/src/sites.ts +70 -180
  196. package/src/source.ts +14 -0
  197. package/src/steps.ts +3 -1
  198. package/src/version.ts +2 -1
  199. package/src/wakeup-driver.ts +22 -6
  200. package/src/worker.ts +12 -8
  201. package/src/writer.ts +145 -56
  202. package/dist/datafile.d.ts +0 -97
  203. package/dist/datafile.js +0 -479
  204. package/dist/datafile.js.map +0 -1
  205. package/src/datafile.ts +0 -601
@@ -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,
@@ -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" },
package/src/change.ts CHANGED
@@ -320,6 +320,16 @@ export const ADMIN = "admin";
320
320
  */
321
321
  export const isRules = (path: string) =>
322
322
  path === "policies/" || path === "resources/people.ts" || /^apps\/[^/]+\/policies\/$/.test(path);
323
+ /**
324
+ * Whether a data file's text imports the runtime's constructors, `sanoma` (by any local name, or
325
+ * the whole entry, `* as`, from which it can reach them): a file that may declare a person, agent
326
+ * or group, which `readPeople` loads and a change to which is a change to the rules. Read from the
327
+ * text, so a draft's file is told without running it.
328
+ */
329
+ export const mayDeclarePeople = (text: string): boolean =>
330
+ /(?:\{[^}]*\bsanoma\b[^}]*\}|\*(?:\s*as\s+[\w$]+)?)\s*from\s*["'](?:sanoma|@sanoma-ai\/workflows)\/resources["']/.test(
331
+ text,
332
+ );
323
333
  /** The runtime rule's id, as a hold's `reasons` name it. */
324
334
  const RULES_WAIT_FOR_ADMIN = "sanoma:rules-wait-for-admin";
325
335
 
package/src/client.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { connectionDriver, type ConnectionRef, type ConnectionSnapshot } from "./connection.ts";
1
2
  import { randomUUID } from "node:crypto";
2
3
  import { isDeepStrictEqual } from "node:util";
3
4
  import { type DBOSClient, Error as DBOSErrors, type WorkflowStatusString } from "@dbos-inc/dbos-sdk";
@@ -16,7 +17,7 @@ import { beforeOf, decideCall, decideStart, recorded, startHoldOf } from "./call
16
17
  import { type Chats, chatStore } from "./chats.ts";
17
18
  import { collectionsDriver } from "./collection.ts";
18
19
  import { type ResolvedConfig, resolveConfig, type SanomaConfig } from "./config.ts";
19
- import { deleteCredential, storeCredential, storedCredentials, storedSecrets } from "./credentials.ts";
20
+ import { configuredSecrets } from "./credentials.ts";
20
21
  import { dbosClient, lazyPool, type LazyPool } from "./db.ts";
21
22
  import { type CredentialStatus, credentialsOf } from "./env.ts";
22
23
  import { type ApprovalState, Principal, type WorkflowDefinition } from "./define.ts";
@@ -52,7 +53,7 @@ import type { CallContext, Driver, Op } from "./op.ts";
52
53
  import { allowAll, type RecordedDecision } from "./policy.ts";
53
54
  import { admit, readPeople } from "./resources.ts";
54
55
  import type { RunArgs, StartHold } from "./run.ts";
55
- import type { Secrets } from "./secret.ts";
56
+ import type { SecretAccess, SecretLocation } from "./secret-store.ts";
56
57
  import {
57
58
  BUILT_IN_WORKFLOWS,
58
59
  CALL_WORKFLOW,
@@ -206,6 +207,7 @@ export interface ChangesFilter {
206
207
 
207
208
  /** What `SanomaClient.call` takes besides the operation's id and its input. */
208
209
  export interface CallOptions {
210
+ connection?: ConnectionRef;
209
211
  /**
210
212
  * Who calls, by id, admitted as `startedBy` is: the policy decides as them, a write's record
211
213
  * names them, and a save's `updatedBy` is their id.
@@ -243,7 +245,7 @@ export class SanomaClient {
243
245
  /** On the config's database, for the runtime's tables (the credentials, the chats): opened on first use. */
244
246
  private readonly pool: LazyPool;
245
247
  /** What a direct call's driver reads secrets from, as a worker's live driver does. */
246
- private readonly storedSecrets: Secrets;
248
+ private readonly storedSecrets: SecretAccess;
247
249
  /** The operator agent's chats: their rows and their transcripts, in the config's database. */
248
250
  readonly chats: Chats;
249
251
  private readonly options: ClientOptions;
@@ -260,7 +262,7 @@ export class SanomaClient {
260
262
  this.options = options;
261
263
  this.audit = ownedLedger(config);
262
264
  this.pool = lazyPool(config.databaseUrl, "client");
263
- this.storedSecrets = storedSecrets(() => this.db(), "sanoma");
265
+ this.storedSecrets = configuredSecrets(config.secrets, () => this.db());
264
266
  this.chats = chatStore(() => this.db());
265
267
  }
266
268
 
@@ -272,7 +274,14 @@ export class SanomaClient {
272
274
  static async connect(config: SanomaConfig, options: ClientOptions = {}): Promise<SanomaClient> {
273
275
  const resolved = resolveConfig(config);
274
276
  const dbos = await dbosClient(resolved.databaseUrl, resolved.appName);
275
- return new SanomaClient(dbos, resolved, resolved.driversByVendor, options);
277
+ const client = new SanomaClient(dbos, resolved, resolved.driversByVendor, options);
278
+ try {
279
+ await client.storedSecrets.validate();
280
+ return client;
281
+ } catch (error) {
282
+ await client.close();
283
+ throw error;
284
+ }
276
285
  }
277
286
 
278
287
  /**
@@ -414,14 +423,21 @@ export class SanomaClient {
414
423
  const fn = drivers.get(op.id);
415
424
  if (!fn) throw new SanomaError("invalid_input", `${op.id} has no driver: the connector package must attach one`);
416
425
  const parsed = parseOrThrow(op.input, input, `The input to ${op.id} does not match its schema`, { op: op.id });
426
+ const connection = await this.config.connections.select(op.vendor, options.connection, actor.id);
427
+ const selected = connection && {
428
+ $connection: connection.id,
429
+ vendor: connection.vendor,
430
+ revision: connection.revision,
431
+ };
417
432
  const runId = `direct:${actor.id}:${id}`;
418
433
  const heldId = `call:${actor.id}:${id}`;
419
- const held: RunArgs = { input: { op: op.id, input }, startedBy: actor };
434
+ const held: RunArgs = { input: { op: op.id, input, ...(selected && { connection: selected }) }, startedBy: actor };
420
435
  const logged = recorded(op, parsed);
421
436
  const write = op.effect !== "read";
422
437
  // Only an id the caller gave can have been used: a write made, or held, with it settles it.
423
438
  if (write && options.id !== undefined) {
424
- const first = (await this.recordedCall(runId, op.id, logged)) ?? (await this.heldCall(op.id, heldId, held));
439
+ const first =
440
+ (await this.recordedCall(runId, op.id, logged, connection)) ?? (await this.heldCall(op.id, heldId, held));
425
441
  if (first) return first.output;
426
442
  }
427
443
  const before = await beforeOf(policy, op, parsed, this.config.collections, (get, key) =>
@@ -431,7 +447,7 @@ export class SanomaClient {
431
447
  ),
432
448
  );
433
449
  const run = { id: runId, workflow: CALL_WORKFLOW, approvals: [] };
434
- const decision = decideCall(policy, op, parsed, actor, run, before);
450
+ const decision = decideCall(policy, op, parsed, actor, run, { ...before, ...(connection && { connection }) });
435
451
  if (decision.kind === "deny") throw new PolicyDeniedError(op.id, decision.reason);
436
452
  if (decision.kind === "approve") {
437
453
  await this.start(CALL_WORKFLOW, held.input, { startedBy: actor, runId: heldId });
@@ -445,7 +461,10 @@ export class SanomaClient {
445
461
  attempt: 1,
446
462
  secrets: this.storedSecrets,
447
463
  by: actor.id,
464
+ principal: actor,
465
+ ...(connection && { connection }),
448
466
  };
467
+ await this.config.connections.assertCurrent(connection, actor.id);
449
468
  if (!write) return outputOf(op, () => fn(parsed, context));
450
469
  return this.directWrite({ op, fn, parsed, logged, actor, decision, context });
451
470
  }
@@ -469,13 +488,22 @@ export class SanomaClient {
469
488
  const { op, parsed, logged, actor, decision, context } = call;
470
489
  const { runId } = context;
471
490
  const { dataStore, collections, ledger } = this.config;
472
- const conn = op.vendor === DATA_VENDOR && !ledger && dataStore ? await (await dataStore.db()).connect() : undefined;
491
+ const connectionMutation = op.id.startsWith("sanoma.connection.");
492
+ const transactionStore = connectionMutation
493
+ ? this.config.connectionPool
494
+ : op.vendor === DATA_VENDOR
495
+ ? dataStore
496
+ : undefined;
497
+ const conn = !ledger && transactionStore ? await (await transactionStore.db()).connect() : undefined;
473
498
  let broken: Error | undefined;
474
499
  try {
475
500
  await conn?.query("BEGIN");
476
- // On the transaction's connection, the collection's own driver: it has no `env` to guard.
501
+ // The metadata or collection driver shares the ledger transaction; neither has an env guard.
477
502
  const fn = conn
478
- ? collectionsDriver([collections.get(op.resource)!], { db: async () => conn }).ops[`${op.resource}.${op.name}`]!
503
+ ? (connectionMutation
504
+ ? connectionDriver(this.config.connections.onDatabase(conn))
505
+ : collectionsDriver([collections.get(op.resource)!], { db: async () => conn })
506
+ ).ops[`${op.resource}.${op.name}`]!
479
507
  : call.fn;
480
508
  const at = Date.now();
481
509
  const record = (outcome: { output: unknown } | { error: ErrorInfo }): LedgerRecord => ({
@@ -492,6 +520,7 @@ export class SanomaClient {
492
520
  effect: op.effect,
493
521
  input: logged,
494
522
  decision,
523
+ ...(context.connection && { connection: context.connection }),
495
524
  ...outcome,
496
525
  durationMs: Date.now() - at,
497
526
  });
@@ -513,7 +542,7 @@ export class SanomaClient {
513
542
  return output;
514
543
  }
515
544
  await conn.query("ROLLBACK");
516
- return (await this.recordedCall(runId, op.id, logged))!.output;
545
+ return (await this.recordedCall(runId, op.id, logged, context.connection))!.output;
517
546
  } catch (err) {
518
547
  await conn?.query("ROLLBACK").catch((e: Error) => (broken = e));
519
548
  throw err;
@@ -526,10 +555,19 @@ export class SanomaClient {
526
555
  * What the direct write recorded as `runId` returned, when one did: refused (`invalid_input`)
527
556
  * when it was of another operation or input, and its failure again (`driver_failed`) when it failed.
528
557
  */
529
- private async recordedCall(runId: string, opId: string, input: unknown): Promise<{ output: unknown } | undefined> {
558
+ private async recordedCall(
559
+ runId: string,
560
+ opId: string,
561
+ input: unknown,
562
+ connection?: ConnectionSnapshot,
563
+ ): Promise<{ output: unknown } | undefined> {
530
564
  const first = (await this.audit.store.read(runId)).find((r) => r.type === "op.called");
531
565
  if (!first) return undefined;
532
- const differs = [...(first.op === opId ? [] : ["op"]), ...(sameJson(first.input, input) ? [] : ["input"])];
566
+ const differs = [
567
+ ...(first.op === opId ? [] : ["op"]),
568
+ ...(sameJson(first.input, input) ? [] : ["input"]),
569
+ ...(sameJson(first.connection ?? null, connection ?? null) ? [] : ["connection"]),
570
+ ];
533
571
  if (differs.length) {
534
572
  throw new SanomaError(
535
573
  "invalid_input",
@@ -1061,7 +1099,8 @@ export class SanomaClient {
1061
1099
  issues: [{ path: ["value"], message: problem, code: "custom" }],
1062
1100
  });
1063
1101
  }
1064
- await storeCredential(await this.db(), { name, value, setBy });
1102
+ const current = await this.storedSecrets.read(name);
1103
+ await this.storedSecrets.write(name, value, { expectedVersion: current?.version ?? null, by: setBy });
1065
1104
  }
1066
1105
 
1067
1106
  /**
@@ -1072,7 +1111,8 @@ export class SanomaClient {
1072
1111
  async clearCredential(name: string, options: { by: string }): Promise<void> {
1073
1112
  await this.admittedBy(options);
1074
1113
  this.declared(name, () => undefined);
1075
- await deleteCredential(await this.db(), name);
1114
+ const current = await this.storedSecrets.read(name);
1115
+ if (current) await this.storedSecrets.delete(name, { expectedVersion: current.version, by: options.by });
1076
1116
  }
1077
1117
 
1078
1118
  /**
@@ -1091,7 +1131,21 @@ export class SanomaClient {
1091
1131
  const path = VARIABLE.test(name) ? "value" : "name";
1092
1132
  throw new SanomaError("invalid_input", problem, { issues: [{ path: [path], message: problem, code: "custom" }] });
1093
1133
  }
1094
- await storeCredential(await this.db(), { name, value, setBy });
1134
+ const current = await this.storedSecrets.read(name);
1135
+ await this.storedSecrets.write(name, value, { expectedVersion: current?.version ?? null, by: setBy });
1136
+ }
1137
+
1138
+ /** Removes a logical secret from the selected store; external bindings remain read-only. */
1139
+ async clearSecret(name: string, options: { by: string }): Promise<void> {
1140
+ const by = await this.admittedBy(options);
1141
+ if (!VARIABLE.test(name)) {
1142
+ const message = "That is not a variable name";
1143
+ throw new SanomaError("invalid_input", message, {
1144
+ issues: [{ path: ["name"], message, code: "custom" }],
1145
+ });
1146
+ }
1147
+ const current = await this.storedSecrets.read(name);
1148
+ if (current) await this.storedSecrets.delete(name, { expectedVersion: current.version, by });
1095
1149
  }
1096
1150
 
1097
1151
  /**
@@ -1100,40 +1154,51 @@ export class SanomaClient {
1100
1154
  * is left out. For server code: a page is told whether one is set, never its value.
1101
1155
  */
1102
1156
  async secrets(names: readonly string[]): Promise<Map<string, StoredSecret>> {
1103
- const rows = await storedCredentials(await this.db(), names);
1104
1157
  const found = new Map<string, StoredSecret>();
1105
1158
  for (const name of names) {
1106
- const env = process.env[name];
1107
- const row = rows.get(name);
1108
- if (env && env !== row?.value) found.set(name, { value: env, source: "environment" });
1109
- else if (row) found.set(name, { ...row, source: "stored" });
1159
+ const row = await this.storedSecrets.read(name);
1160
+ const location = this.storedSecrets.location(name);
1161
+ if (row)
1162
+ found.set(name, {
1163
+ value: row.value,
1164
+ source: location.store === "environment" ? "environment" : "stored",
1165
+ version: row.version,
1166
+ location,
1167
+ ...(row.updatedBy && { setBy: row.updatedBy }),
1168
+ ...(row.updatedAt && { setAt: row.updatedAt }),
1169
+ });
1110
1170
  }
1111
1171
  return found;
1112
1172
  }
1113
1173
 
1114
- /**
1115
- * Each vendor's declared variables and their statuses now: from this process's environment,
1116
- * which wins, else from the stored credentials, with `source` saying which (a value equal to
1117
- * the stored one, as a worker in this process loads it, is `stored`), and who set a stored one
1118
- * and when. Never a value.
1119
- */
1174
+ /** Non-secret statuses for the selected store and declared bindings. */
1120
1175
  async credentials(): Promise<Map<string, CredentialStatus[]>> {
1121
1176
  const names = [...credentialsOf(this.drivers, () => undefined).values()].flat().map((c) => c.name);
1122
- if (!names.length) return new Map();
1123
- const rows = await storedCredentials(await this.db(), names);
1124
- const statuses = credentialsOf(this.drivers, (name) => process.env[name] || rows.get(name)?.value);
1125
- const sourced = (c: CredentialStatus): CredentialStatus => {
1126
- const row = rows.get(c.name);
1127
- const env = process.env[c.name];
1128
- // A worker in this process loads the stored values into its environment: those are stored.
1129
- if (env && env !== row?.value) return { ...c, source: "environment" };
1130
- return row ? { ...c, source: "stored", setBy: row.setBy, setAt: row.setAt } : c;
1131
- };
1132
- return new Map([...statuses].map(([vendor, list]) => [vendor, list.map(sourced)]));
1177
+ const rows = await this.secrets(names);
1178
+ const statuses = credentialsOf(this.drivers, (name) => rows.get(name)?.value);
1179
+ return new Map(
1180
+ [...statuses].map(([vendor, list]) => [
1181
+ vendor,
1182
+ list.map((c) => {
1183
+ const row = rows.get(c.name);
1184
+ return {
1185
+ ...c,
1186
+ location: this.storedSecrets.location(c.name),
1187
+ ...(row && { source: row.source, setBy: row.setBy, setAt: row.setAt }),
1188
+ };
1189
+ }),
1190
+ ]),
1191
+ );
1133
1192
  }
1134
1193
 
1135
1194
  async close() {
1136
- await Promise.all([this.dbos.destroy(), this.pool.close(), this.audit.close(), this.config.dataStore?.close()]);
1195
+ await Promise.all([
1196
+ this.dbos.destroy(),
1197
+ this.pool.close(),
1198
+ this.audit.close(),
1199
+ this.config.dataStore?.close(),
1200
+ this.config.connectionPool.close(),
1201
+ ]);
1137
1202
  }
1138
1203
 
1139
1204
  /**
@@ -1285,6 +1350,8 @@ const sameJson = (a: unknown, b: unknown): boolean => isDeepStrictEqual(asJson(a
1285
1350
 
1286
1351
  /** A secret's value as `SanomaClient.secrets` reads it, and where from. */
1287
1352
  export interface StoredSecret {
1353
+ version?: string;
1354
+ location?: SecretLocation;
1288
1355
  value: string;
1289
1356
  source: "environment" | "stored";
1290
1357
  /** Who stored it (`setSecret`'s `by`), and when, as an ISO 8601 time. */
@@ -1293,7 +1360,7 @@ export interface StoredSecret {
1293
1360
  }
1294
1361
 
1295
1362
  /** What an environment variable's name looks like. */
1296
- const VARIABLE = /^[A-Z_][A-Z0-9_]*$/;
1363
+ const VARIABLE = /^[A-Z][A-Z0-9_]*$/;
1297
1364
 
1298
1365
  const alreadyDecided = (runId: string, a: ApprovalState) =>
1299
1366
  new SanomaError(
package/src/collection.ts CHANGED
@@ -99,10 +99,18 @@ export function collection<
99
99
  const F extends Record<string, Field>,
100
100
  const K extends RequiredTextKeys<F>,
101
101
  >(declaration: { name: N; key: K; fields: F; title?: string }): Collection<N, F, K> {
102
+ // Frame 2 of `callerFile` is this function's caller: the data file.
103
+ return collectionIn(appOfFile(callerFile()), declaration);
104
+ }
105
+
106
+ /** `collection()` in the app `app` names (none for the root's): what the loader's `collection` calls, knowing the file itself. */
107
+ export function collectionIn<
108
+ const N extends string,
109
+ const F extends Record<string, Field>,
110
+ const K extends RequiredTextKeys<F>,
111
+ >(app: string | undefined, declaration: { name: N; key: K; fields: F; title?: string }): Collection<N, F, K> {
102
112
  checkCollection(declaration);
103
113
  const { key, fields, title } = declaration;
104
- // Frame 2 of `callerFile` is this function's caller: the data file.
105
- const app = appOfFile(callerFile());
106
114
  const name = qualified(app, declaration.name);
107
115
  const record = z.strictObject(
108
116
  Object.fromEntries(