@sanoma-ai/workflows 0.10.0 → 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 (89) hide show
  1. package/README.md +33 -7
  2. package/SECRET-SUPPORT.md +33 -0
  3. package/dist/apps.d.ts +6 -0
  4. package/dist/apps.js +18 -1
  5. package/dist/apps.js.map +1 -1
  6. package/dist/authentication.d.ts +30 -0
  7. package/dist/authentication.js +71 -1
  8. package/dist/authentication.js.map +1 -1
  9. package/dist/call.js +7 -5
  10. package/dist/call.js.map +1 -1
  11. package/dist/client.d.ts +54 -6
  12. package/dist/client.js +177 -5
  13. package/dist/client.js.map +1 -1
  14. package/dist/config.d.ts +7 -4
  15. package/dist/config.js +44 -12
  16. package/dist/config.js.map +1 -1
  17. package/dist/connection.d.ts +223 -2
  18. package/dist/connection.js +334 -4
  19. package/dist/connection.js.map +1 -1
  20. package/dist/db.js +15 -2
  21. package/dist/db.js.map +1 -1
  22. package/dist/describe.d.ts +8 -0
  23. package/dist/describe.js +51 -1
  24. package/dist/describe.js.map +1 -1
  25. package/dist/direct.js +4 -0
  26. package/dist/direct.js.map +1 -1
  27. package/dist/fake.d.ts +13 -1
  28. package/dist/fake.js +70 -0
  29. package/dist/fake.js.map +1 -1
  30. package/dist/index.d.ts +4 -4
  31. package/dist/index.js +2 -2
  32. package/dist/index.js.map +1 -1
  33. package/dist/lint.js +1 -1
  34. package/dist/lint.js.map +1 -1
  35. package/dist/load.d.ts +11 -0
  36. package/dist/load.js +42 -8
  37. package/dist/load.js.map +1 -1
  38. package/dist/op.d.ts +14 -6
  39. package/dist/op.js +20 -2
  40. package/dist/op.js.map +1 -1
  41. package/dist/outline.d.ts +5 -0
  42. package/dist/outline.js +33 -1
  43. package/dist/outline.js.map +1 -1
  44. package/dist/paths.d.ts +2 -4
  45. package/dist/paths.js +2 -8
  46. package/dist/paths.js.map +1 -1
  47. package/dist/run.d.ts +3 -1
  48. package/dist/run.js.map +1 -1
  49. package/dist/sandbox.d.ts +4 -1
  50. package/dist/sandbox.js +51 -5
  51. package/dist/sandbox.js.map +1 -1
  52. package/dist/scenario.d.ts +5 -0
  53. package/dist/scenario.js +3 -2
  54. package/dist/scenario.js.map +1 -1
  55. package/dist/secret.d.ts +2 -0
  56. package/dist/secret.js +32 -0
  57. package/dist/secret.js.map +1 -1
  58. package/dist/shared.d.ts +4 -0
  59. package/dist/shared.js +7 -0
  60. package/dist/shared.js.map +1 -1
  61. package/dist/sites.js +18 -3
  62. package/dist/sites.js.map +1 -1
  63. package/dist/steps.d.ts +3 -0
  64. package/dist/steps.js.map +1 -1
  65. package/oxlint.json +1 -0
  66. package/package.json +2 -1
  67. package/src/apps.ts +17 -1
  68. package/src/authentication.ts +119 -2
  69. package/src/call.ts +4 -5
  70. package/src/client.ts +240 -6
  71. package/src/config.ts +45 -11
  72. package/src/connection.ts +472 -6
  73. package/src/db.ts +15 -2
  74. package/src/describe.ts +52 -1
  75. package/src/direct.ts +4 -0
  76. package/src/fake.ts +71 -0
  77. package/src/index.ts +12 -1
  78. package/src/lint.ts +1 -1
  79. package/src/load.ts +36 -7
  80. package/src/op.ts +33 -8
  81. package/src/outline.ts +35 -2
  82. package/src/paths.ts +2 -9
  83. package/src/run.ts +3 -1
  84. package/src/sandbox.ts +55 -5
  85. package/src/scenario.ts +24 -6
  86. package/src/secret.ts +29 -0
  87. package/src/shared.ts +9 -0
  88. package/src/sites.ts +19 -3
  89. package/src/steps.ts +3 -2
package/dist/steps.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { LedgerRecord } from "./ledger.ts";
2
2
  import type { Op } from "./op.ts";
