@sanoma-ai/workflows 0.9.1 → 0.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (207) hide show
  1. package/README.md +193 -50
  2. package/SECRET-SUPPORT.md +33 -0
  3. package/dist/apply-steps.js +5 -2
  4. package/dist/apply-steps.js.map +1 -1
  5. package/dist/apply.d.ts +3 -0
  6. package/dist/apply.js +27 -13
  7. package/dist/apply.js.map +1 -1
  8. package/dist/approvals.d.ts +1 -0
  9. package/dist/approvals.js +6 -1
  10. package/dist/approvals.js.map +1 -1
  11. package/dist/apps.d.ts +13 -24
  12. package/dist/apps.js +69 -68
  13. package/dist/apps.js.map +1 -1
  14. package/dist/ast.d.ts +9 -7
  15. package/dist/ast.js +2 -0
  16. package/dist/ast.js.map +1 -1
  17. package/dist/authentication.d.ts +54 -0
  18. package/dist/authentication.js +138 -0
  19. package/dist/authentication.js.map +1 -0
  20. package/dist/call-workflow.d.ts +5 -0
  21. package/dist/call-workflow.js +4 -3
  22. package/dist/call-workflow.js.map +1 -1
  23. package/dist/call.d.ts +3 -2
  24. package/dist/call.js +36 -7
  25. package/dist/call.js.map +1 -1
  26. package/dist/cedar.js +14 -1
  27. package/dist/cedar.js.map +1 -1
  28. package/dist/change-steps.js +7 -5
  29. package/dist/change-steps.js.map +1 -1
  30. package/dist/change.d.ts +7 -0
  31. package/dist/change.js +7 -0
  32. package/dist/change.js.map +1 -1
  33. package/dist/client.d.ts +62 -10
  34. package/dist/client.js +272 -47
  35. package/dist/client.js.map +1 -1
  36. package/dist/collection.d.ts +7 -0
  37. package/dist/collection.js +5 -2
  38. package/dist/collection.js.map +1 -1
  39. package/dist/config.d.ts +16 -4
  40. package/dist/config.js +89 -21
  41. package/dist/config.js.map +1 -1
  42. package/dist/connection.d.ts +389 -0
  43. package/dist/connection.js +657 -0
  44. package/dist/connection.js.map +1 -0
  45. package/dist/credentials.d.ts +7 -53
  46. package/dist/credentials.js +84 -247
  47. package/dist/credentials.js.map +1 -1
  48. package/dist/db.js +34 -2
  49. package/dist/db.js.map +1 -1
  50. package/dist/define.d.ts +9 -2
  51. package/dist/define.js.map +1 -1
  52. package/dist/definitions.d.ts +20 -0
  53. package/dist/definitions.js +16 -0
  54. package/dist/definitions.js.map +1 -0
  55. package/dist/deployment-key.d.ts +2 -0
  56. package/dist/deployment-key.js +22 -0
  57. package/dist/deployment-key.js.map +1 -0
  58. package/dist/describe.d.ts +15 -175
  59. package/dist/describe.js +122 -35
  60. package/dist/describe.js.map +1 -1
  61. package/dist/description.d.ts +532 -0
  62. package/dist/description.js +381 -0
  63. package/dist/description.js.map +1 -0
  64. package/dist/direct.js +7 -0
  65. package/dist/direct.js.map +1 -1
  66. package/dist/drift.d.ts +1 -1
  67. package/dist/drift.js +1 -1
  68. package/dist/drift.js.map +1 -1
  69. package/dist/env.d.ts +2 -0
  70. package/dist/env.js +2 -2
  71. package/dist/env.js.map +1 -1
  72. package/dist/fake.d.ts +13 -1
  73. package/dist/fake.js +70 -0
  74. package/dist/fake.js.map +1 -1
  75. package/dist/index.d.ts +11 -3
  76. package/dist/index.js +10 -2
  77. package/dist/index.js.map +1 -1
  78. package/dist/ledger.d.ts +2 -0
  79. package/dist/ledger.js +1 -1
  80. package/dist/ledger.js.map +1 -1
  81. package/dist/lint.d.ts +0 -9
  82. package/dist/lint.js +2 -17
  83. package/dist/lint.js.map +1 -1
  84. package/dist/load.d.ts +85 -0
  85. package/dist/load.js +391 -0
  86. package/dist/load.js.map +1 -0
  87. package/dist/lockfile.d.ts +2 -0
  88. package/dist/lockfile.js +8 -3
  89. package/dist/lockfile.js.map +1 -1
  90. package/dist/op.d.ts +22 -5
  91. package/dist/op.js +20 -2
  92. package/dist/op.js.map +1 -1
  93. package/dist/outline.d.ts +5 -0
  94. package/dist/outline.js +46 -5
  95. package/dist/outline.js.map +1 -1
  96. package/dist/paths.d.ts +18 -2
  97. package/dist/paths.js +19 -7
  98. package/dist/paths.js.map +1 -1
  99. package/dist/plugin.d.ts +0 -11
  100. package/dist/plugin.js +3 -18
  101. package/dist/plugin.js.map +1 -1
  102. package/dist/policy.d.ts +2 -0
  103. package/dist/policy.js.map +1 -1
  104. package/dist/resource.d.ts +10 -4
  105. package/dist/resource.js +11 -4
  106. package/dist/resource.js.map +1 -1
  107. package/dist/resources.d.ts +33 -76
  108. package/dist/resources.js +219 -227
  109. package/dist/resources.js.map +1 -1
  110. package/dist/run.d.ts +4 -0
  111. package/dist/run.js.map +1 -1
  112. package/dist/sandbox.d.ts +5 -2
  113. package/dist/sandbox.js +56 -7
  114. package/dist/sandbox.js.map +1 -1
  115. package/dist/scenario.d.ts +5 -0
  116. package/dist/scenario.js +3 -2
  117. package/dist/scenario.js.map +1 -1
  118. package/dist/scenarios.js +7 -1
  119. package/dist/scenarios.js.map +1 -1
  120. package/dist/schema.js +8 -0
  121. package/dist/schema.js.map +1 -1
  122. package/dist/secret-store-check.d.ts +11 -0
  123. package/dist/secret-store-check.js +76 -0
  124. package/dist/secret-store-check.js.map +1 -0
  125. package/dist/secret-store.d.ts +106 -0
  126. package/dist/secret-store.js +228 -0
  127. package/dist/secret-store.js.map +1 -0
  128. package/dist/secret.d.ts +2 -0
  129. package/dist/secret.js +32 -0
  130. package/dist/secret.js.map +1 -1
  131. package/dist/shared.d.ts +21 -11
  132. package/dist/shared.js +41 -7
  133. package/dist/shared.js.map +1 -1
  134. package/dist/sites.d.ts +4 -93
  135. package/dist/sites.js +76 -84
  136. package/dist/sites.js.map +1 -1
  137. package/dist/steps.d.ts +3 -0
  138. package/dist/steps.js.map +1 -1
  139. package/dist/version.d.ts +2 -1
  140. package/dist/version.js +2 -1
  141. package/dist/version.js.map +1 -1
  142. package/dist/wakeup-driver.js +15 -6
  143. package/dist/wakeup-driver.js.map +1 -1
  144. package/dist/worker.js +12 -8
  145. package/dist/worker.js.map +1 -1
  146. package/dist/writer.d.ts +53 -14
  147. package/dist/writer.js +113 -43
  148. package/dist/writer.js.map +1 -1
  149. package/oxlint.json +1 -5
  150. package/package.json +6 -1
  151. package/src/apply-steps.ts +5 -2
  152. package/src/apply.ts +54 -21
  153. package/src/approvals.ts +7 -1
  154. package/src/apps.ts +71 -86
  155. package/src/ast.ts +6 -6
  156. package/src/authentication.ts +191 -0
  157. package/src/call-workflow.ts +5 -4
  158. package/src/call.ts +52 -9
  159. package/src/cedar.ts +14 -1
  160. package/src/change-steps.ts +7 -4
  161. package/src/change.ts +10 -0
  162. package/src/client.ts +346 -45
  163. package/src/collection.ts +10 -2
  164. package/src/config.ts +107 -19
  165. package/src/connection.ts +912 -0
  166. package/src/credentials.ts +94 -281
  167. package/src/db.ts +34 -2
  168. package/src/define.ts +7 -2
  169. package/src/definitions.ts +31 -0
  170. package/src/deployment-key.ts +23 -0
  171. package/src/describe.ts +150 -227
  172. package/src/description.ts +426 -0
  173. package/src/direct.ts +7 -0
  174. package/src/drift.ts +10 -2
  175. package/src/env.ts +4 -2
  176. package/src/fake.ts +71 -0
  177. package/src/index.ts +53 -1
  178. package/src/ledger.ts +3 -1
  179. package/src/lint.ts +2 -17
  180. package/src/load.ts +466 -0
  181. package/src/lockfile.ts +10 -3
  182. package/src/op.ts +41 -7
  183. package/src/outline.ts +53 -7
  184. package/src/paths.ts +21 -6
  185. package/src/plugin.ts +3 -24
  186. package/src/policy.ts +2 -0
  187. package/src/resource.ts +24 -6
  188. package/src/resources.ts +246 -301
  189. package/src/run.ts +4 -0
  190. package/src/sandbox.ts +61 -7
  191. package/src/scenario.ts +24 -6
  192. package/src/scenarios.ts +7 -1
  193. package/src/schema.ts +10 -0
  194. package/src/secret-store-check.ts +93 -0
  195. package/src/secret-store.ts +314 -0
  196. package/src/secret.ts +29 -0
  197. package/src/shared.ts +44 -10
  198. package/src/sites.ts +85 -179
  199. package/src/steps.ts +3 -2
  200. package/src/version.ts +2 -1
  201. package/src/wakeup-driver.ts +22 -6
  202. package/src/worker.ts +12 -8
  203. package/src/writer.ts +145 -56
  204. package/dist/datafile.d.ts +0 -97
  205. package/dist/datafile.js +0 -479
  206. package/dist/datafile.js.map +0 -1
  207. package/src/datafile.ts +0 -601
