@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
@@ -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(
package/src/config.ts CHANGED
@@ -1,3 +1,14 @@
1
+ import { configuredSecrets } from "./credentials.ts";
2
+ import { currentRun } from "./run.ts";
3
+ import {
4
+ connectionCatalog,
5
+ ConnectionRegistry,
6
+ connectionDriver,
7
+ connectionOps,
8
+ type ConnectionDefinition,
9
+ type ConnectionRule,
10
+ } from "./connection.ts";
11
+ import { checkSecretConfiguration, type SecretConfiguration } from "./secret-store.ts";
1
12
  import { existsSync, readFileSync } from "node:fs";
2
13
  import { dirname, join, resolve } from "node:path";
3
14
  import { pathToFileURL } from "node:url";
@@ -81,6 +92,10 @@ export interface SanomaConfig {
81
92
  * only, for tests.
82
93
  */
83
94
  ledger?: LedgerStore;
95
+ /** Credential storage and explicit bindings. Omitted: encrypted Postgres, no environment fallback. */
96
+ secrets?: SecretConfiguration;
97
+ connections?: readonly ConnectionDefinition[];
98
+ connectionRules?: readonly ConnectionRule[];
84
99
  /** Postgres for the runtime. Defaults to `SANOMA_DATABASE_URL`, then `DEFAULT_DATABASE_URL`. */
85
100
  databaseUrl?: string;
86
101
  /** The file that defined the config, absolute, when known. Set by `defineConfig` from its call site. */
@@ -118,6 +133,9 @@ export interface SanomaConfig {
118
133
 
119
134
  /** A config, checked, with everything a worker, client or app derives from it. */
120
135
  export interface ResolvedConfig {
136
+ secrets: SecretConfiguration;
137
+ connections: ConnectionRegistry;
138
+ connectionPool: LazyPool;
121
139
  /**
122
140
  * Scopes workflows, queues and versions in the system database: the `name` of
123
141
  * `<root>/package.json` when it names one, else "sanoma".
@@ -252,6 +270,7 @@ export function rootOf(config: Pick<SanomaConfig, "root" | "file">): string | un
252
270
  * `startApp`, the `sanoma` bin when it loads the config.
253
271
  */
254
272
  export function resolveConfig(config: SanomaConfig): ResolvedConfig {
273
+ checkSecretConfiguration(config.secrets ?? {});
255
274
  if (!Array.isArray(config.connectors)) {
256
275
  throw new Error("The config needs `connectors`: the connectors its workflows call, as their packages export them");
257
276
  }
@@ -280,6 +299,12 @@ export function resolveConfig(config: SanomaConfig): ResolvedConfig {
280
299
  const hostTyped = [...types.values()].some((type) => type.host);
281
300
  // Built-in: the collections' operations, `data.<name>.<op>`, on the config's database.
282
301
  const databaseUrl = resolveDatabaseUrl(config);
302
+ const connectionPool = lazyPool(databaseUrl, "connections");
303
+ const connections = new ConnectionRegistry(
304
+ connectionCatalog(config.connectors, config.connections, config.connectionRules),
305
+ () => connectionPool.db(),
306
+ configuredSecrets(config.secrets ?? {}, () => connectionPool.db()),
307
+ );
283
308
  const declared = [...collections.values()];
284
309
  const dataStore = declared.length ? lazyPool(databaseUrl, "data") : undefined;
285
310
  // Built-in, in every config: a change's operations, which the template's policy names
@@ -287,6 +312,7 @@ export function resolveConfig(config: SanomaConfig): ResolvedConfig {
287
312
  const ops = indexConnectors([
288
313
  ...running,
289
314
  changeOps,
315
+ connectionOps,
290
316
  appsListOps,
291
317
  ...(hostTyped ? [sourceOps] : []),
292
318
  ...(dataStore ? [collectionsConnector(declared)] : []),
@@ -299,6 +325,7 @@ export function resolveConfig(config: SanomaConfig): ResolvedConfig {
299
325
  [
300
326
  ...driversByVendor.values(),
301
327
  changeDriver(remotes),
328
+ connectionDriver(connections),
302
329
  appsListDriver,
303
330
  ...(hostTyped ? [sourceDriver] : []),
304
331
  ...(dataStore ? [collectionsDriver(declared, dataStore)] : []),
@@ -326,7 +353,7 @@ export function resolveConfig(config: SanomaConfig): ResolvedConfig {
326
353
  const connectors = connectorsOf(config);
327
354
  // The types the data files may declare: the vendors' and the people's, whose resources are left out.
328
355
  const declarable = resourceTypesOf(connectors);
329
- const read = () => readDeclared(root, connectors, declarable, types);
356
+ const read = () => readDeclared(root, connectors, declarable, types, connections);
330
357
  if (types.size) {
331
358
  names.set(
332
359
  DRIFT_WORKFLOW,
@@ -343,6 +370,9 @@ export function resolveConfig(config: SanomaConfig): ResolvedConfig {
343
370
  if (call.uses.length) names.set(CALL_WORKFLOW, call);
344
371
  const { policy, policies, problems: policyProblems } = policyAt(root, { ops, workflows: names, collections });
345
372
  return {
373
+ secrets: config.secrets ?? {},
374
+ connections,
375
+ connectionPool,
346
376
  appName,
347
377
  databaseUrl,
348
378
  // The built-in's code and operations are part of the app's version, like the config's own.
@@ -495,9 +525,9 @@ export async function declaredWakeup(config: SanomaConfig): Promise<DeclaredWake
495
525
  const connectors = connectorsOf(config);
496
526
  const types = resourceTypesOf(connectors);
497
527
  if (![...types.values()].some((type) => type.wakeup)) return undefined;
498
- // Loaded when it is needed, not with the worker: the reader parses with oxc-parser.
499
- const { readDataFiles } = await import("./resources.ts");
500
- const { resources } = readDataFiles(rootOf(config), connectors, types);
528
+ // Loaded when it is needed, not with the worker.
529
+ const { loadDataFiles } = await import("./resources.ts");
530
+ const { resources } = loadDataFiles(rootOf(config), connectors, types);
501
531
  const typeOf = (r: DeclaredResource) => types.get(`${r.vendor}.${r.type}`)!;
502
532
  const declared = resources.find((r) => typeOf(r).wakeup);
503
533
  if (!declared) return undefined;
@@ -523,15 +553,38 @@ async function readDeclared(
523
553
  connectors: readonly Connector<any, any>[],
524
554
  declarable: ReadonlyMap<string, Resource>,
525
555
  types: ReadonlyMap<string, Resource>,
556
+ connections: ConnectionRegistry,
526
557
  ): Promise<Omit<ApplyDeclared, "rebinds">> {
527
- // Loaded when a run reads the files, not with the worker: the reader parses with oxc-parser.
528
- const { readDataFiles } = await import("./resources.ts");
529
- const { resources, bindings, orphans, problems } = readDataFiles(root, connectors, declarable);
558
+ // Loaded when a run reads the files, not with the worker.
559
+ const { loadDataFiles } = await import("./resources.ts");
560
+ const { resources, bindings, orphans, problems } = loadDataFiles(root, connectors, declarable);
561
+ // Read inside the data-file durable step: every operation of the plan uses one pinned account.
562
+ const run = currentRun.getStore();
563
+ if (run && !run.sandbox) {
564
+ for (const resource of resources.filter((r) => types.has(`${r.vendor}.${r.type}`))) {
565
+ try {
566
+ const selected = await connections.select(resource.vendor, resource.connection, run.actor.id);
567
+ if (selected) {
568
+ const pinned = { vendor: selected.vendor, $connection: selected.id, revision: selected.revision };
569
+ const locked = bindings[resource.id]?.connection;
570
+ if (locked && (locked.$connection !== pinned.$connection || locked.revision !== pinned.revision))
571
+ throw new Error("The bound resource's account changed; explicitly unbind before using a new account");
572
+ resource.connection = pinned;
573
+ }
574
+ } catch {
575
+ problems.push({
576
+ file: resource.file,
577
+ message: `${resource.id}: its connection is unavailable or changed; reconnect and explicitly rebind`,
578
+ });
579
+ resources.splice(resources.indexOf(resource), 1);
580
+ }
581
+ }
582
+ }
530
583
  return {
531
584
  // Only `types`' resources have a vendor object to check or apply: not the people's (vendorless).
532
585
  resources: resources
533
586
  .filter((r) => types.has(`${r.vendor}.${r.type}`))
534
- .map(({ id, vendor, type, name, identity, binding, desired, refs }) => ({
587
+ .map(({ id, vendor, type, name, identity, binding, desired, refs, connection }) => ({
535
588
  id,
536
589
  vendor,
537
590
  type,
@@ -540,6 +593,7 @@ async function readDeclared(
540
593
  ...(binding !== undefined && { binding }),
541
594
  desired: withoutWriteOnly(types.get(`${vendor}.${type}`)!.fields, desired),
542
595
  refs,
596
+ ...(connection && { connection }),
543
597
  })),
544
598
  bindings,
545
599
  orphans,