3
+ import type { ConnectionRef } from "./connection.ts";
3
4
  /** Any operation, whatever its input and output. */
4
5
  type AnyOp<I = any, O = any> = Op<string, string, string, I, O>;
5
6
  /**
@@ -14,9 +15,11 @@ export type Seed = {
14
15
  op: AnyOp;
15
16
  input: Record<string, unknown>;
16
17
  as?: string;
18
+ connection?: ConnectionRef;
17
19
  } | {
18
20
  op: AnyOp;
19
21
  fault: "failNext" | "rateLimit" | "loseReply";
22
+ connection?: ConnectionRef;
20
23
  };
21
24
  /** A call through an operation: what it was given, and what it returned. */
22
25
  export interface Call<I, O> {
package/dist/steps.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"steps.js","sourceRoot":"","sources":["../src/steps.ts"],"names":[],"mappings":"AAgEA;;;;;;;;;;;GAWG;AACH,MAAM,UAAU,WAAW,CACzB,KAAa,EACb,MAGU;IAEV,MAAM,KAAK,GAAqB,EAAE,CAAC;IACnC,MAAM,CAAC;QACL,KAAK,EAAE,CAAC,UAAU,EAAE,EAAE,EAAE,EAAE,CAAC,KAAK,KAAK,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,OAAO,EAAE,UAAU,EAAE,EAAE,EAAE,CAAC;QAC7E,wFAAwF;QACxF,+CAA+C;QAC/C,IAAI,EAAE,CAAC,UAAU,EAAE,EAAE,EAAE,EAAE,CAAC,KAAK,KAAK,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,UAAU,EAAE,EAAE,EAAE,CAAC;KAC5E,CAAC,CAAC;IACH,OAAO,MAAM,CAAC,MAAM,CAAC,EAAE,KAAK,EAAE,KAAK,EAAE,MAAM,CAAC,MAAM,CAAC,KAAK,CAAC,EAAE,CAAC,CAAC;AAC/D,CAAC"}
1
+ {"version":3,"file":"steps.js","sourceRoot":"","sources":["../src/steps.ts"],"names":[],"mappings":"AAiEA;;;;;;;;;;;GAWG;AACH,MAAM,UAAU,WAAW,CACzB,KAAa,EACb,MAGU;IAEV,MAAM,KAAK,GAAqB,EAAE,CAAC;IACnC,MAAM,CAAC;QACL,KAAK,EAAE,CAAC,UAAU,EAAE,EAAE,EAAE,EAAE,CAAC,KAAK,KAAK,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,OAAO,EAAE,UAAU,EAAE,EAAE,EAAE,CAAC;QAC7E,wFAAwF;QACxF,+CAA+C;QAC/C,IAAI,EAAE,CAAC,UAAU,EAAE,EAAE,EAAE,EAAE,CAAC,KAAK,KAAK,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,UAAU,EAAE,EAAE,EAAE,CAAC;KAC5E,CAAC,CAAC;IACH,OAAO,MAAM,CAAC,MAAM,CAAC,EAAE,KAAK,EAAE,KAAK,EAAE,MAAM,CAAC,MAAM,CAAC,KAAK,CAAC,EAAE,CAAC,CAAC;AAC/D,CAAC"}
package/oxlint.json CHANGED
@@ -94,6 +94,7 @@
94
94
  "@sanoma-ai/connector-*/fake",
95
95
  "@sanoma-ai/connector-*/driver",
96
96
  "@sanoma-ai/connector-*/remote",
97
+ "@sanoma-ai/connector-*/vault",
97
98
  "@sanoma-ai/workflows/describe",
98
99
  "@sanoma-ai/workflows/fake",
99
100
  "@sanoma-ai/workflows/plugin",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sanoma-ai/workflows",
3
- "version": "0.10.0",
3
+ "version": "0.10.1",
4
4
  "description": "Business processes as TypeScript functions, run durably on DBOS",
5
5
  "keywords": [
6
6
  "approvals",
@@ -24,6 +24,7 @@
24
24
  "!src/AGENTS.md",
25
25
  "oxlint.json",
26
26
  "README.md",
27
+ "SECRET-SUPPORT.md",
27
28
  "LICENSE"
28
29
  ],
29
30
  "type": "module",