package/src/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,4 +1,16 @@
1
- import { randomUUID } from "node:crypto";
1
+ import {
2
+ connectionDriver,
3
+ connectionTargetSchema,
4
+ type ConnectionRef,
5
+ type ConnectionSnapshot,
6
+ type ConnectionTarget,
7
+ type ConnectionSetup,
8
+ type ConnectionCard,
9
+ type ConnectionRule,
10
+ } from "./connection.ts";
11
+ import { encodeCredentials, decodeCredentials, verifyAuthentication, AuthenticationError } from "./authentication.ts";
12
+
13
+ import { createHash, randomUUID } from "node:crypto";
2
14
  import { isDeepStrictEqual } from "node:util";
3
15
  import { type DBOSClient, Error as DBOSErrors, type WorkflowStatusString } from "@dbos-inc/dbos-sdk";
4
16
  import type { Pool } from "pg";
@@ -16,7 +28,7 @@ import { beforeOf, decideCall, decideStart, recorded, startHoldOf } from "./call
16
28
  import { type Chats, chatStore } from "./chats.ts";
17
29
  import { collectionsDriver } from "./collection.ts";
18
30
  import { type ResolvedConfig, resolveConfig, type SanomaConfig } from "./config.ts";
19
- import { deleteCredential, storeCredential, storedCredentials, storedSecrets } from "./credentials.ts";
31
+ import { configuredSecrets } from "./credentials.ts";
20
32
  import { dbosClient, lazyPool, type LazyPool } from "./db.ts";
