@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.
- package/README.md +193 -50
- package/SECRET-SUPPORT.md +33 -0
- package/dist/apply-steps.js +5 -2
- package/dist/apply-steps.js.map +1 -1
- package/dist/apply.d.ts +3 -0
- package/dist/apply.js +27 -13
- package/dist/apply.js.map +1 -1
- package/dist/approvals.d.ts +1 -0
- package/dist/approvals.js +6 -1
- package/dist/approvals.js.map +1 -1
- package/dist/apps.d.ts +13 -24
- package/dist/apps.js +69 -68
- package/dist/apps.js.map +1 -1
- package/dist/ast.d.ts +9 -7
- package/dist/ast.js +2 -0
- package/dist/ast.js.map +1 -1
- package/dist/authentication.d.ts +54 -0
- package/dist/authentication.js +138 -0
- package/dist/authentication.js.map +1 -0
- package/dist/call-workflow.d.ts +5 -0
- package/dist/call-workflow.js +4 -3
- package/dist/call-workflow.js.map +1 -1
- package/dist/call.d.ts +3 -2
- package/dist/call.js +36 -7
- package/dist/call.js.map +1 -1
- package/dist/cedar.js +14 -1
- package/dist/cedar.js.map +1 -1
- package/dist/change-steps.js +7 -5
- package/dist/change-steps.js.map +1 -1
- package/dist/change.d.ts +7 -0
- package/dist/change.js +7 -0
- package/dist/change.js.map +1 -1
- package/dist/client.d.ts +62 -10
- package/dist/client.js +272 -47
- package/dist/client.js.map +1 -1
- package/dist/collection.d.ts +7 -0
- package/dist/collection.js +5 -2
- package/dist/collection.js.map +1 -1
- package/dist/config.d.ts +16 -4
- package/dist/config.js +89 -21
- package/dist/config.js.map +1 -1
- package/dist/connection.d.ts +389 -0
- package/dist/connection.js +657 -0
- package/dist/connection.js.map +1 -0
- package/dist/credentials.d.ts +7 -53
- package/dist/credentials.js +84 -247
- package/dist/credentials.js.map +1 -1
- package/dist/db.js +34 -2
- package/dist/db.js.map +1 -1
- package/dist/define.d.ts +9 -2
- package/dist/define.js.map +1 -1
- package/dist/definitions.d.ts +20 -0
- package/dist/definitions.js +16 -0
- package/dist/definitions.js.map +1 -0
- package/dist/deployment-key.d.ts +2 -0
- package/dist/deployment-key.js +22 -0
- package/dist/deployment-key.js.map +1 -0
- package/dist/describe.d.ts +15 -175
- package/dist/describe.js +122 -35
- package/dist/describe.js.map +1 -1
- package/dist/description.d.ts +532 -0
- package/dist/description.js +381 -0
- package/dist/description.js.map +1 -0
- package/dist/direct.js +7 -0
- package/dist/direct.js.map +1 -1
- package/dist/drift.d.ts +1 -1
- package/dist/drift.js +1 -1
- package/dist/drift.js.map +1 -1
- package/dist/env.d.ts +2 -0
- package/dist/env.js +2 -2
- package/dist/env.js.map +1 -1
- package/dist/fake.d.ts +13 -1
- package/dist/fake.js +70 -0
- package/dist/fake.js.map +1 -1
- package/dist/index.d.ts +11 -3
- package/dist/index.js +10 -2
- package/dist/index.js.map +1 -1
- package/dist/ledger.d.ts +2 -0
- package/dist/ledger.js +1 -1
- package/dist/ledger.js.map +1 -1
- package/dist/lint.d.ts +0 -9
- package/dist/lint.js +2 -17
- package/dist/lint.js.map +1 -1
- package/dist/load.d.ts +85 -0
- package/dist/load.js +391 -0
- package/dist/load.js.map +1 -0
- package/dist/lockfile.d.ts +2 -0
- package/dist/lockfile.js +8 -3
- package/dist/lockfile.js.map +1 -1
- package/dist/op.d.ts +22 -5
- package/dist/op.js +20 -2
- package/dist/op.js.map +1 -1
- package/dist/outline.d.ts +5 -0
- package/dist/outline.js +46 -5
- package/dist/outline.js.map +1 -1
- package/dist/paths.d.ts +18 -2
- package/dist/paths.js +19 -7
- package/dist/paths.js.map +1 -1
- package/dist/plugin.d.ts +0 -11
- package/dist/plugin.js +3 -18
- package/dist/plugin.js.map +1 -1
- package/dist/policy.d.ts +2 -0
- package/dist/policy.js.map +1 -1
- package/dist/resource.d.ts +10 -4
- package/dist/resource.js +11 -4
- package/dist/resource.js.map +1 -1
- package/dist/resources.d.ts +33 -76
- package/dist/resources.js +219 -227
- package/dist/resources.js.map +1 -1
- package/dist/run.d.ts +4 -0
- package/dist/run.js.map +1 -1
- package/dist/sandbox.d.ts +5 -2
- package/dist/sandbox.js +56 -7
- package/dist/sandbox.js.map +1 -1
- package/dist/scenario.d.ts +5 -0
- package/dist/scenario.js +3 -2
- package/dist/scenario.js.map +1 -1
- package/dist/scenarios.js +7 -1
- package/dist/scenarios.js.map +1 -1
- package/dist/schema.js +8 -0
- package/dist/schema.js.map +1 -1
- package/dist/secret-store-check.d.ts +11 -0
- package/dist/secret-store-check.js +76 -0
- package/dist/secret-store-check.js.map +1 -0
- package/dist/secret-store.d.ts +106 -0
- package/dist/secret-store.js +228 -0
- package/dist/secret-store.js.map +1 -0
- package/dist/secret.d.ts +2 -0
- package/dist/secret.js +32 -0
- package/dist/secret.js.map +1 -1
- package/dist/shared.d.ts +21 -11
- package/dist/shared.js +41 -7
- package/dist/shared.js.map +1 -1
- package/dist/sites.d.ts +4 -93
- package/dist/sites.js +76 -84
- package/dist/sites.js.map +1 -1
- package/dist/steps.d.ts +3 -0
- package/dist/steps.js.map +1 -1
- package/dist/version.d.ts +2 -1
- package/dist/version.js +2 -1
- package/dist/version.js.map +1 -1
- package/dist/wakeup-driver.js +15 -6
- package/dist/wakeup-driver.js.map +1 -1
- package/dist/worker.js +12 -8
- package/dist/worker.js.map +1 -1
- package/dist/writer.d.ts +53 -14
- package/dist/writer.js +113 -43
- package/dist/writer.js.map +1 -1
- package/oxlint.json +1 -5
- package/package.json +6 -1
- package/src/apply-steps.ts +5 -2
- package/src/apply.ts +54 -21
- package/src/approvals.ts +7 -1
- package/src/apps.ts +71 -86
- package/src/ast.ts +6 -6
- package/src/authentication.ts +191 -0
- package/src/call-workflow.ts +5 -4
- package/src/call.ts +52 -9
- package/src/cedar.ts +14 -1
- package/src/change-steps.ts +7 -4
- package/src/change.ts +10 -0
- package/src/client.ts +346 -45
- package/src/collection.ts +10 -2
- package/src/config.ts +107 -19
- package/src/connection.ts +912 -0
- package/src/credentials.ts +94 -281
- package/src/db.ts +34 -2
- package/src/define.ts +7 -2
- package/src/definitions.ts +31 -0
- package/src/deployment-key.ts +23 -0
- package/src/describe.ts +150 -227
- package/src/description.ts +426 -0
- package/src/direct.ts +7 -0
- package/src/drift.ts +10 -2
- package/src/env.ts +4 -2
- package/src/fake.ts +71 -0
- package/src/index.ts +53 -1
- package/src/ledger.ts +3 -1
- package/src/lint.ts +2 -17
- package/src/load.ts +466 -0
- package/src/lockfile.ts +10 -3
- package/src/op.ts +41 -7
- package/src/outline.ts +53 -7
- package/src/paths.ts +21 -6
- package/src/plugin.ts +3 -24
- package/src/policy.ts +2 -0
- package/src/resource.ts +24 -6
- package/src/resources.ts +246 -301
- package/src/run.ts +4 -0
- package/src/sandbox.ts +61 -7
- package/src/scenario.ts +24 -6
- package/src/scenarios.ts +7 -1
- package/src/schema.ts +10 -0
- package/src/secret-store-check.ts +93 -0
- package/src/secret-store.ts +314 -0
- package/src/secret.ts +29 -0
- package/src/shared.ts +44 -10
- package/src/sites.ts +85 -179
- package/src/steps.ts +3 -2
- package/src/version.ts +2 -1
- package/src/wakeup-driver.ts +22 -6
- package/src/worker.ts +12 -8
- package/src/writer.ts +145 -56
- package/dist/datafile.d.ts +0 -97
- package/dist/datafile.js +0 -479
- package/dist/datafile.js.map +0 -1
- package/src/datafile.ts +0 -601
package/dist/client.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { connectionDriver, connectionTargetSchema, } from "./connection.js";
|
|
2
|
+
import { encodeCredentials, decodeCredentials, verifyAuthentication, AuthenticationError } from "./authentication.js";
|
|
3
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
4
|
import { isDeepStrictEqual } from "node:util";
|
|
3
5
|
import { Error as DBOSErrors } from "@dbos-inc/dbos-sdk";
|
|
4
6
|
import { z } from "zod";
|
|
@@ -7,7 +9,7 @@ import { beforeOf, decideCall, decideStart, recorded, startHoldOf } from "./call
|
|
|
7
9
|
import { chatStore } from "./chats.js";
|
|
8
10
|
import { collectionsDriver } from "./collection.js";
|
|
9
11
|
import { resolveConfig } from "./config.js";
|
|
10
|
-
import {
|
|
12
|
+
import { configuredSecrets } from "./credentials.js";
|
|
11
13
|
import { dbosClient, lazyPool } from "./db.js";
|
|
12
14
|
import { credentialsOf } from "./env.js";
|
|
13
15
|
import { Principal } from "./define.js";
|
|
@@ -20,6 +22,7 @@ import { insertRecord, ownedLedger } from "./ledger.js";
|
|
|
20
22
|
import { warn } from "./log.js";
|
|
21
23
|
import { allowAll } from "./policy.js";
|
|
22
24
|
import { admit, readPeople } from "./resources.js";
|
|
25
|
+
import { SecretStoreError } from "./secret-store.js";
|
|
23
26
|
import { BUILT_IN_WORKFLOWS, CALL_WORKFLOW, CHANGE_EVENT, CHANGE_MERGE, CHANGE_TOPIC, CHANGE_WORKFLOW, changeIdOf, DATA_VENDOR, ENDED_STATUSES, errorMessage, HOST_CHECK_AGENT, isEnded, isVisitor, refusalOf, SCHEDULE_AGENT, WORKFLOW_ACTION, } from "./shared.js";
|
|
24
27
|
import { draftOf } from "./source.js";
|
|
25
28
|
/** Whether two references name one repository: a host's names are compared without case. */
|
|
@@ -88,7 +91,7 @@ export class SanomaClient {
|
|
|
88
91
|
this.options = options;
|
|
89
92
|
this.audit = ownedLedger(config);
|
|
90
93
|
this.pool = lazyPool(config.databaseUrl, "client");
|
|
91
|
-
this.storedSecrets =
|
|
94
|
+
this.storedSecrets = configuredSecrets(config.secrets, () => this.db());
|
|
92
95
|
this.chats = chatStore(() => this.db());
|
|
93
96
|
}
|
|
94
97
|
/**
|
|
@@ -99,7 +102,15 @@ export class SanomaClient {
|
|
|
99
102
|
static async connect(config, options = {}) {
|
|
100
103
|
const resolved = resolveConfig(config);
|
|
101
104
|
const dbos = await dbosClient(resolved.databaseUrl, resolved.appName);
|
|
102
|
-
|
|
105
|
+
const client = new SanomaClient(dbos, resolved, resolved.driversByVendor, options);
|
|
106
|
+
try {
|
|
107
|
+
await client.storedSecrets.validate();
|
|
108
|
+
return client;
|
|
109
|
+
}
|
|
110
|
+
catch (error) {
|
|
111
|
+
await client.close();
|
|
112
|
+
throw error;
|
|
113
|
+
}
|
|
103
114
|
}
|
|
104
115
|
/**
|
|
105
116
|
* Checks the input against the workflow's schema, then queues a run for the worker. The run
|
|
@@ -211,20 +222,26 @@ export class SanomaClient {
|
|
|
211
222
|
if (!fn)
|
|
212
223
|
throw new SanomaError("invalid_input", `${op.id} has no driver: the connector package must attach one`);
|
|
213
224
|
const parsed = parseOrThrow(op.input, input, `The input to ${op.id} does not match its schema`, { op: op.id });
|
|
225
|
+
const connection = await this.config.connections.select(op.vendor, options.connection, actor.id);
|
|
226
|
+
const selected = connection && {
|
|
227
|
+
$connection: connection.id,
|
|
228
|
+
vendor: connection.vendor,
|
|
229
|
+
revision: connection.revision,
|
|
230
|
+
};
|
|
214
231
|
const runId = `direct:${actor.id}:${id}`;
|
|
215
232
|
const heldId = `call:${actor.id}:${id}`;
|
|
216
|
-
const held = { input: { op: op.id, input }, startedBy: actor };
|
|
233
|
+
const held = { input: { op: op.id, input, ...(selected && { connection: selected }) }, startedBy: actor };
|
|
217
234
|
const logged = recorded(op, parsed);
|
|
218
235
|
const write = op.effect !== "read";
|
|
219
236
|
// Only an id the caller gave can have been used: a write made, or held, with it settles it.
|
|
220
237
|
if (write && options.id !== undefined) {
|
|
221
|
-
const first = (await this.recordedCall(runId, op.id, logged)) ?? (await this.heldCall(op.id, heldId, held));
|
|
238
|
+
const first = (await this.recordedCall(runId, op.id, logged, connection)) ?? (await this.heldCall(op.id, heldId, held));
|
|
222
239
|
if (first)
|
|
223
240
|
return first.output;
|
|
224
241
|
}
|
|
225
242
|
const before = await beforeOf(policy, op, parsed, this.config.collections, (get, key) => drivers.get(get)?.({ key }, { idempotencyKey: `${runId}:before`, runId, opId: get, attempt: 1, secrets: this.storedSecrets, by: actor.id }));
|
|
226
243
|
const run = { id: runId, workflow: CALL_WORKFLOW, approvals: [] };
|
|
227
|
-
const decision = decideCall(policy, op, parsed, actor, run, before);
|
|
244
|
+
const decision = decideCall(policy, op, parsed, actor, run, { ...before, ...(connection && { connection }) });
|
|
228
245
|
if (decision.kind === "deny")
|
|
229
246
|
throw new PolicyDeniedError(op.id, decision.reason);
|
|
230
247
|
if (decision.kind === "approve") {
|
|
@@ -239,7 +256,10 @@ export class SanomaClient {
|
|
|
239
256
|
attempt: 1,
|
|
240
257
|
secrets: this.storedSecrets,
|
|
241
258
|
by: actor.id,
|
|
259
|
+
principal: actor,
|
|
260
|
+
...(connection && { connection }),
|
|
242
261
|
};
|
|
262
|
+
await this.config.connections.assertCurrent(connection, actor.id);
|
|
243
263
|
if (!write)
|
|
244
264
|
return outputOf(op, () => fn(parsed, context));
|
|
245
265
|
return this.directWrite({ op, fn, parsed, logged, actor, decision, context });
|
|
@@ -255,13 +275,21 @@ export class SanomaClient {
|
|
|
255
275
|
const { op, parsed, logged, actor, decision, context } = call;
|
|
256
276
|
const { runId } = context;
|
|
257
277
|
const { dataStore, collections, ledger } = this.config;
|
|
258
|
-
const
|
|
278
|
+
const connectionMutation = op.id.startsWith("sanoma.connection.");
|
|
279
|
+
const transactionStore = connectionMutation
|
|
280
|
+
? this.config.connectionPool
|
|
281
|
+
: op.vendor === DATA_VENDOR
|
|
282
|
+
? dataStore
|
|
283
|
+
: undefined;
|
|
284
|
+
const conn = !ledger && transactionStore ? await (await transactionStore.db()).connect() : undefined;
|
|
259
285
|
let broken;
|
|
260
286
|
try {
|
|
261
287
|
await conn?.query("BEGIN");
|
|
262
|
-
//
|
|
288
|
+
// The metadata or collection driver shares the ledger transaction; neither has an env guard.
|
|
263
289
|
const fn = conn
|
|
264
|
-
?
|
|
290
|
+
? (connectionMutation
|
|
291
|
+
? connectionDriver(this.config.connections.onDatabase(conn))
|
|
292
|
+
: collectionsDriver([collections.get(op.resource)], { db: async () => conn })).ops[`${op.resource}.${op.name}`]
|
|
265
293
|
: call.fn;
|
|
266
294
|
const at = Date.now();
|
|
267
295
|
const record = (outcome) => ({
|
|
@@ -278,6 +306,7 @@ export class SanomaClient {
|
|
|
278
306
|
effect: op.effect,
|
|
279
307
|
input: logged,
|
|
280
308
|
decision,
|
|
309
|
+
...(context.connection && { connection: context.connection }),
|
|
281
310
|
...outcome,
|
|
282
311
|
durationMs: Date.now() - at,
|
|
283
312
|
});
|
|
@@ -300,7 +329,7 @@ export class SanomaClient {
|
|
|
300
329
|
return output;
|
|
301
330
|
}
|
|
302
331
|
await conn.query("ROLLBACK");
|
|
303
|
-
return (await this.recordedCall(runId, op.id, logged)).output;
|
|
332
|
+
return (await this.recordedCall(runId, op.id, logged, context.connection)).output;
|
|
304
333
|
}
|
|
305
334
|
catch (err) {
|
|
306
335
|
await conn?.query("ROLLBACK").catch((e) => (broken = e));
|
|
@@ -314,11 +343,15 @@ export class SanomaClient {
|
|
|
314
343
|
* What the direct write recorded as `runId` returned, when one did: refused (`invalid_input`)
|
|
315
344
|
* when it was of another operation or input, and its failure again (`driver_failed`) when it failed.
|
|
316
345
|
*/
|
|
317
|
-
async recordedCall(runId, opId, input) {
|
|
346
|
+
async recordedCall(runId, opId, input, connection) {
|
|
318
347
|
const first = (await this.audit.store.read(runId)).find((r) => r.type === "op.called");
|
|
319
348
|
if (!first)
|
|
320
349
|
return undefined;
|
|
321
|
-
const differs = [
|
|
350
|
+
const differs = [
|
|
351
|
+
...(first.op === opId ? [] : ["op"]),
|
|
352
|
+
...(sameJson(first.input, input) ? [] : ["input"]),
|
|
353
|
+
...(sameJson(first.connection ?? null, connection ?? null) ? [] : ["connection"]),
|
|
354
|
+
];
|
|
322
355
|
if (differs.length) {
|
|
323
356
|
throw new SanomaError("invalid_input", `Call ${runId} was made already with a different ${differs.join(" and ")}; use another id`, { runId, differs });
|
|
324
357
|
}
|
|
@@ -794,7 +827,8 @@ export class SanomaClient {
|
|
|
794
827
|
issues: [{ path: ["value"], message: problem, code: "custom" }],
|
|
795
828
|
});
|
|
796
829
|
}
|
|
797
|
-
|
|
830
|
+
const current = await this.storedSecrets.read(name);
|
|
831
|
+
await this.storedSecrets.write(name, value, { expectedVersion: current?.version ?? null, by: setBy });
|
|
798
832
|
}
|
|
799
833
|
/**
|
|
800
834
|
* Deletes the stored value of a declared variable, and tells the workers, which unset it unless
|
|
@@ -804,7 +838,9 @@ export class SanomaClient {
|
|
|
804
838
|
async clearCredential(name, options) {
|
|
805
839
|
await this.admittedBy(options);
|
|
806
840
|
this.declared(name, () => undefined);
|
|
807
|
-
|
|
841
|
+
const current = await this.storedSecrets.read(name);
|
|
842
|
+
if (current)
|
|
843
|
+
await this.storedSecrets.delete(name, { expectedVersion: current.version, by: options.by });
|
|
808
844
|
}
|
|
809
845
|
/**
|
|
810
846
|
* Stores a secret as `by`, and tells the workers: a value under any variable's name, declared by
|
|
@@ -822,50 +858,239 @@ export class SanomaClient {
|
|
|
822
858
|
const path = VARIABLE.test(name) ? "value" : "name";
|
|
823
859
|
throw new SanomaError("invalid_input", problem, { issues: [{ path: [path], message: problem, code: "custom" }] });
|
|
824
860
|
}
|
|
825
|
-
|
|
861
|
+
const current = await this.storedSecrets.read(name);
|
|
862
|
+
await this.storedSecrets.write(name, value, { expectedVersion: current?.version ?? null, by: setBy });
|
|
863
|
+
}
|
|
864
|
+
/** Removes a logical secret from the selected store; external bindings remain read-only. */
|
|
865
|
+
async clearSecret(name, options) {
|
|
866
|
+
const by = await this.admittedBy(options);
|
|
867
|
+
if (!VARIABLE.test(name)) {
|
|
868
|
+
const message = "That is not a variable name";
|
|
869
|
+
throw new SanomaError("invalid_input", message, {
|
|
870
|
+
issues: [{ path: ["name"], message, code: "custom" }],
|
|
871
|
+
});
|
|
872
|
+
}
|
|
873
|
+
const current = await this.storedSecrets.read(name);
|
|
874
|
+
if (current)
|
|
875
|
+
await this.storedSecrets.delete(name, { expectedVersion: current.version, by });
|
|
876
|
+
}
|
|
877
|
+
connectionAuthorization() {
|
|
878
|
+
return createHash("sha256")
|
|
879
|
+
.update(JSON.stringify({
|
|
880
|
+
version: this.config.version,
|
|
881
|
+
policies: this.config.policies,
|
|
882
|
+
problems: this.config.policyProblems,
|
|
883
|
+
}))
|
|
884
|
+
.digest("hex");
|
|
885
|
+
}
|
|
886
|
+
/** Safe account cards, scoped to the admitted principal. No provider or secret reads. */
|
|
887
|
+
async connections(options) {
|
|
888
|
+
const actor = await this.admitted(parseOrThrow(PrincipalOrId, options.actor, "Name the principal"));
|
|
889
|
+
return this.config.connections.cards(actor.id);
|
|
890
|
+
}
|
|
891
|
+
/** Creation choices are scoped by code to the admitted person's current groups. */
|
|
892
|
+
async connectionRules(options) {
|
|
893
|
+
const actor = await this.admitted(parseOrThrow(PrincipalOrId, options.actor, "Name the principal"));
|
|
894
|
+
return [...this.config.connections.catalog.rules]
|
|
895
|
+
.filter(([, rule]) => rule.createBy.some((by) => (typeof by === "string" ? by === actor.id : actor.groups?.includes(by.group))))
|
|
896
|
+
.map(([id, rule]) => ({ id, rule }));
|
|
897
|
+
}
|
|
898
|
+
/** Readiness only, without reading or listing any credential. The adapter's bootstrap
|
|
899
|
+
* authority is checked by validate; provider details never escape this boundary. */
|
|
900
|
+
async secretStoreStatus(options) {
|
|
901
|
+
await this.admitted(parseOrThrow(PrincipalOrId, options.actor, "Name the principal"));
|
|
902
|
+
const { store, namespace } = this.storedSecrets;
|
|
903
|
+
let problem;
|
|
904
|
+
try {
|
|
905
|
+
await this.storedSecrets.validate();
|
|
906
|
+
}
|
|
907
|
+
catch (error) {
|
|
908
|
+
problem = error.code;
|
|
909
|
+
}
|
|
910
|
+
return {
|
|
911
|
+
store: store.id,
|
|
912
|
+
namespace,
|
|
913
|
+
capabilities: { ...store.capabilities },
|
|
914
|
+
ready: !problem,
|
|
915
|
+
...(problem && { problem }),
|
|
916
|
+
};
|
|
917
|
+
}
|
|
918
|
+
/** Secure intake endpoint, never a workflow operation. Only its metadata-only prepare gate
|
|
919
|
+
* enters policy/approval history; callers keep the value in a password form while reviewing. */
|
|
920
|
+
async previewConnection(target, credentials, options) {
|
|
921
|
+
const actor = await this.admitted(parseOrThrow(PrincipalOrId, options.actor, "Name the principal"));
|
|
922
|
+
const id = parseOrThrow(z.string().min(1).max(200), options.id, "Name the setup request");
|
|
923
|
+
target = parseOrThrow(connectionTargetSchema, target, "Select a declared connection or creation rule");
|
|
924
|
+
const setup = await this.config.connections.setup(target, actor, id);
|
|
925
|
+
const authorized = (await this.call("sanoma.connection.prepare", {
|
|
926
|
+
target,
|
|
927
|
+
requestId: id,
|
|
928
|
+
definition: setup.definition,
|
|
929
|
+
authorization: this.connectionAuthorization(),
|
|
930
|
+
}, { actor, id: `connection-prepare:${id}` }));
|
|
931
|
+
if (!isDeepStrictEqual(authorized, setup))
|
|
932
|
+
throw new SanomaError("invalid_input", "The connection changed; start a new review");
|
|
933
|
+
const method = this.config.connections.catalog.methods.get(setup.vendor)[setup.method];
|
|
934
|
+
const external = method.kind !== "workload-identity" &&
|
|
935
|
+
(setup.location?.management === "external" || !this.storedSecrets.store.capabilities.writable);
|
|
936
|
+
if (!external &&
|
|
937
|
+
method.kind !== "workload-identity" &&
|
|
938
|
+
this.storedSecrets.store.capabilities.conditionalWrites === "none")
|
|
939
|
+
throw new SecretStoreError("unsupported");
|
|
940
|
+
if (credentials !== undefined && (external || method.kind === "workload-identity"))
|
|
941
|
+
throw new SecretStoreError("read_only");
|
|
942
|
+
const existing = setup.credential ? await this.storedSecrets.read(setup.credential) : undefined;
|
|
943
|
+
if (external && !existing)
|
|
944
|
+
throw new SecretStoreError("unavailable");
|
|
945
|
+
const encoded = method.kind === "workload-identity"
|
|
946
|
+
? undefined
|
|
947
|
+
: external
|
|
948
|
+
? existing.value
|
|
949
|
+
: encodeCredentials(method, credentials);
|
|
950
|
+
const identity = await verifyAuthentication(method, encoded === undefined ? undefined : decodeCredentials(method, encoded));
|
|
951
|
+
if (setup.expectedAccount && setup.expectedAccount !== identity.account)
|
|
952
|
+
throw new AuthenticationError("wrong_account");
|
|
953
|
+
if (setup.requiredScopes.some((scope) => !identity.scopes.includes(scope)))
|
|
954
|
+
throw new AuthenticationError("scope_changed");
|
|
955
|
+
return {
|
|
956
|
+
...setup,
|
|
957
|
+
requestId: id,
|
|
958
|
+
authorization: this.connectionAuthorization(),
|
|
959
|
+
account: identity.account,
|
|
960
|
+
scopes: [...identity.scopes],
|
|
961
|
+
credentialVersion: existing?.version ?? null,
|
|
962
|
+
...(identity.expiresAt !== undefined && { expiresAt: identity.expiresAt }),
|
|
963
|
+
};
|
|
964
|
+
}
|
|
965
|
+
/** Reverify the reviewed authority, reserve its metadata under policy, then write the bundle
|
|
966
|
+
* outside durable execution. Activation has its own policy gate and verifies what was saved. */
|
|
967
|
+
async connectConnection(review, credentials, options) {
|
|
968
|
+
const actor = await this.admitted(parseOrThrow(PrincipalOrId, options.actor, "Name the principal"));
|
|
969
|
+
if (review.authorization !== this.connectionAuthorization())
|
|
970
|
+
throw new SanomaError("invalid_input", "The policy changed; start a new connection review");
|
|
971
|
+
const setup = await this.config.connections.setup(review.target, actor, review.requestId);
|
|
972
|
+
for (const field of ["id", "vendor", "method", "definition", "credential", "location"]) {
|
|
973
|
+
if (!isDeepStrictEqual(setup[field], review[field]))
|
|
974
|
+
throw new SanomaError("invalid_input", "The destination changed; review it again before saving");
|
|
975
|
+
}
|
|
976
|
+
// Reuse only the metadata gate, never a cached credential or verification result.
|
|
977
|
+
await this.call("sanoma.connection.prepare", {
|
|
978
|
+
target: review.target,
|
|
979
|
+
requestId: review.requestId,
|
|
980
|
+
definition: setup.definition,
|
|
981
|
+
authorization: review.authorization,
|
|
982
|
+
}, { actor, id: `connection-prepare:${review.requestId}` });
|
|
983
|
+
const method = this.config.connections.catalog.methods.get(setup.vendor)[setup.method];
|
|
984
|
+
const external = method.kind !== "workload-identity" &&
|
|
985
|
+
(setup.location?.management === "external" || !this.storedSecrets.store.capabilities.writable);
|
|
986
|
+
if (!external &&
|
|
987
|
+
method.kind !== "workload-identity" &&
|
|
988
|
+
this.storedSecrets.store.capabilities.conditionalWrites === "none")
|
|
989
|
+
throw new SecretStoreError("unsupported");
|
|
990
|
+
if (credentials !== undefined && (external || method.kind === "workload-identity"))
|
|
991
|
+
throw new SecretStoreError("read_only");
|
|
992
|
+
const existing = setup.credential ? await this.storedSecrets.read(setup.credential) : undefined;
|
|
993
|
+
if (external && (!existing || existing.version !== review.credentialVersion))
|
|
994
|
+
throw new SecretStoreError("conflict");
|
|
995
|
+
if (setup.expectedRevision === review.expectedRevision && (existing?.version ?? null) !== review.credentialVersion)
|
|
996
|
+
throw new SecretStoreError("conflict");
|
|
997
|
+
const encoded = method.kind === "workload-identity"
|
|
998
|
+
? undefined
|
|
999
|
+
: external
|
|
1000
|
+
? existing.value
|
|
1001
|
+
: encodeCredentials(method, credentials);
|
|
1002
|
+
const identity = await verifyAuthentication(method, encoded === undefined ? undefined : decodeCredentials(method, encoded));
|
|
1003
|
+
if (identity.account !== review.account)
|
|
1004
|
+
throw new AuthenticationError("wrong_account");
|
|
1005
|
+
if (JSON.stringify(identity.scopes) !== JSON.stringify([...new Set(review.scopes)].toSorted()))
|
|
1006
|
+
throw new AuthenticationError("scope_changed");
|
|
1007
|
+
const selected = (await this.call("sanoma.connection.configure", {
|
|
1008
|
+
target: review.target,
|
|
1009
|
+
requestId: review.requestId,
|
|
1010
|
+
expectedRevision: review.expectedRevision,
|
|
1011
|
+
definition: review.definition,
|
|
1012
|
+
account: identity.account,
|
|
1013
|
+
scopes: [...identity.scopes],
|
|
1014
|
+
}, { actor, id: `connection-configure:${review.requestId}` }));
|
|
1015
|
+
const ref = { vendor: selected.vendor, $connection: selected.id };
|
|
1016
|
+
if ((await this.config.connections.setupState(selected, actor)) === "pending" &&
|
|
1017
|
+
!external &&
|
|
1018
|
+
encoded !== undefined) {
|
|
1019
|
+
let written = false;
|
|
1020
|
+
try {
|
|
1021
|
+
const receipt = await this.config.connections.storedVersion(selected.id, selected.revision);
|
|
1022
|
+
if (receipt) {
|
|
1023
|
+
if (existing?.version !== receipt || existing.value !== encoded)
|
|
1024
|
+
throw new SecretStoreError("conflict");
|
|
1025
|
+
}
|
|
1026
|
+
else {
|
|
1027
|
+
const stored = await this.storedSecrets.write(setup.credential, encoded, {
|
|
1028
|
+
expectedVersion: review.credentialVersion,
|
|
1029
|
+
by: actor.id,
|
|
1030
|
+
});
|
|
1031
|
+
written = true;
|
|
1032
|
+
await this.config.connections.recordStoredVersion(selected.id, selected.revision, stored.version);
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
catch (error) {
|
|
1036
|
+
if (written || error?.code === "unavailable")
|
|
1037
|
+
await this.config.connections.uncertain(ref, selected.revision, actor.id);
|
|
1038
|
+
throw error;
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
const connected = (await this.call("sanoma.connection.activate", {
|
|
1042
|
+
connection: ref,
|
|
1043
|
+
expectedRevision: selected.revision,
|
|
1044
|
+
}, { actor, id: `connection-activate:${review.requestId}` }));
|
|
1045
|
+
await this.config.connections.assertCurrent(connected, actor.id);
|
|
1046
|
+
return connected;
|
|
826
1047
|
}
|
|
827
1048
|
/**
|
|
828
|
-
*
|
|
829
|
-
*
|
|
830
|
-
* is left out. For server code: a page is told whether one is set, never its value.
|
|
1049
|
+
* Values resolved through the selected store and explicit bindings, with version and change
|
|
1050
|
+
* metadata. Missing names are left out. Server code only; pages receive status, never values.
|
|
831
1051
|
*/
|
|
832
1052
|
async secrets(names) {
|
|
833
|
-
const rows = await storedCredentials(await this.db(), names);
|
|
834
1053
|
const found = new Map();
|
|
835
1054
|
for (const name of names) {
|
|
836
|
-
const
|
|
837
|
-
const
|
|
838
|
-
if (
|
|
839
|
-
found.set(name, {
|
|
840
|
-
|
|
841
|
-
|
|
1055
|
+
const row = await this.storedSecrets.read(name);
|
|
1056
|
+
const location = this.storedSecrets.location(name);
|
|
1057
|
+
if (row)
|
|
1058
|
+
found.set(name, {
|
|
1059
|
+
value: row.value,
|
|
1060
|
+
source: location.store === "environment" ? "environment" : "stored",
|
|
1061
|
+
version: row.version,
|
|
1062
|
+
location,
|
|
1063
|
+
...(row.updatedBy && { setBy: row.updatedBy }),
|
|
1064
|
+
...(row.updatedAt && { setAt: row.updatedAt }),
|
|
1065
|
+
});
|
|
842
1066
|
}
|
|
843
1067
|
return found;
|
|
844
1068
|
}
|
|
845
|
-
/**
|
|
846
|
-
* Each vendor's declared variables and their statuses now: from this process's environment,
|
|
847
|
-
* which wins, else from the stored credentials, with `source` saying which (a value equal to
|
|
848
|
-
* the stored one, as a worker in this process loads it, is `stored`), and who set a stored one
|
|
849
|
-
* and when. Never a value.
|
|
850
|
-
*/
|
|
1069
|
+
/** Non-secret statuses for the selected store and declared bindings. */
|
|
851
1070
|
async credentials() {
|
|
852
1071
|
const names = [...credentialsOf(this.drivers, () => undefined).values()].flat().map((c) => c.name);
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
1072
|
+
const rows = await this.secrets(names);
|
|
1073
|
+
const statuses = credentialsOf(this.drivers, (name) => rows.get(name)?.value);
|
|
1074
|
+
return new Map([...statuses].map(([vendor, list]) => [
|
|
1075
|
+
vendor,
|
|
1076
|
+
list.map((c) => {
|
|
1077
|
+
const row = rows.get(c.name);
|
|
1078
|
+
return {
|
|
1079
|
+
...c,
|
|
1080
|
+
location: this.storedSecrets.location(c.name),
|
|
1081
|
+
...(row && { source: row.source, setBy: row.setBy, setAt: row.setAt }),
|
|
1082
|
+
};
|
|
1083
|
+
}),
|
|
1084
|
+
]));
|
|
866
1085
|
}
|
|
867
1086
|
async close() {
|
|
868
|
-
await Promise.all([
|
|
1087
|
+
await Promise.all([
|
|
1088
|
+
this.dbos.destroy(),
|
|
1089
|
+
this.pool.close(),
|
|
1090
|
+
this.audit.close(),
|
|
1091
|
+
this.config.dataStore?.close(),
|
|
1092
|
+
this.config.connectionPool.close(),
|
|
1093
|
+
]);
|
|
869
1094
|
}
|
|
870
1095
|
/**
|
|
871
1096
|
* The name's statuses with `lookup`'s value, one per vendor whose drivers declare it, or
|
|
@@ -988,7 +1213,7 @@ const asJson = (v) => (v === undefined ? undefined : JSON.parse(JSON.stringify(v
|
|
|
988
1213
|
/** True when the two are the same JSON, whatever the order of their keys. */
|
|
989
1214
|
const sameJson = (a, b) => isDeepStrictEqual(asJson(a), asJson(b));
|
|
990
1215
|
/** What an environment variable's name looks like. */
|
|
991
|
-
const VARIABLE = /^[A-
|
|
1216
|
+
const VARIABLE = /^[A-Z][A-Z0-9_]*$/;
|
|
992
1217
|
const alreadyDecided = (runId, a) => new SanomaError("already_decided", a.status === "withdrawn"
|
|
993
1218
|
? `${a.id} on run ${runId} was already withdrawn: ${a.withdrawnReason}`
|
|
994
1219
|
: `${a.id} on run ${runId} was already ${a.status} by ${a.decidedBy}`, {
|