package/src/apps.ts CHANGED
@@ -7,7 +7,7 @@ import { exportsOf, type Loaded, loadModules } from "./load.ts";
7
7
  import { stringValue } from "./outline.ts";
8
8
  import { APPS_DIR, filesUnder, inside, isDirectory, RELATIVE, relPath } from "./paths.ts";
9
9
  import type { ResourceProblem } from "./resources.ts";
10
- import { APP_NAME } from "./shared.ts";
10
+ import { APP_NAME, problemLine } from "./shared.ts";
11
11
 
12
12
  // A company's apps: each folder of `apps/` holds the kinds the root does (workflows, resources,
13
13
  // policies, scenarios, sites), merged from a source repository whose URL its `app.ts` names, or
@@ -65,6 +65,22 @@ export function appsAt(root: string | undefined): { apps: AppEntry[]; problems:
65
65
  return { apps, problems };
66
66
  }
67
67
 
68
+ /**
69
+ * The source URL the app `folder` under `root` is taken from, as its `app.ts` names it; an error
70
+ * saying why there is none: no such app, its `app.ts` does not load or is not `defineApp({ source })`
71
+ * (what is wrong, where), or it names none (the company's own app).
72
+ */
73
+ export function appSourceOf(root: string, folder: string): string {
74
+ if (!APP_NAME.test(folder) || !isDirectory(join(root, APPS_DIR, folder)))
75
+ throw new Error(`There is no app ${APPS_DIR}/${folder}/`);
76
+ const file = `${APPS_DIR}/${folder}/app.ts`;
77
+ const path = join(root, file);
78
+ const read = existsSync(path) ? appDefinitionOf(file, loadModules(root, [path], [])) : { problems: [] };
79
+ if (read.problems.length) throw new Error(read.problems.map(problemLine).join("; "));
80
+ if (read.source === undefined) throw new Error(`${file} names no source: the app is the company's own`);
81
+ return read.source;
82
+ }
83
+
68
84
  /** What `app.ts`'s default export, `defineApp({ … })`, may say. */
69
85
  const AppDefinitionSchema = z.strictObject({ source: z.string().optional() });
70
86
 
@@ -1,6 +1,95 @@
1
1
  import type { z } from "zod";
2
+ import type { CallContext } from "./op.ts";
2
3
  import { SecretStoreError, type SecretStoreCapabilities } from "./secret-store.ts";
3
4
 
5
+ /** Facts verified with the provider, not account claims supplied by the caller. */
6
+ export interface AuthenticationIdentity {
7
+ account: string;
8
+ scopes: readonly string[];
9
+ /** Unix time in milliseconds. Missing means the provider gives no expiry. */
10
+ expiresAt?: number;
11
+ }
12
+
13
+ export type AuthenticationProblem =
14
+ | "missing"
15
+ | "expired"
16
+ | "revoked"
17
+ | "rejected"
18
+ | "wrong_account"
19
+ | "scope_changed"
20
+ | "uncertain"
21
+ | "unavailable"
22
+ | "unsupported";
23
+
24
+ /** Safe lifecycle information only: never a provider response, cause or credential. */
25
+ export class AuthenticationError extends Error {
26
+ readonly code = "authentication_failed";
27
+ readonly retryable: boolean;
28
+ readonly problem: AuthenticationProblem;
29
+ constructor(problem: AuthenticationProblem) {
30
+ super(`Authentication: ${problem}`);
31
+ this.problem = problem;
32
+ this.name = "AuthenticationError";
33
+ this.retryable = problem === "unavailable";
34
+ }
35
+ }
36
+
37
+ const authenticationProblems = new Set<AuthenticationProblem>([
38
+ "missing",
39
+ "expired",
40
+ "revoked",
41
+ "rejected",
42
+ "wrong_account",
43
+ "scope_changed",
44
+ "uncertain",
45
+ "unavailable",
46
+ "unsupported",
47
+ ]);
48
+
49
+ export function authenticationProblem(error: unknown): AuthenticationProblem | undefined {
50
+ const failure = error as Partial<AuthenticationError> | null;
51
+ return failure?.name === "AuthenticationError" && authenticationProblems.has(failure.problem!)
52
+ ? failure.problem
53
+ : undefined;
54
+ }
55
+
56
+ /** Shared by credential intake and execution; unknown provider failures never imply revocation. */
57
+ export async function verifyAuthentication(
58
+ method: Authentication,
59
+ credentials?: unknown,
60
+ ): Promise<AuthenticationIdentity> {
61
+ if (!method.verify) throw new AuthenticationError("unsupported");
62
+ try {
63
+ const identity = await method.verify(credentials);
64
+ if (
65
+ typeof identity?.account !== "string" ||
66
+ !identity.account.trim() ||
67
+ !Array.isArray(identity.scopes) ||
68
+ identity.scopes.some((scope) => typeof scope !== "string") ||
69
+ (identity.expiresAt !== undefined && !Number.isFinite(identity.expiresAt))
70
+ )
71
+ throw new AuthenticationError("unavailable");
72
+ if (identity.expiresAt !== undefined && identity.expiresAt <= Date.now()) throw new AuthenticationError("expired");
73
+ return {
74
+ account: identity.account,
75
+ scopes: [...new Set(identity.scopes)].toSorted(),
76
+ ...(identity.expiresAt !== undefined && { expiresAt: identity.expiresAt }),
77
+ };
78
+ } catch (error) {
79
+ // Structural recognition works across installed copies. Rebuild even recognized errors so
80
+ // an SDK message, stack or cause attached by a verifier cannot cross this boundary.
81
+ throw new AuthenticationError(authenticationProblem(error) ?? "unavailable");
82
+ }
83
+ }
84
+
85
+ /** Ephemeral material for one authorized driver invocation; never checkpoint this object. */
86
+ export interface CallAuthentication {
87
+ method: string;
88
+ kind: Authentication["kind"];
89
+ credentialVersion?: string;
90
+ credentials?: unknown;
91
+ }
92
+
4
93
  /** Credential schemas and lifecycle are owned by authentication, never by the store provider. */
