@sanoma-ai/workflows 0.10.0 → 0.10.2

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 (99) hide show
  1. package/CLOUD-IDENTITY.md +140 -0
  2. package/KMS-SECRETS.md +99 -0
  3. package/README.md +39 -7
  4. package/SECRET-SUPPORT.md +33 -0
  5. package/dist/apps.d.ts +6 -0
  6. package/dist/apps.js +18 -1
  7. package/dist/apps.js.map +1 -1
  8. package/dist/authentication.d.ts +35 -0
  9. package/dist/authentication.js +71 -1
  10. package/dist/authentication.js.map +1 -1
  11. package/dist/call.js +7 -5
  12. package/dist/call.js.map +1 -1
  13. package/dist/client.d.ts +54 -6
  14. package/dist/client.js +177 -5
  15. package/dist/client.js.map +1 -1
  16. package/dist/cloud-identity.d.ts +52 -0
  17. package/dist/cloud-identity.js +222 -0
  18. package/dist/cloud-identity.js.map +1 -0
  19. package/dist/config.d.ts +7 -4
  20. package/dist/config.js +44 -12
  21. package/dist/config.js.map +1 -1
  22. package/dist/connection.d.ts +223 -2
  23. package/dist/connection.js +340 -4
  24. package/dist/connection.js.map +1 -1
  25. package/dist/db.js +15 -2
  26. package/dist/db.js.map +1 -1
  27. package/dist/describe.d.ts +8 -0
  28. package/dist/describe.js +51 -1
  29. package/dist/describe.js.map +1 -1
  30. package/dist/direct.js +4 -0
  31. package/dist/direct.js.map +1 -1
  32. package/dist/fake.d.ts +13 -1
  33. package/dist/fake.js +70 -0
  34. package/dist/fake.js.map +1 -1
  35. package/dist/index.d.ts +4 -4
  36. package/dist/index.js +2 -2
  37. package/dist/index.js.map +1 -1
  38. package/dist/kms-secrets.d.ts +24 -0
  39. package/dist/kms-secrets.js +209 -0
  40. package/dist/kms-secrets.js.map +1 -0
  41. package/dist/lint.js +1 -1
  42. package/dist/lint.js.map +1 -1
  43. package/dist/load.d.ts +11 -0
  44. package/dist/load.js +42 -8
  45. package/dist/load.js.map +1 -1
  46. package/dist/op.d.ts +14 -6
  47. package/dist/op.js +20 -2
  48. package/dist/op.js.map +1 -1
  49. package/dist/outline.d.ts +5 -0
  50. package/dist/outline.js +33 -1
  51. package/dist/outline.js.map +1 -1
  52. package/dist/paths.d.ts +2 -4
  53. package/dist/paths.js +2 -8
  54. package/dist/paths.js.map +1 -1
  55. package/dist/run.d.ts +3 -1
  56. package/dist/run.js.map +1 -1
  57. package/dist/sandbox.d.ts +4 -1
  58. package/dist/sandbox.js +51 -5
  59. package/dist/sandbox.js.map +1 -1
  60. package/dist/scenario.d.ts +5 -0
  61. package/dist/scenario.js +3 -2
  62. package/dist/scenario.js.map +1 -1
  63. package/dist/secret.d.ts +2 -0
  64. package/dist/secret.js +32 -0
  65. package/dist/secret.js.map +1 -1
  66. package/dist/shared.d.ts +4 -0
  67. package/dist/shared.js +7 -0
  68. package/dist/shared.js.map +1 -1
  69. package/dist/sites.js +18 -3
  70. package/dist/sites.js.map +1 -1
  71. package/dist/steps.d.ts +3 -0
  72. package/dist/steps.js.map +1 -1
  73. package/oxlint.json +3 -0
  74. package/package.json +16 -2
  75. package/src/apps.ts +17 -1
  76. package/src/authentication.ts +126 -2
  77. package/src/call.ts +4 -5
  78. package/src/client.ts +242 -6
  79. package/src/cloud-identity.ts +266 -0
  80. package/src/config.ts +45 -11
  81. package/src/connection.ts +478 -6
  82. package/src/db.ts +15 -2
  83. package/src/describe.ts +52 -1
  84. package/src/direct.ts +4 -0
  85. package/src/fake.ts +71 -0
  86. package/src/index.ts +13 -1
  87. package/src/kms-secrets.ts +271 -0
  88. package/src/lint.ts +1 -1
  89. package/src/load.ts +36 -7
  90. package/src/op.ts +33 -8
  91. package/src/outline.ts +35 -2
  92. package/src/paths.ts +2 -9
  93. package/src/run.ts +3 -1
  94. package/src/sandbox.ts +55 -5
  95. package/src/scenario.ts +24 -6
  96. package/src/secret.ts +29 -0
  97. package/src/shared.ts +9 -0
  98. package/src/sites.ts +19 -3
  99. package/src/steps.ts +3 -2