21
33
  import { type CredentialStatus, credentialsOf } from "./env.ts";
22
34
  import { type ApprovalState, Principal, type WorkflowDefinition } from "./define.ts";
@@ -52,7 +64,7 @@ import type { CallContext, Driver, Op } from "./op.ts";
52
64
  import { allowAll, type RecordedDecision } from "./policy.ts";
53
65
  import { admit, readPeople } from "./resources.ts";
54
66
  import type { RunArgs, StartHold } from "./run.ts";
55
- import type { Secrets } from "./secret.ts";
67
+ import { SecretStoreError, type SecretAccess, type SecretLocation } from "./secret-store.ts";
56
68
  import {
57
69
  BUILT_IN_WORKFLOWS,
58
70
  CALL_WORKFLOW,
@@ -206,6 +218,7 @@ export interface ChangesFilter {
206
218
 
207
219
  /** What `SanomaClient.call` takes besides the operation's id and its input. */
208
220
  export interface CallOptions {
221
+ connection?: ConnectionRef;
209
222
  /**
210
223
  * Who calls, by id, admitted as `startedBy` is: the policy decides as them, a write's record
211
224
  * names them, and a save's `updatedBy` is their id.
@@ -218,6 +231,15 @@ export interface CallOptions {
218
231
  id?: string;
219
232
  }
220
233
 
234
+ /** A credential-free review shown before the user saves a connection. */
235
+ export interface ConnectionReview extends ConnectionSetup {
236
+ authorization: string;
237
+ requestId: string;
238
+ account: string;
239
+ scopes: string[];
240
+ credentialVersion: string | null;
241
+ expiresAt?: number;
242
+ }
221
243
  /** What `SanomaClient.connect` is given besides the config. */
222
244
  export interface ClientOptions {
223
245
  /**
@@ -243,7 +265,7 @@ export class SanomaClient {
243
265
  /** On the config's database, for the runtime's tables (the credentials, the chats): opened on first use. */
244
266
  private readonly pool: LazyPool;
245
267
  /** What a direct call's driver reads secrets from, as a worker's live driver does. */
246
- private readonly storedSecrets: Secrets;
268
+ private readonly storedSecrets: SecretAccess;
247
269
  /** The operator agent's chats: their rows and their transcripts, in the config's database. */
248
270
  readonly chats: Chats;
249
271
  private readonly options: ClientOptions;
@@ -260,7 +282,7 @@ export class SanomaClient {
260
282
  this.options = options;
261
283
  this.audit = ownedLedger(config);
262
284
  this.pool = lazyPool(config.databaseUrl, "client");
263
- this.storedSecrets = storedSecrets(() => this.db(), "sanoma");
285
+ this.storedSecrets = configuredSecrets(config.secrets, () => this.db());
264
286
  this.chats = chatStore(() => this.db());
265
287
  }
266
288
 
@@ -272,7 +294,14 @@ export class SanomaClient {
272
294
  static async connect(config: SanomaConfig, options: ClientOptions = {}): Promise<SanomaClient> {
273
295
  const resolved = resolveConfig(config);
274
296
  const dbos = await dbosClient(resolved.databaseUrl, resolved.appName);
275
- return new SanomaClient(dbos, resolved, resolved.driversByVendor, options);
297
+ const client = new SanomaClient(dbos, resolved, resolved.driversByVendor, options);
298
+ try {
299
+ await client.storedSecrets.validate();
300
+ return client;
301
+ } catch (error) {
302
+ await client.close();
303
+ throw error;
304
+ }
276
305
  }
277
306
 
278
307
  /**
@@ -414,14 +443,21 @@ export class SanomaClient {
414
443
  const fn = drivers.get(op.id);
415
444
  if (!fn) throw new SanomaError("invalid_input", `${op.id} has no driver: the connector package must attach one`);
416
445
  const parsed = parseOrThrow(op.input, input, `The input to ${op.id} does not match its schema`, { op: op.id });
446
+ const connection = await this.config.connections.select(op.vendor, options.connection, actor.id);
447
+ const selected = connection && {
448
+ $connection: connection.id,
449
+ vendor: connection.vendor,
450
+ revision: connection.revision,
451
+ };
417
452
  const runId = `direct:${actor.id}:${id}`;
418
453
  const heldId = `call:${actor.id}:${id}`;
419
- const held: RunArgs = { input: { op: op.id, input }, startedBy: actor };
454
+ const held: RunArgs = { input: { op: op.id, input, ...(selected && { connection: selected }) }, startedBy: actor };
420
455
  const logged = recorded(op, parsed);
421
456
  const write = op.effect !== "read";
422
457
  // Only an id the caller gave can have been used: a write made, or held, with it settles it.
423
458
  if (write && options.id !== undefined) {
424
- const first = (await this.recordedCall(runId, op.id, logged)) ?? (await this.heldCall(op.id, heldId, held));
459
+ const first =
460
+ (await this.recordedCall(runId, op.id, logged, connection)) ?? (await this.heldCall(op.id, heldId, held));
425
461
  if (first) return first.output;
426
462
  }
427
463
  const before = await beforeOf(policy, op, parsed, this.config.collections, (get, key) =>
@@ -431,7 +467,7 @@ export class SanomaClient {
431
467
  ),
432
468
  );
433
469
  const run = { id: runId, workflow: CALL_WORKFLOW, approvals: [] };
434
- const decision = decideCall(policy, op, parsed, actor, run, before);
470
+ const decision = decideCall(policy, op, parsed, actor, run, { ...before, ...(connection && { connection }) });
435
471
  if (decision.kind === "deny") throw new PolicyDeniedError(op.id, decision.reason);
436
472
  if (decision.kind === "approve") {
437
473
  await this.start(CALL_WORKFLOW, held.input, { startedBy: actor, runId: heldId });
@@ -445,7 +481,10 @@ export class SanomaClient {
445
481
  attempt: 1,
446
482
  secrets: this.storedSecrets,
447
483
  by: actor.id,
484
+ principal: actor,
485
+ ...(connection && { connection }),
448
486
  };
487
+ await this.config.connections.assertCurrent(connection, actor.id);
449
488
  if (!write) return outputOf(op, () => fn(parsed, context));
450
489
  return this.directWrite({ op, fn, parsed, logged, actor, decision, context });
451
490
  }
@@ -469,13 +508,22 @@ export class SanomaClient {
469
508
  const { op, parsed, logged, actor, decision, context } = call;
470
509
  const { runId } = context;
471
510
  const { dataStore, collections, ledger } = this.config;
472
- const conn = op.vendor === DATA_VENDOR && !ledger && dataStore ? await (await dataStore.db()).connect() : undefined;
511
+ const connectionMutation = op.id.startsWith("sanoma.connection.");
512
+ const transactionStore = connectionMutation
513
+ ? this.config.connectionPool
514
+ : op.vendor === DATA_VENDOR
515
+ ? dataStore
516
+ : undefined;
517
+ const conn = !ledger && transactionStore ? await (await transactionStore.db()).connect() : undefined;
473
518
  let broken: Error | undefined;
474
519
  try {
475
520
  await conn?.query("BEGIN");
476
- // On the transaction's connection, the collection's own driver: it has no `env` to guard.
521
+ // The metadata or collection driver shares the ledger transaction; neither has an env guard.
477
522
  const fn = conn
478
- ? collectionsDriver([collections.get(op.resource)!], { db: async () => conn }).ops[`${op.resource}.${op.name}`]!
523
+ ? (connectionMutation
524
+ ? connectionDriver(this.config.connections.onDatabase(conn))
525
+ : collectionsDriver([collections.get(op.resource)!], { db: async () => conn })
526
+ ).ops[`${op.resource}.${op.name}`]!
479
527
  : call.fn;
480
528
  const at = Date.now();
481
529
  const record = (outcome: { output: unknown } | { error: ErrorInfo }): LedgerRecord => ({
@@ -492,6 +540,7 @@ export class SanomaClient {
492
540
  effect: op.effect,
493
541
  input: logged,
494
542
  decision,
543
+ ...(context.connection && { connection: context.connection }),
495
544
  ...outcome,
496
545
  durationMs: Date.now() - at,
497
546
  });
@@ -513,7 +562,7 @@ export class SanomaClient {
513
562
  return output;
514
563
  }
515
564
  await conn.query("ROLLBACK");
516
- return (await this.recordedCall(runId, op.id, logged))!.output;
565
+ return (await this.recordedCall(runId, op.id, logged, context.connection))!.output;
517
566
  } catch (err) {
518
567
  await conn?.query("ROLLBACK").catch((e: Error) => (broken = e));
519
568
  throw err;
@@ -526,10 +575,19 @@ export class SanomaClient {
526
575
  * What the direct write recorded as `runId` returned, when one did: refused (`invalid_input`)
527
576
  * when it was of another operation or input, and its failure again (`driver_failed`) when it failed.
528
577
  */
529
- private async recordedCall(runId: string, opId: string, input: unknown): Promise<{ output: unknown } | undefined> {
578
+ private async recordedCall(
579
+ runId: string,
580
+ opId: string,
581
+ input: unknown,
582
+ connection?: ConnectionSnapshot,
583
+ ): Promise<{ output: unknown } | undefined> {
530
584
  const first = (await this.audit.store.read(runId)).find((r) => r.type === "op.called");
531
585
  if (!first) return undefined;
532
- const differs = [...(first.op === opId ? [] : ["op"]), ...(sameJson(first.input, input) ? [] : ["input"])];
586
+ const differs = [
587
+ ...(first.op === opId ? [] : ["op"]),
588
+ ...(sameJson(first.input, input) ? [] : ["input"]),
589
+ ...(sameJson(first.connection ?? null, connection ?? null) ? [] : ["connection"]),
590
+ ];
533
591
  if (differs.length) {
534
592
  throw new SanomaError(
535
593
  "invalid_input",
@@ -1061,7 +1119,8 @@ export class SanomaClient {
1061
1119
  issues: [{ path: ["value"], message: problem, code: "custom" }],
1062
1120
  });
1063
1121
  }
1064
- await storeCredential(await this.db(), { name, value, setBy });
1122
+ const current = await this.storedSecrets.read(name);
1123
+ await this.storedSecrets.write(name, value, { expectedVersion: current?.version ?? null, by: setBy });
1065
1124
  }
1066
1125
 
1067
1126
  /**
@@ -1072,7 +1131,8 @@ export class SanomaClient {
1072
1131
  async clearCredential(name: string, options: { by: string }): Promise<void> {
1073
1132
  await this.admittedBy(options);
1074
1133
  this.declared(name, () => undefined);
1075
- await deleteCredential(await this.db(), name);
1134
+ const current = await this.storedSecrets.read(name);
1135
+ if (current) await this.storedSecrets.delete(name, { expectedVersion: current.version, by: options.by });
1076
1136
  }
1077
1137
 
1078
1138
  /**
@@ -1091,49 +1151,288 @@ export class SanomaClient {
1091
1151
  const path = VARIABLE.test(name) ? "value" : "name";
1092
1152
  throw new SanomaError("invalid_input", problem, { issues: [{ path: [path], message: problem, code: "custom" }] });
1093
1153
  }
1094
- await storeCredential(await this.db(), { name, value, setBy });
1154
+ const current = await this.storedSecrets.read(name);
1155
+ await this.storedSecrets.write(name, value, { expectedVersion: current?.version ?? null, by: setBy });
1156
+ }
1157
+
1158
+ /** Removes a logical secret from the selected store; external bindings remain read-only. */
1159
+ async clearSecret(name: string, options: { by: string }): Promise<void> {
1160
+ const by = await this.admittedBy(options);
1161
+ if (!VARIABLE.test(name)) {
1162
+ const message = "That is not a variable name";
1163
+ throw new SanomaError("invalid_input", message, {
1164
+ issues: [{ path: ["name"], message, code: "custom" }],
1165
+ });
1166
+ }
1167
+ const current = await this.storedSecrets.read(name);
1168
+ if (current) await this.storedSecrets.delete(name, { expectedVersion: current.version, by });
1169
+ }
1170
+
1171
+ private connectionAuthorization(): string {
1172
+ return createHash("sha256")
1173
+ .update(
1174
+ JSON.stringify({
1175
+ version: this.config.version,
1176
+ policies: this.config.policies,
1177
+ problems: this.config.policyProblems,
1178
+ }),
1179
+ )
1180
+ .digest("hex");
1181
+ }
1182
+
1183
+ /** Safe account cards, scoped to the admitted principal. No provider or secret reads. */
1184
+ async connections(options: { actor: Principal | string }): Promise<ConnectionCard[]> {
1185
+ const actor = await this.admitted(parseOrThrow(PrincipalOrId, options.actor, "Name the principal"));
1186
+ return this.config.connections.cards(actor.id);
1187
+ }
1188
+
1189
+ /** Creation choices are scoped by code to the admitted person's current groups. */
1190
+ async connectionRules(options: { actor: Principal | string }): Promise<{ id: string; rule: ConnectionRule }[]> {
1191
+ const actor = await this.admitted(parseOrThrow(PrincipalOrId, options.actor, "Name the principal"));
1192
+ return [...this.config.connections.catalog.rules]
1193
+ .filter(([, rule]) =>
1194
+ rule.createBy.some((by) => (typeof by === "string" ? by === actor.id : actor.groups?.includes(by.group))),
1195
+ )
1196
+ .map(([id, rule]) => ({ id, rule }));
1197
+ }
1198
+
1199
+ /** Readiness only, without reading or listing any credential. The adapter's bootstrap
1200
+ * authority is checked by validate; provider details never escape this boundary. */
1201
+ async secretStoreStatus(options: { actor: Principal | string }) {
1202
+ await this.admitted(parseOrThrow(PrincipalOrId, options.actor, "Name the principal"));
1203
+ const { store, namespace } = this.storedSecrets;
1204
+ let problem: string | undefined;
1205
+ try {
1206
+ await this.storedSecrets.validate();
1207
+ } catch (error) {
1208
+ problem = (error as SecretStoreError).code;
1209
+ }
1210
+ return {
1211
+ store: store.id,
1212
+ namespace,
1213
+ capabilities: { ...store.capabilities },
1214
+ ready: !problem,
1215
+ ...(problem && { problem }),
1216
+ };
1217
+ }
1218
+
1219
+ /** Secure intake endpoint, never a workflow operation. Only its metadata-only prepare gate
1220
+ * enters policy/approval history; callers keep the value in a password form while reviewing. */
1221
+ async previewConnection(
1222
+ target: ConnectionTarget,
1223
+ credentials: unknown,
1224
+ options: { actor: Principal | string; id: string },
1225
+ ): Promise<ConnectionReview> {
1226
+ const actor = await this.admitted(parseOrThrow(PrincipalOrId, options.actor, "Name the principal"));
1227
+ const id = parseOrThrow(z.string().min(1).max(200), options.id, "Name the setup request");
1228
+ target = parseOrThrow(connectionTargetSchema, target, "Select a declared connection or creation rule");
1229
+ const setup = await this.config.connections.setup(target, actor, id);
1230
+ const authorized = (await this.call(
1231
+ "sanoma.connection.prepare",
1232
+ {
1233
+ target,
1234
+ requestId: id,
1235
+ definition: setup.definition,
1236
+ authorization: this.connectionAuthorization(),
1237
+ },
1238
+ { actor, id: `connection-prepare:${id}` },
1239
+ )) as ConnectionSetup;
1240
+ if (!isDeepStrictEqual(authorized, setup))
1241
+ throw new SanomaError("invalid_input", "The connection changed; start a new review");
1242
+ const method = this.config.connections.catalog.methods.get(setup.vendor)![setup.method]!;
1243
+ const external =
1244
+ method.kind !== "workload-identity" &&
1245
+ (setup.location?.management === "external" || !this.storedSecrets.store.capabilities.writable);
1246
+ if (
1247
+ !external &&
1248
+ method.kind !== "workload-identity" &&
1249
+ this.storedSecrets.store.capabilities.conditionalWrites === "none"
1250
+ )
1251
+ throw new SecretStoreError("unsupported");
1252
+ if (credentials !== undefined && (external || method.kind === "workload-identity"))
1253
+ throw new SecretStoreError("read_only");
1254
+ const existing = setup.credential ? await this.storedSecrets.read(setup.credential) : undefined;
1255
+ if (external && !existing) throw new SecretStoreError("unavailable");
1256
+ const encoded =
1257
+ method.kind === "workload-identity"
1258
+ ? undefined
1259
+ : external
1260
+ ? existing!.value
1261
+ : encodeCredentials(method, credentials);
1262
+ const identity = await verifyAuthentication(
1263
+ method,
1264
+ encoded === undefined ? undefined : decodeCredentials(method, encoded),
1265
+ );
1266
+ if (setup.expectedAccount && setup.expectedAccount !== identity.account)
1267
+ throw new AuthenticationError("wrong_account");
1268
+ if (setup.requiredScopes.some((scope) => !identity.scopes.includes(scope)))
1269
+ throw new AuthenticationError("scope_changed");
1270
+ return {
1271
+ ...setup,
1272
+ requestId: id,
1273
+ authorization: this.connectionAuthorization(),
1274
+ account: identity.account,
1275
+ scopes: [...identity.scopes],
1276
+ credentialVersion: existing?.version ?? null,
1277
+ ...(identity.expiresAt !== undefined && { expiresAt: identity.expiresAt }),
1278
+ };
1279
+ }
1280
+
1281
+ /** Reverify the reviewed authority, reserve its metadata under policy, then write the bundle
1282
+ * outside durable execution. Activation has its own policy gate and verifies what was saved. */
1283
+ async connectConnection(
1284
+ review: ConnectionReview,
1285
+ credentials: unknown,
1286
+ options: { actor: Principal | string },
1287
+ ): Promise<ConnectionSnapshot> {
1288
+ const actor = await this.admitted(parseOrThrow(PrincipalOrId, options.actor, "Name the principal"));
1289
+ if (review.authorization !== this.connectionAuthorization())
1290
+ throw new SanomaError("invalid_input", "The policy changed; start a new connection review");
1291
+ const setup = await this.config.connections.setup(review.target, actor, review.requestId);
1292
+ for (const field of ["id", "vendor", "method", "definition", "credential", "location"] as const) {
1293
+ if (!isDeepStrictEqual(setup[field], review[field]))
1294
+ throw new SanomaError("invalid_input", "The destination changed; review it again before saving");
1295
+ }
1296
+ // Reuse only the metadata gate, never a cached credential or verification result.
1297
+ await this.call(
1298
+ "sanoma.connection.prepare",
1299
+ {
1300
+ target: review.target,
1301
+ requestId: review.requestId,
1302
+ definition: setup.definition,
1303
+ authorization: review.authorization,
1304
+ },
1305
+ { actor, id: `connection-prepare:${review.requestId}` },
1306
+ );
1307
+ const method = this.config.connections.catalog.methods.get(setup.vendor)![setup.method]!;
1308
+ const external =
1309
+ method.kind !== "workload-identity" &&
1310
+ (setup.location?.management === "external" || !this.storedSecrets.store.capabilities.writable);
1311
+ if (
1312
+ !external &&
1313
+ method.kind !== "workload-identity" &&
1314
+ this.storedSecrets.store.capabilities.conditionalWrites === "none"
1315
+ )
1316
+ throw new SecretStoreError("unsupported");
1317
+ if (credentials !== undefined && (external || method.kind === "workload-identity"))
1318
+ throw new SecretStoreError("read_only");
1319
+ const existing = setup.credential ? await this.storedSecrets.read(setup.credential) : undefined;
1320
+ if (external && (!existing || existing.version !== review.credentialVersion))
1321
+ throw new SecretStoreError("conflict");
1322
+ if (setup.expectedRevision === review.expectedRevision && (existing?.version ?? null) !== review.credentialVersion)
1323
+ throw new SecretStoreError("conflict");
1324
+ const encoded =
1325
+ method.kind === "workload-identity"
1326
+ ? undefined
1327
+ : external
1328
+ ? existing!.value
1329
+ : encodeCredentials(method, credentials);
1330
+ const identity = await verifyAuthentication(
1331
+ method,
1332
+ encoded === undefined ? undefined : decodeCredentials(method, encoded),
1333
+ );
1334
+ if (identity.account !== review.account) throw new AuthenticationError("wrong_account");
1335
+ if (JSON.stringify(identity.scopes) !== JSON.stringify([...new Set(review.scopes)].toSorted()))
1336
+ throw new AuthenticationError("scope_changed");
1337
+ const selected = (await this.call(
1338
+ "sanoma.connection.configure",
1339
+ {
1340
+ target: review.target,
1341
+ requestId: review.requestId,
1342
+ expectedRevision: review.expectedRevision,
1343
+ definition: review.definition,
1344
+ account: identity.account,
1345
+ scopes: [...identity.scopes],
1346
+ },
1347
+ { actor, id: `connection-configure:${review.requestId}` },
1348
+ )) as ConnectionSnapshot;
1349
+ const ref = { vendor: selected.vendor, $connection: selected.id };
1350
+ if (
1351
+ (await this.config.connections.setupState(selected, actor)) === "pending" &&
1352
+ !external &&
1353
+ encoded !== undefined
1354
+ ) {
1355
+ let written = false;
1356
+ try {
1357
+ const receipt = await this.config.connections.storedVersion(selected.id, selected.revision);
1358
+ if (receipt) {
1359
+ if (existing?.version !== receipt || existing.value !== encoded) throw new SecretStoreError("conflict");
1360
+ } else {
1361
+ const stored = await this.storedSecrets.write(setup.credential!, encoded, {
1362
+ expectedVersion: review.credentialVersion,
1363
+ by: actor.id,
1364
+ });
1365
+ written = true;
1366
+ await this.config.connections.recordStoredVersion(selected.id, selected.revision, stored.version);
1367
+ }
1368
+ } catch (error) {
1369
+ if (written || (error as { code?: string })?.code === "unavailable")
1370
+ await this.config.connections.uncertain(ref, selected.revision, actor.id);
1371
+ throw error;
1372
+ }
1373
+ }
1374
+ const connected = (await this.call(
1375
+ "sanoma.connection.activate",
1376
+ {
1377
+ connection: ref,
1378
+ expectedRevision: selected.revision,
1379
+ },
1380
+ { actor, id: `connection-activate:${review.requestId}` },
1381
+ )) as ConnectionSnapshot;
1382
+ await this.config.connections.assertCurrent(connected, actor.id);
1383
+ return connected;
1095
1384
  }
1096
1385
 
1097
1386
  /**
1098
- * The values of these secrets, as a driver's `secrets.get` reads them: this process's
1099
- * environment, which wins, else the stored value, with who set it and when. A name with neither
1100
- * is left out. For server code: a page is told whether one is set, never its value.
1387
+ * Values resolved through the selected store and explicit bindings, with version and change
1388
+ * metadata. Missing names are left out. Server code only; pages receive status, never values.
1101
1389
  */
1102
1390
  async secrets(names: readonly string[]): Promise<Map<string, StoredSecret>> {
1103
- const rows = await storedCredentials(await this.db(), names);
1104
1391
  const found = new Map<string, StoredSecret>();
1105
1392
  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" });
1393
+ const row = await this.storedSecrets.read(name);
1394
+ const location = this.storedSecrets.location(name);
1395
+ if (row)
1396
+ found.set(name, {
1397
+ value: row.value,
1398
+ source: location.store === "environment" ? "environment" : "stored",
1399
+ version: row.version,
1400
+ location,
1401
+ ...(row.updatedBy && { setBy: row.updatedBy }),
1402
+ ...(row.updatedAt && { setAt: row.updatedAt }),
1403
+ });
1110
1404
  }
1111
1405
  return found;
1112
1406
  }
1113
1407
 
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
- */
1408
+ /** Non-secret statuses for the selected store and declared bindings. */
1120
1409
  async credentials(): Promise<Map<string, CredentialStatus[]>> {
1121
1410
  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)]));
1411
+ const rows = await this.secrets(names);
1412
+ const statuses = credentialsOf(this.drivers, (name) => rows.get(name)?.value);
1413
+ return new Map(
1414
+ [...statuses].map(([vendor, list]) => [
1415
+ vendor,
1416
+ list.map((c) => {
1417
+ const row = rows.get(c.name);
1418
+ return {
1419
+ ...c,
1420
+ location: this.storedSecrets.location(c.name),
1421
+ ...(row && { source: row.source, setBy: row.setBy, setAt: row.setAt }),
1422
+ };
1423
+ }),
1424
+ ]),
1425
+ );
1133
1426
  }
1134
1427
 
1135
1428
  async close() {
1136
- await Promise.all([this.dbos.destroy(), this.pool.close(), this.audit.close(), this.config.dataStore?.close()]);
1429
+ await Promise.all([
1430
+ this.dbos.destroy(),
1431
+ this.pool.close(),
1432
+ this.audit.close(),
1433
+ this.config.dataStore?.close(),
1434
+ this.config.connectionPool.close(),
1435
+ ]);
1137
1436
  }
1138
1437
 
1139
1438
  /**
@@ -1285,6 +1584,8 @@ const sameJson = (a: unknown, b: unknown): boolean => isDeepStrictEqual(asJson(a
1285
1584
 
1286
1585
  /** A secret's value as `SanomaClient.secrets` reads it, and where from. */
1287
1586
  export interface StoredSecret {
1587
+ version?: string;
1588
+ location?: SecretLocation;
1288
1589
  value: string;
1289
1590
  source: "environment" | "stored";
1290
1591
  /** Who stored it (`setSecret`'s `by`), and when, as an ISO 8601 time. */
@@ -1293,7 +1594,7 @@ export interface StoredSecret {
1293
1594
  }
1294
1595
 
1295
1596
  /** What an environment variable's name looks like. */
1296
- const VARIABLE = /^[A-Z_][A-Z0-9_]*$/;
1597
+ const VARIABLE = /^[A-Z][A-Z0-9_]*$/;
1297
1598
 
1298
1599
  const alreadyDecided = (runId: string, a: ApprovalState) =>
1299
1600
  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(