5
94
  export type Authentication<S extends z.ZodType = z.ZodType> =
6
95
  | {
@@ -8,14 +97,23 @@ export type Authentication<S extends z.ZodType = z.ZodType> =
8
97
  version: number;
9
98
  schema: S;
10
99
  storage: { writable: boolean; maxBytes: number; conditionalWrites: boolean };
100
+ verify?(credentials: z.output<S>): Promise<AuthenticationIdentity>;
11
101
  }
12
- | { kind: "workload-identity"; storage?: never; version?: never; schema?: never };
102
+ | {
103
+ kind: "workload-identity";
104
+ storage?: never;
105
+ version?: never;
106
+ schema?: never;
107
+ verify?(): Promise<AuthenticationIdentity>;
108
+ };
13
109
 
14
110
  export function defineAuthentication<S extends z.ZodType>(definition: Authentication<S>): Authentication<S> {
111
+ if (definition.verify !== undefined && typeof definition.verify !== "function")
112
+ throw new SecretStoreError("invalid_configuration");
15
113
  if (definition.kind === "workload-identity") {
16
114
  if (definition.storage !== undefined || definition.schema !== undefined || definition.version !== undefined)
17
115
  throw new SecretStoreError("invalid_configuration");
18
- return Object.freeze({ kind: "workload-identity" });
116
+ return Object.freeze({ ...definition });
19
117
  }
20
118
  if (
21
119
  !["api-key", "fields", "oauth2", "signed"].includes(definition.kind) ||
@@ -33,6 +131,25 @@ export function defineAuthentication<S extends z.ZodType>(definition: Authentica
33
131
  return Object.freeze({ ...definition, storage: Object.freeze({ ...definition.storage }) });
34
132
  }
35
133
 
134
+ /** Typed access inside a driver. A missing or different method never falls back to the environment. */
135
+ export function authenticationCredentials<S extends z.ZodType>(
136
+ method: string,
137
+ definition: Authentication<S>,
138
+ call: CallContext,
139
+ ): z.output<S> {
140
+ if (
141
+ definition.kind === "workload-identity" ||
142
+ call.authentication?.method !== method ||
143
+ call.authentication.kind !== definition.kind
144
+ )
145
+ throw new SecretStoreError("unsupported");
146
+ try {
147
+ return definition.schema.parse(call.authentication.credentials);
148
+ } catch {
149
+ throw new SecretStoreError("invalid_value");
150
+ }
151
+ }
152
+
36
153
  /** Compatibility check before consent, including externally renewed (read-only) OAuth bundles. */
37
154
  export function checkAuthenticationStore(
38
155
  auth: Authentication,
package/src/call.ts CHANGED
@@ -458,9 +458,8 @@ async function callOp(run: Run, id: string, input: unknown, place: Placed, ref?:
458
458
  const connection = await DBOS.runStep(
459
459
  async () => {
460
460
  if (run.sandbox !== undefined) {
461
- if (ref)
462
- throw new SanomaError("invalid_input", "Named sandbox connections require explicit fake account state");
463
- return undefined;
461
+ if (!run.fakeConnection) throw new SanomaError("invalid_input", "Sandbox account state is unavailable");
462
+ return run.fakeConnection(op.vendor, ref);
464
463
  }
465
464
  return run.state.connections?.select(op.vendor, ref, run.actor.id);
466
465
  },
@@ -469,7 +468,7 @@ async function callOp(run: Run, id: string, input: unknown, place: Placed, ref?:
469
468
  const decideStep = () =>
470
469
  DBOS.runStep(
471
470
  async () => {
472
- await run.state.connections?.assertCurrent(connection, run.actor.id);
471
+ if (run.sandbox === undefined) await run.state.connections?.assertCurrent(connection, run.actor.id);
473
472
  return decide(run, op, parsed, connection);
474
473
  },
475
474
  { name: `policy:${op.id}` },
@@ -527,7 +526,7 @@ async function callOp(run: Run, id: string, input: unknown, place: Placed, ref?:
527
526
  async () => {
528
527
  const at = Date.now();
529
528
  attempt = DBOS.stepStatus?.currentAttempt ?? 1;
530
- await run.state.connections?.assertCurrent(connection, run.actor.id);
529
+ if (run.sandbox === undefined) await run.state.connections?.assertCurrent(connection, run.actor.id);
531
530
  const secrets = secretsOf(run);
532
531
  const context: CallContext = {
533
532
  idempotencyKey: `${run.id}:${seq}`,
package/src/client.ts CHANGED
@@ -1,5 +1,16 @@
1
- import { connectionDriver, type ConnectionRef, type ConnectionSnapshot } from "./connection.ts";
2
- 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";
3
14
  import { isDeepStrictEqual } from "node:util";
4
15
  import { type DBOSClient, Error as DBOSErrors, type WorkflowStatusString } from "@dbos-inc/dbos-sdk";
5
16
  import type { Pool } from "pg";
@@ -53,7 +64,7 @@ import type { CallContext, Driver, Op } from "./op.ts";
53
64
  import { allowAll, type RecordedDecision } from "./policy.ts";
54
65
  import { admit, readPeople } from "./resources.ts";
55
66
  import type { RunArgs, StartHold } from "./run.ts";
56
- import type { SecretAccess, SecretLocation } from "./secret-store.ts";
67
+ import { SecretStoreError, type SecretAccess, type SecretLocation } from "./secret-store.ts";
57
68
  import {
58
69
  BUILT_IN_WORKFLOWS,
59
70
  CALL_WORKFLOW,
@@ -220,6 +231,15 @@ export interface CallOptions {
220
231
  id?: string;
221
232
  }
222
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
+ }
223
243
  /** What `SanomaClient.connect` is given besides the config. */
224
244
  export interface ClientOptions {
225
245
  /**
@@ -1148,10 +1168,224 @@ export class SanomaClient {
1148
1168
  if (current) await this.storedSecrets.delete(name, { expectedVersion: current.version, by });
1149
1169
  }
1150
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;
1384
+ }
1385
+
1151
1386
  /**
1152
- * The values of these secrets, as a driver's `secrets.get` reads them: this process's
1153
- * environment, which wins, else the stored value, with who set it and when. A name with neither
1154
- * 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.
1155
1389
  */
1156
1390
  async secrets(names: readonly string[]): Promise<Map<string, StoredSecret>> {
1157
1391
  const found = new Map<string, StoredSecret>();
package/src/config.ts CHANGED
@@ -1,4 +1,6 @@
1
1
  import { configuredSecrets } from "./credentials.ts";
2
+ import { secretsForInput } from "./secret.ts";
3
+ import { isRunControlError } from "./errors.ts";
2
4
  import { currentRun } from "./run.ts";
3
5
  import {
4
6
  connectionCatalog,
@@ -331,6 +333,7 @@ export function resolveConfig(config: SanomaConfig): ResolvedConfig {
331
333
  ...(dataStore ? [collectionsDriver(declared, dataStore)] : []),
332
334
  ],
333
335
  ops,
336
+ { connections },
334
337
  );
335
338
  const fakes = makersOf(running, "fake");
336
339
  if (dataStore) fakes.set(DATA_VENDOR, () => collectionsFake(declared));
@@ -617,11 +620,15 @@ function indexConnectors(list: Connector<any, any>[]) {
617
620
  }
618
621
 
619
622
  /**
620
- * The drivers' functions by operation id, refusing one no connector declares. A driver that
621
- * declares an `env` has each function guarded by it (`guarded`); a fake declares none, so a
622
- * sandbox run's calls are not.
623
+ * The drivers' functions by operation id, refusing one no connector declares. Named live
624
+ * calls resolve authentication here, after policy, inside the driver's execution boundary.
625
+ * Drivers without named authentication retain their declared environment guard.
623
626
  */
624
- export function indexDrivers(list: Driver[], ops: Map<string, Op>) {
627
+ export function indexDrivers(
628
+ list: Driver[],
629
+ ops: Map<string, Op>,
630
+ options: { connections?: ConnectionRegistry; sandbox?: boolean } = {},
631
+ ) {
625
632
  const map = new Map<string, DriverFn>();
626
633
  for (const d of list) {
627
634
  for (const [key, fn] of Object.entries(d.ops)) {
@@ -629,7 +636,36 @@ export function indexDrivers(list: Driver[], ops: Map<string, Op>) {
629
636
  if (!ops.has(id)) {
630
637
  throw new Error(`Driver "${d.vendor}" implements ${id}, which no connector in \`connectors\` declares`);
631
638
  }
632
- map.set(id, d.env ? guarded(d.vendor, d.env, fn) : fn);
639
+ const unselected = d.env ? guarded(d.vendor, d.env, fn) : fn;
640
+ map.set(id, async (input, call) => {
641
+ if (options.sandbox) return fn(input, call);
642
+ if (!call.connection) {
643
+ if (d.authentication?.length)
644
+ throw new DriverError("Select a named connection for this driver", { retryable: false });
645
+ return unselected(input, call);
646
+ }
647
+ if (!options.connections || !call.principal || !d.authentication?.includes(call.connection.method))
648
+ throw new DriverError("This driver does not support the selected connection", { retryable: false });
649
+ const authentication = await options.connections.authenticate(call.connection, call.principal.id);
650
+ try {
651
+ return await fn(input, {
652
+ ...call,
653
+ connection: structuredClone(call.connection),
654
+ authentication,
655
+ secrets: secretsForInput(input, call.secrets, ops.get(id)!.createsSecrets?.(input)),
656
+ });
657
+ } catch (error) {
658
+ if (isRunControlError(error)) throw error;
659
+ // SDK exceptions can contain the complete request, including authorization headers.
660
+ // Drivers may come from another installed copy of this package.
661
+ const failure = error as { code?: unknown; retryable?: unknown; status?: unknown } | null;
662
+ const status = failure?.code === "driver_failed" ? failure.status : undefined;
663
+ throw new DriverError(`${d.vendor} operation failed`, {
664
+ retryable: failure?.code === "driver_failed" && failure.retryable === true,
665
+ ...(typeof status === "number" && Number.isInteger(status) && status >= 100 && status <= 599 && { status }),
666
+ });
667
+ }
668
+ });
633
669
  }
634
670
  }
635
671
  return map;
@@ -637,12 +673,10 @@ export function indexDrivers(list: Driver[], ops: Map<string, Op>) {
637
673
 
638
674
  /**
639
675
  * `fn`, refused while the environment leaves a variable `env` declares missing or invalid. The
640
- * worker started without it (`startWorker` only warns), and stored credentials load into the
641
- * environment as they are set, so the check is made when the function is called, inside the
642
- * driver step, and nowhere earlier: not at start, and not before the policy's decision or an
643
- * approval hold, so a credential set on the connector page while a call waits for approval is
644
- * used when the approver approves. The refusal is the call's own failure, final: recorded as
645
- * the call's, and never retried.
676
+ * worker started without it (`startWorker` only warns). This checks only explicitly supplied
677
+ * process environment; stored credentials are never copied there. The guard runs inside the
678
+ * driver step after policy and approval. Named connections never use this path. The refusal
679
+ * is the call's own failure, final: recorded as the call's, and never retried.
646
680
  */
647
681
  function guarded(vendor: string, env: DriverEnv, fn: DriverFn): DriverFn {
648
682
  return async (input, call) => {