package/src/connection.ts CHANGED
@@ -1,11 +1,21 @@
1
1
  import type { Principal } from "./define.ts";
2
2
  import { createHash, randomUUID } from "node:crypto";
3
3
  import { z } from "zod";
4
- import { checkAuthenticationStore, defineAuthentication, type Authentication } from "./authentication.ts";
4
+ import {
5
+ checkAuthenticationStore,
6
+ decodeCredentials,
7
+ defineAuthentication,
8
+ verifyAuthentication,
9
+ AuthenticationError,
10
+ authenticationProblem,
11
+ type AuthenticationProblem,
12
+ type Authentication,
13
+ type CallAuthentication,
14
+ } from "./authentication.ts";
5
15
  import { type Connector, setupOf, VENDOR, defineConnector, defineDriver, type CallContext } from "./op.ts";
6
16
  import { isSecret, type Secret } from "./secret.ts";
7
17
  import { SanomaError } from "./errors.ts";
8
- import type { SecretAccess } from "./secret-store.ts";
18
+ import { SecretStoreError, type SecretAccess, type SecretLocation } from "./secret-store.ts";
9
19
  import type { Db } from "./db.ts";
10
20
 
11
21
  /** Serializable account reference; vendor literal prevents cross-connector selection in typed calls. */
@@ -40,6 +50,51 @@ export interface ConnectionSnapshot {
40
50
  /** A logical reference only, never the value or a provider credential. */
41
51
  credentials?: Secret;
42
52
  }
53
+ export type ConnectionTarget = { connection: ConnectionRef } | { rule: string };
54
+ export interface ConnectionCard {
55
+ id: string;
56
+ vendor: string;
57
+ name: string;
58
+ method: string;
59
+ account?: string;
60
+ scopes: readonly string[];
61
+ requiredScopes: readonly string[];
62
+ expectedAccount?: string;
63
+ revision?: string;
64
+ owner?: string;
65
+ status: "missing" | "connected" | "disconnected" | "pending" | "uncertain" | "configuration_changed";
66
+ location?: SecretLocation;
67
+ boundLocation?: SecretLocation;
68
+ updatedBy?: string;
69
+ updatedAt?: string;
70
+ health?: ConnectionHealth;
71
+ }
72
+ export interface ConnectionHealth {
73
+ state:
74
+ | AuthenticationProblem
75
+ | "connected"
76
+ | "disconnected"
77
+ | "pending"
78
+ | "configuration_changed"
79
+ | "store_denied"
80
+ | "invalid";
81
+ checkedAt: string;
82
+ externallyRenewed: boolean;
83
+ }
84
+ export interface ConnectionSetup {
85
+ target: ConnectionTarget;
86
+ id: string;
87
+ vendor: string;
88
+ method: string;
89
+ definition: string;
90
+ credential?: string;
91
+ location?: SecretLocation;
92
+ expectedRevision: string | null;
93
+ requiredScopes: readonly string[];
94
+ expectedAccount?: string;
95
+ previousAccount?: string;
96
+ previousLocation?: SecretLocation;
97
+ }
43
98
  const idPart = /^[a-z][a-z0-9-]{0,63}$/;
44
99
  function refuse(message: string): never {
45
100
  throw new SanomaError("invalid_input", message);
@@ -59,6 +114,28 @@ const snapshotSchema = z.object({
59
114
  definition: z.string(),
60
115
  credentials: z.object({ $secret: z.string() }).optional(),
61
116
  });
117
+ export const connectionTargetSchema = z.union([
118
+ z.object({ connection: connectionRefSchema }),
119
+ z.object({ rule: z.string().min(1) }),
120
+ ]);
121
+ const setupSchema = z.object({
122
+ target: connectionTargetSchema,
123
+ id: z.string(),
124
+ vendor: z.string(),
125
+ method: z.string(),
126
+ definition: z.string(),
127
+ credential: z.string().optional(),
128
+ location: z
129
+ .object({ store: z.string(), namespace: z.string(), path: z.string(), management: z.enum(["sanoma", "external"]) })
130
+ .optional(),
131
+ expectedRevision: z.string().nullable(),
132
+ requiredScopes: z.array(z.string()),
133
+ expectedAccount: z.string().optional(),
134
+ previousAccount: z.string().optional(),
135
+ previousLocation: z
136
+ .object({ store: z.string(), namespace: z.string(), path: z.string(), management: z.enum(["sanoma", "external"]) })
137
+ .optional(),
138
+ });
62
139
 
63
140
  export function defineConnection<C extends Connector<any, any>>(
64
141
  connector: C,
@@ -189,7 +266,13 @@ type ConnectionRow = {
189
266
  revision: string;
190
267
  definition: string;
191
268
  credential: string | null;
192
- status: "connected" | "disconnected";
269
+ location?: SecretLocation | null;
270
+ status: "connected" | "disconnected" | "pending" | "uncertain";
271
+ updated_by?: string;
272
+ updated_at?: Date;
273
+ health?: ConnectionHealth["state"];
274
+ checked_at?: Date;
275
+ externally_renewed?: boolean;
193
276
  };
194
277
  const snapshot = (r: ConnectionRow): ConnectionSnapshot => ({
195
278
  id: r.id,
@@ -222,6 +305,285 @@ export class ConnectionRegistry {
222
305
  return new ConnectionRegistry(this.catalog, async () => db, this.secrets);
223
306
  }
224
307
 
308
+ /** Safe card data only. Listing does not read credential values or contact a vendor. */
309
+ async cards(actor: string): Promise<ConnectionCard[]> {
310
+ const rows = (
311
+ await (
312
+ await this.db()
313
+ ).query<ConnectionRow>(
314
+ "SELECT c.*, h.health, h.checked_at, h.externally_renewed FROM sanoma_connections c LEFT JOIN sanoma_connection_health h ON c.id=h.connection_id AND c.revision=h.revision WHERE c.owner IS NULL OR c.owner=$1",
315
+ [actor],
316
+ )
317
+ ).rows;
318
+ const found = new Map(rows.map((r) => [r.id, r]));
319
+ const ids = new Set([...this.catalog.definitions.keys(), ...rows.filter((r) => r.rule).map((r) => r.id)]);
320
+ const cards: ConnectionCard[] = [];
321
+ for (const id of ids) {
322
+ const row = found.get(id);
323
+ const declaration = row?.rule ? this.catalog.rules.get(row.rule) : this.catalog.definitions.get(id);
324
+ if (!declaration || (row?.rule && row.owner !== actor)) continue;
325
+ const method = this.catalog.methods.get(declaration.vendor)?.[declaration.method];
326
+ if (!method) continue;
327
+ const credential =
328
+ method.kind === "workload-identity"
329
+ ? undefined
330
+ : "default" in declaration
331
+ ? declaration.credentials?.$secret
332
+ : row?.credential;
333
+ cards.push({
334
+ id,
335
+ vendor: declaration.vendor,
336
+ name: row?.rule ? id.slice(declaration.vendor.length + 1) : declaration.name,
337
+ method: declaration.method,
338
+ ...(row && { account: row.account, revision: row.revision }),
339
+ scopes: row?.scopes ?? [],
340
+ requiredScopes: declaration.scopes,
341
+ ...("account" in declaration && declaration.account && { expectedAccount: declaration.account }),
342
+ ...(row?.owner && { owner: row.owner }),
343
+ status: !row
344
+ ? "missing"
345
+ : row.definition !== this.bindingFingerprint(declaration, method, credential)
346
+ ? "configuration_changed"
347
+ : row.status,
348
+ ...(credential && this.secrets && { location: this.secrets.location(credential) }),
349
+ ...(row?.location && { boundLocation: row.location }),
350
+ ...(row?.updated_by && { updatedBy: row.updated_by }),
351
+ ...(row?.updated_at && { updatedAt: row.updated_at.toISOString() }),
352
+ ...(row?.health &&
353
+ row.checked_at && {
354
+ health: {
355
+ state: row.health,
356
+ checkedAt: row.checked_at.toISOString(),
357
+ externallyRenewed: row.externally_renewed ?? false,
358
+ },
359
+ }),
360
+ });
361
+ }
362
+ return cards;
363
+ }
364
+
365
+ /** Resolve code-defined setup authority without reading a credential. The request id stays
366
+ * stable across preview, approval and retry; account allocation is scoped to its principal. */
367
+ async setup(target: ConnectionTarget, actor: Principal, requestId: string): Promise<ConnectionSetup> {
368
+ let declaration: ConnectionDefinition | ConnectionRule | undefined;
369
+ let row: ConnectionRow | undefined;
370
+ let id: string;
371
+ let credential: string | undefined;
372
+ if ("rule" in target) {
373
+ const rule = this.catalog.rules.get(target.rule);
374
+ if (
375
+ !rule ||
376
+ !rule.createBy.some((by) => (typeof by === "string" ? by === actor.id : actor.groups?.includes(by.group)))
377
+ )
378
+ throw new SanomaError("policy_denied", "This principal cannot create connections under this rule");
379
+ declaration = rule;
380
+ const allocation = createHash("sha256")
381
+ .update(JSON.stringify([actor.id, target.rule, requestId]))
382
+ .digest("hex");
383
+ id = `${target.rule}/${allocation}`;
384
+ credential = `CONNECTION_${allocation.toUpperCase()}`;
385
+ row = await this.row(id);
386
+ } else {
387
+ id = target.connection.$connection;
388
+ row = await this.row(id);
389
+ declaration = row?.rule ? this.catalog.rules.get(row.rule) : this.catalog.definitions.get(id);
390
+ if (!declaration || declaration.vendor !== target.connection.vendor) refuse("The connection is not declared");
391
+ if (row?.rule && row.owner !== actor.id)
392
+ throw new SanomaError("policy_denied", "The connection belongs to another principal");
393
+ credential = "credentials" in declaration ? declaration.credentials?.$secret : (row?.credential ?? undefined);
394
+ }
395
+ const method = this.catalog.methods.get(declaration.vendor)?.[declaration.method];
396
+ if (!method?.verify) throw new AuthenticationError("unsupported");
397
+ if (method.kind === "workload-identity") credential = undefined;
398
+ this.checkStore(method, credential);
399
+ return {
400
+ target,
401
+ id,
402
+ vendor: declaration.vendor,
403
+ method: declaration.method,
404
+ definition: this.bindingFingerprint(declaration, method, credential),
405
+ ...(credential && { credential, ...(this.secrets && { location: this.secrets.location(credential) }) }),
406
+ expectedRevision: row?.revision ?? null,
407
+ requiredScopes: declaration.scopes,
408
+ ...("account" in declaration && declaration.account && { expectedAccount: declaration.account }),
409
+ ...(row && { previousAccount: row.account }),
410
+ ...(row?.location && { previousLocation: row.location }),
411
+ };
412
+ }
413
+
414
+ /** Metadata-only, policy-authorized reservation. Calls cannot use it until activation verifies
415
+ * the saved bundle. A store write failure leaves an explicit uncertain, unusable account. */
416
+ async configure(
417
+ target: ConnectionTarget,
418
+ requestId: string,
419
+ expectedRevision: string | null,
420
+ definition: string,
421
+ account: string,
422
+ scopes: string[],
423
+ actor: Principal,
424
+ ): Promise<ConnectionSnapshot> {
425
+ const setup = await this.setup(target, actor, requestId);
426
+ if (setup.definition !== definition || setup.expectedRevision !== expectedRevision)
427
+ refuse("The connection changed; review it again before saving");
428
+ if (setup.expectedAccount && setup.expectedAccount !== account) throw new AuthenticationError("wrong_account");
429
+ if (setup.requiredScopes.some((s) => !scopes.includes(s))) throw new AuthenticationError("scope_changed");
430
+ const current = await this.row(setup.id);
431
+ const row: ConnectionRow = {
432
+ id: setup.id,
433
+ vendor: setup.vendor,
434
+ method: setup.method,
435
+ owner: "rule" in target ? actor.id : (current?.owner ?? null),
436
+ rule: "rule" in target ? target.rule : (current?.rule ?? null),
437
+ account,
438
+ scopes,
439
+ revision: randomUUID(),
440
+ definition,
441
+ credential: setup.credential ?? null,
442
+ status: "pending",
443
+ };
444
+ await this.save(row, expectedRevision, actor.id);
445
+ return snapshot(row);
446
+ }
447
+
448
+ async uncertain(ref: ConnectionRef, revision: string, actor: string): Promise<void> {
449
+ await (
450
+ await this.db()
451
+ ).query(
452
+ "UPDATE sanoma_connections SET status='uncertain',updated_by=$3,updated_at=now() WHERE id=$1 AND revision=$2 AND status='pending'",
453
+ [ref.$connection, revision, actor],
454
+ );
455
+ }
456
+
457
+ async storedVersion(id: string, revision: string): Promise<string | undefined> {
458
+ return (
459
+ await (
460
+ await this.db()
461
+ ).query<{ credential_version: string }>(
462
+ "SELECT credential_version FROM sanoma_connection_setups WHERE connection_id=$1 AND revision=$2",
463
+ [id, revision],
464
+ )
465
+ ).rows[0]?.credential_version;
466
+ }
467
+
468
+ async recordStoredVersion(id: string, revision: string, version: string): Promise<void> {
469
+ await (
470
+ await this.db()
471
+ ).query(
472
+ "INSERT INTO sanoma_connection_setups (connection_id,revision,credential_version) SELECT id,revision,$3 FROM sanoma_connections WHERE id=$1 AND revision=$2 AND status='pending' ON CONFLICT DO NOTHING",
473
+ [id, revision, version],
474
+ );
475
+ if ((await this.storedVersion(id, revision)) !== version) throw new SecretStoreError("conflict");
476
+ }
477
+
478
+ private async health(
479
+ id: string,
480
+ revision: string,
481
+ state: ConnectionHealth["state"],
482
+ version?: string,
483
+ external = false,
484
+ ): Promise<ConnectionHealth> {
485
+ const previous = (
486
+ await (
487
+ await this.db()
488
+ ).query<{ credential_version: string | null }>(
489
+ "SELECT credential_version FROM sanoma_connection_health WHERE connection_id=$1 AND revision=$2",
490
+ [id, revision],
491
+ )
492
+ ).rows[0];
493
+ const renewed =
494
+ external &&
495
+ previous?.credential_version != null &&
496
+ version !== undefined &&
497
+ previous.credential_version !== version;
498
+ const result = await (
499
+ await this.db()
500
+ ).query<{ checked_at: Date }>(
501
+ "INSERT INTO sanoma_connection_health (connection_id,revision,health,credential_version,externally_renewed) SELECT id,revision,$3,$4,$5 FROM sanoma_connections WHERE id=$1 AND revision=$2 ON CONFLICT(connection_id) DO UPDATE SET revision=excluded.revision,health=excluded.health,credential_version=excluded.credential_version,externally_renewed=excluded.externally_renewed,checked_at=now() RETURNING checked_at",
502
+ [id, revision, state, version ?? previous?.credential_version ?? null, renewed],
503
+ );
504
+ const checked = result.rows[0];
505
+ if (!checked) refuse("The connection changed during the test; reload before testing it again");
506
+ return { state, checkedAt: checked.checked_at.toISOString(), externallyRenewed: renewed };
507
+ }
508
+
509
+ async test(ref: ConnectionRef, actor: Principal): Promise<ConnectionHealth> {
510
+ const setup = await this.setup({ connection: ref }, actor, "test");
511
+ const row = await this.row(setup.id);
512
+ if (!row) return { state: "missing", checkedAt: new Date().toISOString(), externallyRenewed: false };
513
+ if (row.definition !== setup.definition)
514
+ return { state: "configuration_changed", checkedAt: new Date().toISOString(), externallyRenewed: false };
515
+ if (row.status !== "connected") return this.health(row.id, row.revision, row.status);
516
+ try {
517
+ const material = await this.authenticate(snapshot(row), actor.id);
518
+ return this.health(
519
+ row.id,
520
+ row.revision,
521
+ "connected",
522
+ material.credentialVersion,
523
+ setup.location?.management === "external" || !this.secrets?.store.capabilities.writable,
524
+ );
525
+ } catch (error) {
526
+ const code = (error as { code?: string })?.code;
527
+ const state =
528
+ authenticationProblem(error) ??
529
+ (code === "denied" ? "store_denied" : code === "invalid_value" ? "invalid" : "unavailable");
530
+ return this.health(row.id, row.revision, state);
531
+ }
532
+ }
533
+
534
+ async setupState(selected: ConnectionSnapshot, actor: Principal): Promise<"pending" | "connected"> {
535
+ const setup = await this.setup(
536
+ { connection: { vendor: selected.vendor, $connection: selected.id } },
537
+ actor,
538
+ "state",
539
+ );
540
+ const row = await this.row(selected.id);
541
+ if (
542
+ !row ||
543
+ row.definition !== setup.definition ||
544
+ row.account !== selected.account ||
545
+ JSON.stringify(row.scopes.toSorted()) !== JSON.stringify(selected.scopes.toSorted())
546
+ )
547
+ refuse("The connection changed; review it again before saving");
548
+ if (row.status === "connected") return "connected";
549
+ if (row.status !== "pending" || row.revision !== selected.revision)
550
+ refuse("The connection changed; review it again before saving");
551
+ return "pending";
552
+ }
553
+
554
+ /** Used inside an authorized operation; verification reads stay inside its driver step. */
555
+ async activate(ref: ConnectionRef, revision: string, actor: Principal): Promise<ConnectionSnapshot> {
556
+ const setup = await this.setup({ connection: ref }, actor, "activate");
557
+ const row = await this.row(setup.id);
558
+ if (
559
+ !row ||
560
+ row.revision !== revision ||
561
+ row.definition !== setup.definition ||
562
+ !["pending", "uncertain"].includes(row.status)
563
+ )
564
+ refuse("The connection changed; review it again before activating");
565
+ const method = this.catalog.methods.get(row.vendor)![row.method]!;
566
+ const bundle = row.credential ? await this.secrets?.read(row.credential) : undefined;
567
+ if (row.credential && !bundle) throw new SecretStoreError("unavailable");
568
+ const identity = await verifyAuthentication(method, bundle && decodeCredentials(method, bundle.value), {
569
+ connectionId: row.id,
570
+ vendor: row.vendor,
571
+ });
572
+ if (identity.account !== row.account) throw new AuthenticationError("wrong_account");
573
+ if (JSON.stringify(identity.scopes) !== JSON.stringify([...new Set(row.scopes)].toSorted()))
574
+ throw new AuthenticationError("scope_changed");
575
+ const next = randomUUID();
576
+ const result = await (
577
+ await this.db()
578
+ ).query(
579
+ "UPDATE sanoma_connections SET status='connected',revision=$3,updated_by=$4,updated_at=now() WHERE id=$1 AND revision=$2 RETURNING id",
580
+ [row.id, revision, next, actor.id],
581
+ );
582
+ if (!result.rowCount) refuse("The connection changed; review it again before activating");
583
+ await this.health(row.id, next, "connected", bundle?.version);
584
+ return snapshot({ ...row, revision: next, status: "connected" });
585
+ }
586
+
225
587
  private bindingFingerprint(
226
588
  declaration: ConnectionDefinition | ConnectionRule,
227
589
  method: Authentication,
@@ -286,10 +648,43 @@ export class ConnectionRegistry {
286
648
  async assertCurrent(selected: ConnectionSnapshot | undefined, actor: string): Promise<void> {
287
649
  if (!selected) return;
288
650
  const current = await this.select(selected.vendor, { vendor: selected.vendor, $connection: selected.id }, actor);
289
- if (!current || current.revision !== selected.revision || current.definition !== selected.definition)
651
+ if (
652
+ !current ||
653
+ current.revision !== selected.revision ||
654
+ current.definition !== selected.definition ||
655
+ current.method !== selected.method ||
656
+ current.account !== selected.account ||
657
+ current.credentials?.$secret !== selected.credentials?.$secret ||
658
+ JSON.stringify(current.scopes.toSorted()) !== JSON.stringify(selected.scopes.toSorted())
659
+ )
290
660
  refuse("The connection changed after authorization; start a new call");
291
661
  }
292
662
 
663
+ /** Only called inside an authorized driver boundary. No value escapes into workflow history. */
664
+ async authenticate(selected: ConnectionSnapshot, actor: string): Promise<CallAuthentication> {
665
+ await this.assertCurrent(selected, actor);
666
+ const method = this.catalog.methods.get(selected.vendor)?.[selected.method];
667
+ if (!method?.verify) throw new AuthenticationError("unsupported");
668
+ const material: CallAuthentication = { method: selected.method, kind: method.kind };
669
+ if (method.kind !== "workload-identity") {
670
+ if (!this.secrets || !selected.credentials) throw new SecretStoreError("unavailable");
671
+ const bundle = await this.secrets.read(selected.credentials.$secret);
672
+ if (!bundle) throw new AuthenticationError("missing");
673
+ material.credentials = decodeCredentials(method, bundle.value);
674
+ material.credentialVersion = bundle.version;
675
+ }
676
+ const identity = await verifyAuthentication(method, material.credentials, {
677
+ connectionId: selected.id,
678
+ vendor: selected.vendor,
679
+ });
680
+ if (identity.account !== selected.account) throw new AuthenticationError("wrong_account");
681
+ if (JSON.stringify(identity.scopes) !== JSON.stringify([...new Set(selected.scopes)].toSorted()))
682
+ throw new AuthenticationError("scope_changed");
683
+ // A disconnect or rebind while verification was in flight revokes this invocation too.
684
+ await this.assertCurrent(selected, actor);
685
+ return material;
686
+ }
687
+
293
688
  async bind(
294
689
  ref: ConnectionRef,
295
690
  account: string,
@@ -395,11 +790,13 @@ export class ConnectionRegistry {
395
790
  row.definition,
396
791
  row.credential,
397
792
  actor,
793
+ row.status,
794
+ row.credential && this.secrets ? JSON.stringify(this.secrets.location(row.credential)) : null,
398
795
  ];
399
796
  const query =
400
797
  expected === null
401
- ? "INSERT INTO sanoma_connections (id,vendor,method,owner,rule,account,scopes,revision,definition,credential,updated_by) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11) ON CONFLICT(id) DO NOTHING RETURNING id"
402
- : "UPDATE sanoma_connections SET vendor=$2,method=$3,owner=$4,rule=$5,account=$6,scopes=$7,revision=$8,definition=$9,credential=$10,updated_by=$11,updated_at=now(),status='connected' WHERE id=$1 AND revision=$12 RETURNING id";
798
+ ? "INSERT INTO sanoma_connections (id,vendor,method,owner,rule,account,scopes,revision,definition,credential,updated_by,status,location) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13) ON CONFLICT(id) DO NOTHING RETURNING id"
799
+ : "UPDATE sanoma_connections SET vendor=$2,method=$3,owner=$4,rule=$5,account=$6,scopes=$7,revision=$8,definition=$9,credential=$10,updated_by=$11,updated_at=now(),status=$12,location=$13 WHERE id=$1 AND revision=$14 RETURNING id";
403
800
  const result = await (await this.db()).query(query, [...values, ...(expected === null ? [] : [expected])]);
404
801
  if (!result.rowCount) refuse("The connection changed; reload before binding it");
405
802
  }
@@ -408,6 +805,64 @@ export class ConnectionRegistry {
408
805
  /** Metadata mutations are access operations, so they are policy-reviewed and audited like other effects. */
409
806
  export const connectionOps = defineConnector("sanoma", {
410
807
  connection: {
808
+ test: {
809
+ effect: "read",
810
+ input: z.object({ connection: connectionRefSchema }),
811
+ output: z.object({
812
+ state: z.enum([
813
+ "missing",
814
+ "expired",
815
+ "revoked",
816
+ "rejected",
817
+ "wrong_account",
818
+ "scope_changed",
819
+ "uncertain",
820
+ "unavailable",
821
+ "unsupported",
822
+ "connected",
823
+ "disconnected",
824
+ "pending",
825
+ "configuration_changed",
826
+ "store_denied",
827
+ "invalid",
828
+ ]),
829
+ checkedAt: z.string(),
830
+ externallyRenewed: z.boolean(),
831
+ }),
832
+ target: (input: { connection: ConnectionRef }) => input.connection.$connection,
833
+ },
834
+ prepare: {
835
+ effect: "access",
836
+ input: z.object({
837
+ target: connectionTargetSchema,
838
+ requestId: z.string().min(1),
839
+ definition: z.string(),
840
+ authorization: z.string(),
841
+ }),
842
+ output: setupSchema,
843
+ target: (input: { target: ConnectionTarget }) =>
844
+ "connection" in input.target ? input.target.connection.$connection : input.target.rule,
845
+ },
846
+ configure: {
847
+ effect: "access",
848
+ input: z.object({
849
+ target: connectionTargetSchema,
850
+ requestId: z.string().min(1),
851
+ expectedRevision: z.string().nullable(),
852
+ definition: z.string(),
853
+ account: z.string().min(1),
854
+ scopes: z.array(z.string()),
855
+ }),
856
+ output: snapshotSchema,
857
+ target: (input: { target: ConnectionTarget }) =>
858
+ "connection" in input.target ? input.target.connection.$connection : input.target.rule,
859
+ },
860
+ activate: {
861
+ effect: "access",
862
+ input: z.object({ connection: connectionRefSchema, expectedRevision: z.string() }),
863
+ output: snapshotSchema,
864
+ target: (input: { connection: ConnectionRef }) => input.connection.$connection,
865
+ },
411
866
  disconnect: {
412
867
  effect: "access",
413
868
  input: z.object({ connection: connectionRefSchema, expectedRevision: z.string() }),
@@ -437,6 +892,23 @@ const actor = (call: CallContext) => call.principal ?? refuse("Connection mutati
437
892
  export function connectionDriver(registry: ConnectionRegistry) {
438
893
  return defineDriver(connectionOps, {
439
894
  connection: {
895
+ test: (input, call) => registry.test(input.connection, actor(call)),
896
+ prepare: async (input, call) => {
897
+ const setup = await registry.setup(input.target, actor(call), input.requestId);
898
+ if (setup.definition !== input.definition) refuse("The connection changed; review its requirements again");
899
+ return { ...setup, requiredScopes: [...setup.requiredScopes] };
900
+ },
901
+ configure: (input, call) =>
902
+ registry.configure(
903
+ input.target,
904
+ input.requestId,
905
+ input.expectedRevision,
906
+ input.definition,
907
+ input.account,
908
+ input.scopes,
909
+ actor(call),
910
+ ),
911
+ activate: (input, call) => registry.activate(input.connection, input.expectedRevision, actor(call)),
440
912
  disconnect: (input, call) => registry.disconnect(input.connection, input.expectedRevision, actor(call).id),
441
913
  bind: (input, call) =>
442
914
  registry.bind(input.connection, input.account, input.scopes, input.expectedRevision, actor(call).id),
package/src/db.ts CHANGED
@@ -31,13 +31,26 @@ export const CHAT_MESSAGES_TABLE = "sanoma_chat_messages";
31
31
 
32
32
  /** The runtime's tables, each with the word the messages name it by. */
33
33
  const TABLES: readonly [what: string, ddl: string][] = [
34
+ [
35
+ "connection health",
36
+ `CREATE TABLE IF NOT EXISTS sanoma_connection_health (
37
+ connection_id text PRIMARY KEY, revision text NOT NULL, health text NOT NULL,
38
+ credential_version text, externally_renewed boolean NOT NULL, checked_at timestamptz NOT NULL DEFAULT now())`,
39
+ ],
40
+ [
41
+ "connection setup receipts",
42
+ `CREATE TABLE IF NOT EXISTS sanoma_connection_setups (
43
+ connection_id text NOT NULL, revision text NOT NULL, credential_version text NOT NULL,
44
+ PRIMARY KEY(connection_id, revision))`,
45
+ ],
34
46
  [
35
47
  "connections",
36
48
  `CREATE TABLE IF NOT EXISTS sanoma_connections (
37
49
  id text PRIMARY KEY, vendor text NOT NULL, method text NOT NULL, owner text, rule text,
38
50
  account text NOT NULL, scopes jsonb NOT NULL, revision text NOT NULL, definition text NOT NULL,
39
- credential text, status text NOT NULL DEFAULT 'connected', updated_by text NOT NULL,
40
- updated_at timestamptz NOT NULL DEFAULT now())`,
51
+ credential text, location jsonb, status text NOT NULL DEFAULT 'connected', updated_by text NOT NULL,
52
+ updated_at timestamptz NOT NULL DEFAULT now());
53
+ ALTER TABLE sanoma_connections ADD COLUMN IF NOT EXISTS location jsonb`,
41
54
  ],
42
55
  [
43
56
  "credentials",
package/src/describe.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { resolve } from "node:path";
1
2
  import type { z } from "zod";
2
3
  import { type AppEntry, appsAt } from "./apps.ts";
3
4
  import { readSource, type SourceFile } from "./ast.ts";
@@ -17,13 +18,16 @@ import {
17
18
  import { isDirect } from "./direct.ts";
18
19
  import { type CredentialStatus, credentialsOf } from "./env.ts";
19
20
  import { lintWorkflow } from "./lint.ts";
21
+ import { fresh, stampOf, stampsOf } from "./load.ts";
22
+ import { LOCKFILE } from "./lockfile.ts";
20
23
  import { VENDOR, type VendorInfo } from "./op.ts";
21
24
  import { outlineWithSource } from "./outline.ts";
25
+ import { APPS_DIR, kindDirs } from "./paths.ts";
22
26
  import { allowAll, policyOpOf } from "./policy.ts";
23
27
  import { type Resource, resourceTypesOf, valueAt } from "./resource.ts";
24
28
  import { type DeclaredResource, inAppPeople, loadDataFiles, type ResourceProblem } from "./resources.ts";
25
29
  import { apiNameProblem } from "./sdk.ts";
26
- import { apiNameOf, BUILT_IN_WORKFLOWS, DATA_VENDOR, problemLine } from "./shared.ts";
30
+ import { apiNameOf, BUILT_IN_WORKFLOWS, DATA_VENDOR, errorMessage, problemLine } from "./shared.ts";
27
31
  import { readSites, type SiteEntry } from "./sites.ts";
28
32
 
29
33
  // `@sanoma-ai/workflows/describe`: what a UI renders from, apart from the main entry.
@@ -79,6 +83,53 @@ export { previewRequests } from "./shared.ts";
79
83
  // The apply workflow's plan and report, which the app renders.
80
84
  export type { ApplyInput, ApplyPlan, ApplyReport, ApplyResult, PlanAnswer, PlanStep } from "./apply.ts";
81
85
 
86
+ /**
87
+ * `describeConfig(config)`, kept and made again only once the files it loaded have changed: each
88
+ * company file a load read and its folder (`stampsOf`), the lockfile, and the folders a new data
89
+ * file, app or site would appear in, one `stat` each per call. What the config imports (its
90
+ * workflows, connectors and policies) is read once, as the worker reads it. When describing again
91
+ * throws, the last description stands until the files change again.
92
+ */
93
+ export function liveDescription(config: SanomaConfig): () => ConfigDescription {
94
+ const root = rootOf(config);
95
+ if (root === undefined) {
96
+ const description = describeConfig(config);
97
+ return () => description;
98
+ }
99
+ /** The description and what it read; `last`, kept (and said) when describing again throws. */
100
+ const described = (last?: ConfigDescription) => {
101
+ // The folders and the lockfile as they are before describing, so a change while it reads is seen after.
102
+ const extra = [
103
+ resolve(root, LOCKFILE),
104
+ resolve(root, APPS_DIR),
105
+ ...kindDirs(root, "sites"),
106
+ ...kindDirs(root, "resources"),
107
+ ].map((path) => [path, stampOf(path)] as const);
108
+ let description: ConfigDescription;
109
+ try {
110
+ description = describeConfig(config);
111
+ } catch (err) {
112
+ if (last === undefined) throw err;
113
+ console.warn(
114
+ `sanoma: the files could not be described again, so the last description stands: ${errorMessage(err)}`,
115
+ );
116
+ description = last;
117
+ }
118
+ return { description, stamps: new Map([...extra, ...stampsOf(root)]) };
119
+ };
120
+ let now = described();
121
+ // The workflows as the config imported them, which the worker runs: their outlines and sources
122
+ // from the first description, never a file edited since.
123
+ const { workflows } = now.description;
124
+ return () => {
125
+ if (!fresh(now.stamps)) {
126
+ now = described(now.description);
127
+ now.description.workflows = workflows;
128
+ }
129
+ return now.description;
130
+ };
131
+ }
132
+
82
133
  /**
83
134
  * Describes a config. Throws what `startWorker` would refuse (see `resolveConfig`), except missing
84
135
  * credentials (`SanomaClient.credentials` gives their statuses) and what is wrong in a workflow's
package/src/direct.ts CHANGED
@@ -19,4 +19,8 @@ export const isDirect = (
19
19
  op.id === "sanoma.connection.disconnect" ||
20
20
  op.id === "sanoma.connection.bind" ||
21
21
  op.id === "sanoma.connection.create" ||
22
+ op.id === "sanoma.connection.prepare" ||
23
+ op.id === "sanoma.connection.configure" ||
24
+ op.id === "sanoma.connection.activate" ||
25
+ op.id === "sanoma.connection.test" ||
22
26
  (op.effect === "read" && op.vendor !== "sanoma" && !isResourceOp(op, types));