@sanoma-ai/workflows 0.9.1 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +167 -50
- 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 +7 -24
- package/dist/apps.js +51 -67
- 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 +24 -0
- package/dist/authentication.js +68 -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 +34 -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 +10 -6
- package/dist/client.js +96 -43
- 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 +9 -0
- package/dist/config.js +45 -9
- package/dist/config.js.map +1 -1
- package/dist/connection.d.ts +168 -0
- package/dist/connection.js +327 -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 +21 -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 +9 -177
- package/dist/describe.js +71 -34
- 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 +3 -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/index.d.ts +10 -2
- 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 +1 -16
- package/dist/lint.js.map +1 -1
- package/dist/load.d.ts +74 -0
- package/dist/load.js +357 -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 +10 -1
- package/dist/op.js +1 -1
- package/dist/op.js.map +1 -1
- package/dist/outline.js +13 -4
- package/dist/outline.js.map +1 -1
- package/dist/paths.d.ts +18 -0
- package/dist/paths.js +21 -3
- 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 +2 -0
- package/dist/run.js.map +1 -1
- package/dist/sandbox.d.ts +1 -1
- package/dist/sandbox.js +5 -2
- package/dist/sandbox.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/shared.d.ts +17 -11
- package/dist/shared.js +34 -7
- package/dist/shared.js.map +1 -1
- package/dist/sites.d.ts +4 -93
- package/dist/sites.js +61 -84
- package/dist/sites.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 +0 -5
- package/package.json +5 -1
- package/src/apply-steps.ts +5 -2
- package/src/apply.ts +54 -21
- package/src/approvals.ts +7 -1
- package/src/apps.ts +54 -85
- package/src/ast.ts +6 -6
- package/src/authentication.ts +74 -0
- package/src/call-workflow.ts +5 -4
- package/src/call.ts +53 -9
- package/src/cedar.ts +14 -1
- package/src/change-steps.ts +7 -4
- package/src/change.ts +10 -0
- package/src/client.ts +108 -41
- package/src/collection.ts +10 -2
- package/src/config.ts +62 -8
- package/src/connection.ts +446 -0
- package/src/credentials.ts +94 -281
- package/src/db.ts +21 -2
- package/src/define.ts +7 -2
- package/src/definitions.ts +31 -0
- package/src/deployment-key.ts +23 -0
- package/src/describe.ts +102 -230
- package/src/description.ts +426 -0
- package/src/direct.ts +3 -0
- package/src/drift.ts +10 -2
- package/src/env.ts +4 -2
- package/src/index.ts +42 -1
- package/src/ledger.ts +3 -1
- package/src/lint.ts +1 -16
- package/src/load.ts +437 -0
- package/src/lockfile.ts +10 -3
- package/src/op.ts +10 -1
- package/src/outline.ts +18 -5
- package/src/paths.ts +24 -2
- 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 +2 -0
- package/src/sandbox.ts +6 -2
- 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/shared.ts +35 -10
- package/src/sites.ts +70 -180
- 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/src/change-steps.ts
CHANGED
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
followBase,
|
|
16
16
|
repositoryOf,
|
|
17
17
|
isRules,
|
|
18
|
+
mayDeclarePeople,
|
|
18
19
|
sourceOfRun,
|
|
19
20
|
touchesOf,
|
|
20
21
|
} from "./change.ts";
|
|
@@ -23,7 +24,7 @@ import type { DriftReport } from "./drift.ts";
|
|
|
23
24
|
import { isRunControlError } from "./errors.ts";
|
|
24
25
|
import { warn } from "./log.ts";
|
|
25
26
|
import { inside } from "./paths.ts";
|
|
26
|
-
import {
|
|
27
|
+
import { peopleSources } from "./resources.ts";
|
|
27
28
|
import { currentRun, type Run } from "./run.ts";
|
|
28
29
|
import {
|
|
29
30
|
AGENTS,
|
|
@@ -48,8 +49,8 @@ const runNow = (): Run => {
|
|
|
48
49
|
return run;
|
|
49
50
|
};
|
|
50
51
|
|
|
51
|
-
/** True when the file at `path` is a people file (`
|
|
52
|
-
const fileDeclaresPeople = async (path: string) =>
|
|
52
|
+
/** True when the file at `path` is a people file (`mayDeclarePeople`), one `readPeople` would load; false when there is none. */
|
|
53
|
+
const fileDeclaresPeople = async (path: string) => mayDeclarePeople(await readFile(path, "utf8").catch(() => ""));
|
|
53
54
|
|
|
54
55
|
/** Which events win when several wait: one that ends the change over one that plans it again. */
|
|
55
56
|
const WEIGHT: Record<ChangeEvent["kind"], number> = { commits: 0, close: 1, closed: 2, merged: 3 };
|
|
@@ -188,12 +189,14 @@ export function changeSteps({ remotes, hosts }: ChangeStepsOptions): ChangeSteps
|
|
|
188
189
|
.filter((file) => file.startsWith("resources/"))
|
|
189
190
|
.flatMap((file) => [join(source.root, file), join(draft.root, file)].map(fileDeclaresPeople)),
|
|
190
191
|
);
|
|
192
|
+
const sources = peopleSources(source.root);
|
|
191
193
|
return {
|
|
192
194
|
touches,
|
|
193
195
|
strandedRuns: stranded.length,
|
|
194
196
|
stranded,
|
|
195
197
|
checksOk: check.ok,
|
|
196
|
-
|
|
198
|
+
// Or a file main's people are read from: a helper a people file imports.
|
|
199
|
+
rules: people.includes(true) || touches.some(isRules) || files.some((file) => sources.has(file)),
|
|
197
200
|
};
|
|
198
201
|
},
|
|
199
202
|
{ name: "change:plan" },
|
package/src/change.ts
CHANGED
|
@@ -320,6 +320,16 @@ export const ADMIN = "admin";
|
|
|
320
320
|
*/
|
|
321
321
|
export const isRules = (path: string) =>
|
|
322
322
|
path === "policies/" || path === "resources/people.ts" || /^apps\/[^/]+\/policies\/$/.test(path);
|
|
323
|
+
/**
|
|
324
|
+
* Whether a data file's text imports the runtime's constructors, `sanoma` (by any local name, or
|
|
325
|
+
* the whole entry, `* as`, from which it can reach them): a file that may declare a person, agent
|
|
326
|
+
* or group, which `readPeople` loads and a change to which is a change to the rules. Read from the
|
|
327
|
+
* text, so a draft's file is told without running it.
|
|
328
|
+
*/
|
|
329
|
+
export const mayDeclarePeople = (text: string): boolean =>
|
|
330
|
+
/(?:\{[^}]*\bsanoma\b[^}]*\}|\*(?:\s*as\s+[\w$]+)?)\s*from\s*["'](?:sanoma|@sanoma-ai\/workflows)\/resources["']/.test(
|
|
331
|
+
text,
|
|
332
|
+
);
|
|
323
333
|
/** The runtime rule's id, as a hold's `reasons` name it. */
|
|
324
334
|
const RULES_WAIT_FOR_ADMIN = "sanoma:rules-wait-for-admin";
|
|
325
335
|
|
package/src/client.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { connectionDriver, type ConnectionRef, type ConnectionSnapshot } from "./connection.ts";
|
|
1
2
|
import { randomUUID } from "node:crypto";
|
|
2
3
|
import { isDeepStrictEqual } from "node:util";
|
|
3
4
|
import { type DBOSClient, Error as DBOSErrors, type WorkflowStatusString } from "@dbos-inc/dbos-sdk";
|
|
@@ -16,7 +17,7 @@ import { beforeOf, decideCall, decideStart, recorded, startHoldOf } from "./call
|
|
|
16
17
|
import { type Chats, chatStore } from "./chats.ts";
|
|
17
18
|
import { collectionsDriver } from "./collection.ts";
|
|
18
19
|
import { type ResolvedConfig, resolveConfig, type SanomaConfig } from "./config.ts";
|
|
19
|
-
import {
|
|
20
|
+
import { configuredSecrets } from "./credentials.ts";
|
|
20
21
|
import { dbosClient, lazyPool, type LazyPool } from "./db.ts";
|
|
21
22
|
import { type CredentialStatus, credentialsOf } from "./env.ts";
|
|
22
23
|
import { type ApprovalState, Principal, type WorkflowDefinition } from "./define.ts";
|
|
@@ -52,7 +53,7 @@ import type { CallContext, Driver, Op } from "./op.ts";
|
|
|
52
53
|
import { allowAll, type RecordedDecision } from "./policy.ts";
|
|
53
54
|
import { admit, readPeople } from "./resources.ts";
|
|
54
55
|
import type { RunArgs, StartHold } from "./run.ts";
|
|
55
|
-
import type {
|
|
56
|
+
import type { SecretAccess, SecretLocation } from "./secret-store.ts";
|
|
56
57
|
import {
|
|
57
58
|
BUILT_IN_WORKFLOWS,
|
|
58
59
|
CALL_WORKFLOW,
|
|
@@ -206,6 +207,7 @@ export interface ChangesFilter {
|
|
|
206
207
|
|
|
207
208
|
/** What `SanomaClient.call` takes besides the operation's id and its input. */
|
|
208
209
|
export interface CallOptions {
|
|
210
|
+
connection?: ConnectionRef;
|
|
209
211
|
/**
|
|
210
212
|
* Who calls, by id, admitted as `startedBy` is: the policy decides as them, a write's record
|
|
211
213
|
* names them, and a save's `updatedBy` is their id.
|
|
@@ -243,7 +245,7 @@ export class SanomaClient {
|
|
|
243
245
|
/** On the config's database, for the runtime's tables (the credentials, the chats): opened on first use. */
|
|
244
246
|
private readonly pool: LazyPool;
|
|
245
247
|
/** What a direct call's driver reads secrets from, as a worker's live driver does. */
|
|
246
|
-
private readonly storedSecrets:
|
|
248
|
+
private readonly storedSecrets: SecretAccess;
|
|
247
249
|
/** The operator agent's chats: their rows and their transcripts, in the config's database. */
|
|
248
250
|
readonly chats: Chats;
|
|
249
251
|
private readonly options: ClientOptions;
|
|
@@ -260,7 +262,7 @@ export class SanomaClient {
|
|
|
260
262
|
this.options = options;
|
|
261
263
|
this.audit = ownedLedger(config);
|
|
262
264
|
this.pool = lazyPool(config.databaseUrl, "client");
|
|
263
|
-
this.storedSecrets =
|
|
265
|
+
this.storedSecrets = configuredSecrets(config.secrets, () => this.db());
|
|
264
266
|
this.chats = chatStore(() => this.db());
|
|
265
267
|
}
|
|
266
268
|
|
|
@@ -272,7 +274,14 @@ export class SanomaClient {
|
|
|
272
274
|
static async connect(config: SanomaConfig, options: ClientOptions = {}): Promise<SanomaClient> {
|
|
273
275
|
const resolved = resolveConfig(config);
|
|
274
276
|
const dbos = await dbosClient(resolved.databaseUrl, resolved.appName);
|
|
275
|
-
|
|
277
|
+
const client = new SanomaClient(dbos, resolved, resolved.driversByVendor, options);
|
|
278
|
+
try {
|
|
279
|
+
await client.storedSecrets.validate();
|
|
280
|
+
return client;
|
|
281
|
+
} catch (error) {
|
|
282
|
+
await client.close();
|
|
283
|
+
throw error;
|
|
284
|
+
}
|
|
276
285
|
}
|
|
277
286
|
|
|
278
287
|
/**
|
|
@@ -414,14 +423,21 @@ export class SanomaClient {
|
|
|
414
423
|
const fn = drivers.get(op.id);
|
|
415
424
|
if (!fn) throw new SanomaError("invalid_input", `${op.id} has no driver: the connector package must attach one`);
|
|
416
425
|
const parsed = parseOrThrow(op.input, input, `The input to ${op.id} does not match its schema`, { op: op.id });
|
|
426
|
+
const connection = await this.config.connections.select(op.vendor, options.connection, actor.id);
|
|
427
|
+
const selected = connection && {
|
|
428
|
+
$connection: connection.id,
|
|
429
|
+
vendor: connection.vendor,
|
|
430
|
+
revision: connection.revision,
|
|
431
|
+
};
|
|
417
432
|
const runId = `direct:${actor.id}:${id}`;
|
|
418
433
|
const heldId = `call:${actor.id}:${id}`;
|
|
419
|
-
const held: RunArgs = { input: { op: op.id, input }, startedBy: actor };
|
|
434
|
+
const held: RunArgs = { input: { op: op.id, input, ...(selected && { connection: selected }) }, startedBy: actor };
|
|
420
435
|
const logged = recorded(op, parsed);
|
|
421
436
|
const write = op.effect !== "read";
|
|
422
437
|
// Only an id the caller gave can have been used: a write made, or held, with it settles it.
|
|
423
438
|
if (write && options.id !== undefined) {
|
|
424
|
-
const first =
|
|
439
|
+
const first =
|
|
440
|
+
(await this.recordedCall(runId, op.id, logged, connection)) ?? (await this.heldCall(op.id, heldId, held));
|
|
425
441
|
if (first) return first.output;
|
|
426
442
|
}
|
|
427
443
|
const before = await beforeOf(policy, op, parsed, this.config.collections, (get, key) =>
|
|
@@ -431,7 +447,7 @@ export class SanomaClient {
|
|
|
431
447
|
),
|
|
432
448
|
);
|
|
433
449
|
const run = { id: runId, workflow: CALL_WORKFLOW, approvals: [] };
|
|
434
|
-
const decision = decideCall(policy, op, parsed, actor, run, before);
|
|
450
|
+
const decision = decideCall(policy, op, parsed, actor, run, { ...before, ...(connection && { connection }) });
|
|
435
451
|
if (decision.kind === "deny") throw new PolicyDeniedError(op.id, decision.reason);
|
|
436
452
|
if (decision.kind === "approve") {
|
|
437
453
|
await this.start(CALL_WORKFLOW, held.input, { startedBy: actor, runId: heldId });
|
|
@@ -445,7 +461,10 @@ export class SanomaClient {
|
|
|
445
461
|
attempt: 1,
|
|
446
462
|
secrets: this.storedSecrets,
|
|
447
463
|
by: actor.id,
|
|
464
|
+
principal: actor,
|
|
465
|
+
...(connection && { connection }),
|
|
448
466
|
};
|
|
467
|
+
await this.config.connections.assertCurrent(connection, actor.id);
|
|
449
468
|
if (!write) return outputOf(op, () => fn(parsed, context));
|
|
450
469
|
return this.directWrite({ op, fn, parsed, logged, actor, decision, context });
|
|
451
470
|
}
|
|
@@ -469,13 +488,22 @@ export class SanomaClient {
|
|
|
469
488
|
const { op, parsed, logged, actor, decision, context } = call;
|
|
470
489
|
const { runId } = context;
|
|
471
490
|
const { dataStore, collections, ledger } = this.config;
|
|
472
|
-
const
|
|
491
|
+
const connectionMutation = op.id.startsWith("sanoma.connection.");
|
|
492
|
+
const transactionStore = connectionMutation
|
|
493
|
+
? this.config.connectionPool
|
|
494
|
+
: op.vendor === DATA_VENDOR
|
|
495
|
+
? dataStore
|
|
496
|
+
: undefined;
|
|
497
|
+
const conn = !ledger && transactionStore ? await (await transactionStore.db()).connect() : undefined;
|
|
473
498
|
let broken: Error | undefined;
|
|
474
499
|
try {
|
|
475
500
|
await conn?.query("BEGIN");
|
|
476
|
-
//
|
|
501
|
+
// The metadata or collection driver shares the ledger transaction; neither has an env guard.
|
|
477
502
|
const fn = conn
|
|
478
|
-
?
|
|
503
|
+
? (connectionMutation
|
|
504
|
+
? connectionDriver(this.config.connections.onDatabase(conn))
|
|
505
|
+
: collectionsDriver([collections.get(op.resource)!], { db: async () => conn })
|
|
506
|
+
).ops[`${op.resource}.${op.name}`]!
|
|
479
507
|
: call.fn;
|
|
480
508
|
const at = Date.now();
|
|
481
509
|
const record = (outcome: { output: unknown } | { error: ErrorInfo }): LedgerRecord => ({
|
|
@@ -492,6 +520,7 @@ export class SanomaClient {
|
|
|
492
520
|
effect: op.effect,
|
|
493
521
|
input: logged,
|
|
494
522
|
decision,
|
|
523
|
+
...(context.connection && { connection: context.connection }),
|
|
495
524
|
...outcome,
|
|
496
525
|
durationMs: Date.now() - at,
|
|
497
526
|
});
|
|
@@ -513,7 +542,7 @@ export class SanomaClient {
|
|
|
513
542
|
return output;
|
|
514
543
|
}
|
|
515
544
|
await conn.query("ROLLBACK");
|
|
516
|
-
return (await this.recordedCall(runId, op.id, logged))!.output;
|
|
545
|
+
return (await this.recordedCall(runId, op.id, logged, context.connection))!.output;
|
|
517
546
|
} catch (err) {
|
|
518
547
|
await conn?.query("ROLLBACK").catch((e: Error) => (broken = e));
|
|
519
548
|
throw err;
|
|
@@ -526,10 +555,19 @@ export class SanomaClient {
|
|
|
526
555
|
* What the direct write recorded as `runId` returned, when one did: refused (`invalid_input`)
|
|
527
556
|
* when it was of another operation or input, and its failure again (`driver_failed`) when it failed.
|
|
528
557
|
*/
|
|
529
|
-
private async recordedCall(
|
|
558
|
+
private async recordedCall(
|
|
559
|
+
runId: string,
|
|
560
|
+
opId: string,
|
|
561
|
+
input: unknown,
|
|
562
|
+
connection?: ConnectionSnapshot,
|
|
563
|
+
): Promise<{ output: unknown } | undefined> {
|
|
530
564
|
const first = (await this.audit.store.read(runId)).find((r) => r.type === "op.called");
|
|
531
565
|
if (!first) return undefined;
|
|
532
|
-
const differs = [
|
|
566
|
+
const differs = [
|
|
567
|
+
...(first.op === opId ? [] : ["op"]),
|
|
568
|
+
...(sameJson(first.input, input) ? [] : ["input"]),
|
|
569
|
+
...(sameJson(first.connection ?? null, connection ?? null) ? [] : ["connection"]),
|
|
570
|
+
];
|
|
533
571
|
if (differs.length) {
|
|
534
572
|
throw new SanomaError(
|
|
535
573
|
"invalid_input",
|
|
@@ -1061,7 +1099,8 @@ export class SanomaClient {
|
|
|
1061
1099
|
issues: [{ path: ["value"], message: problem, code: "custom" }],
|
|
1062
1100
|
});
|
|
1063
1101
|
}
|
|
1064
|
-
|
|
1102
|
+
const current = await this.storedSecrets.read(name);
|
|
1103
|
+
await this.storedSecrets.write(name, value, { expectedVersion: current?.version ?? null, by: setBy });
|
|
1065
1104
|
}
|
|
1066
1105
|
|
|
1067
1106
|
/**
|
|
@@ -1072,7 +1111,8 @@ export class SanomaClient {
|
|
|
1072
1111
|
async clearCredential(name: string, options: { by: string }): Promise<void> {
|
|
1073
1112
|
await this.admittedBy(options);
|
|
1074
1113
|
this.declared(name, () => undefined);
|
|
1075
|
-
|
|
1114
|
+
const current = await this.storedSecrets.read(name);
|
|
1115
|
+
if (current) await this.storedSecrets.delete(name, { expectedVersion: current.version, by: options.by });
|
|
1076
1116
|
}
|
|
1077
1117
|
|
|
1078
1118
|
/**
|
|
@@ -1091,7 +1131,21 @@ export class SanomaClient {
|
|
|
1091
1131
|
const path = VARIABLE.test(name) ? "value" : "name";
|
|
1092
1132
|
throw new SanomaError("invalid_input", problem, { issues: [{ path: [path], message: problem, code: "custom" }] });
|
|
1093
1133
|
}
|
|
1094
|
-
|
|
1134
|
+
const current = await this.storedSecrets.read(name);
|
|
1135
|
+
await this.storedSecrets.write(name, value, { expectedVersion: current?.version ?? null, by: setBy });
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
/** Removes a logical secret from the selected store; external bindings remain read-only. */
|
|
1139
|
+
async clearSecret(name: string, options: { by: string }): Promise<void> {
|
|
1140
|
+
const by = await this.admittedBy(options);
|
|
1141
|
+
if (!VARIABLE.test(name)) {
|
|
1142
|
+
const message = "That is not a variable name";
|
|
1143
|
+
throw new SanomaError("invalid_input", message, {
|
|
1144
|
+
issues: [{ path: ["name"], message, code: "custom" }],
|
|
1145
|
+
});
|
|
1146
|
+
}
|
|
1147
|
+
const current = await this.storedSecrets.read(name);
|
|
1148
|
+
if (current) await this.storedSecrets.delete(name, { expectedVersion: current.version, by });
|
|
1095
1149
|
}
|
|
1096
1150
|
|
|
1097
1151
|
/**
|
|
@@ -1100,40 +1154,51 @@ export class SanomaClient {
|
|
|
1100
1154
|
* is left out. For server code: a page is told whether one is set, never its value.
|
|
1101
1155
|
*/
|
|
1102
1156
|
async secrets(names: readonly string[]): Promise<Map<string, StoredSecret>> {
|
|
1103
|
-
const rows = await storedCredentials(await this.db(), names);
|
|
1104
1157
|
const found = new Map<string, StoredSecret>();
|
|
1105
1158
|
for (const name of names) {
|
|
1106
|
-
const
|
|
1107
|
-
const
|
|
1108
|
-
if (
|
|
1109
|
-
|
|
1159
|
+
const row = await this.storedSecrets.read(name);
|
|
1160
|
+
const location = this.storedSecrets.location(name);
|
|
1161
|
+
if (row)
|
|
1162
|
+
found.set(name, {
|
|
1163
|
+
value: row.value,
|
|
1164
|
+
source: location.store === "environment" ? "environment" : "stored",
|
|
1165
|
+
version: row.version,
|
|
1166
|
+
location,
|
|
1167
|
+
...(row.updatedBy && { setBy: row.updatedBy }),
|
|
1168
|
+
...(row.updatedAt && { setAt: row.updatedAt }),
|
|
1169
|
+
});
|
|
1110
1170
|
}
|
|
1111
1171
|
return found;
|
|
1112
1172
|
}
|
|
1113
1173
|
|
|
1114
|
-
/**
|
|
1115
|
-
* Each vendor's declared variables and their statuses now: from this process's environment,
|
|
1116
|
-
* which wins, else from the stored credentials, with `source` saying which (a value equal to
|
|
1117
|
-
* the stored one, as a worker in this process loads it, is `stored`), and who set a stored one
|
|
1118
|
-
* and when. Never a value.
|
|
1119
|
-
*/
|
|
1174
|
+
/** Non-secret statuses for the selected store and declared bindings. */
|
|
1120
1175
|
async credentials(): Promise<Map<string, CredentialStatus[]>> {
|
|
1121
1176
|
const names = [...credentialsOf(this.drivers, () => undefined).values()].flat().map((c) => c.name);
|
|
1122
|
-
|
|
1123
|
-
const
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1177
|
+
const rows = await this.secrets(names);
|
|
1178
|
+
const statuses = credentialsOf(this.drivers, (name) => rows.get(name)?.value);
|
|
1179
|
+
return new Map(
|
|
1180
|
+
[...statuses].map(([vendor, list]) => [
|
|
1181
|
+
vendor,
|
|
1182
|
+
list.map((c) => {
|
|
1183
|
+
const row = rows.get(c.name);
|
|
1184
|
+
return {
|
|
1185
|
+
...c,
|
|
1186
|
+
location: this.storedSecrets.location(c.name),
|
|
1187
|
+
...(row && { source: row.source, setBy: row.setBy, setAt: row.setAt }),
|
|
1188
|
+
};
|
|
1189
|
+
}),
|
|
1190
|
+
]),
|
|
1191
|
+
);
|
|
1133
1192
|
}
|
|
1134
1193
|
|
|
1135
1194
|
async close() {
|
|
1136
|
-
await Promise.all([
|
|
1195
|
+
await Promise.all([
|
|
1196
|
+
this.dbos.destroy(),
|
|
1197
|
+
this.pool.close(),
|
|
1198
|
+
this.audit.close(),
|
|
1199
|
+
this.config.dataStore?.close(),
|
|
1200
|
+
this.config.connectionPool.close(),
|
|
1201
|
+
]);
|
|
1137
1202
|
}
|
|
1138
1203
|
|
|
1139
1204
|
/**
|
|
@@ -1285,6 +1350,8 @@ const sameJson = (a: unknown, b: unknown): boolean => isDeepStrictEqual(asJson(a
|
|
|
1285
1350
|
|
|
1286
1351
|
/** A secret's value as `SanomaClient.secrets` reads it, and where from. */
|
|
1287
1352
|
export interface StoredSecret {
|
|
1353
|
+
version?: string;
|
|
1354
|
+
location?: SecretLocation;
|
|
1288
1355
|
value: string;
|
|
1289
1356
|
source: "environment" | "stored";
|
|
1290
1357
|
/** Who stored it (`setSecret`'s `by`), and when, as an ISO 8601 time. */
|
|
@@ -1293,7 +1360,7 @@ export interface StoredSecret {
|
|
|
1293
1360
|
}
|
|
1294
1361
|
|
|
1295
1362
|
/** What an environment variable's name looks like. */
|
|
1296
|
-
const VARIABLE = /^[A-
|
|
1363
|
+
const VARIABLE = /^[A-Z][A-Z0-9_]*$/;
|
|
1297
1364
|
|
|
1298
1365
|
const alreadyDecided = (runId: string, a: ApprovalState) =>
|
|
1299
1366
|
new SanomaError(
|
package/src/collection.ts
CHANGED
|
@@ -99,10 +99,18 @@ export function collection<
|
|
|
99
99
|
const F extends Record<string, Field>,
|
|
100
100
|
const K extends RequiredTextKeys<F>,
|
|
101
101
|
>(declaration: { name: N; key: K; fields: F; title?: string }): Collection<N, F, K> {
|
|
102
|
+
// Frame 2 of `callerFile` is this function's caller: the data file.
|
|
103
|
+
return collectionIn(appOfFile(callerFile()), declaration);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** `collection()` in the app `app` names (none for the root's): what the loader's `collection` calls, knowing the file itself. */
|
|
107
|
+
export function collectionIn<
|
|
108
|
+
const N extends string,
|
|
109
|
+
const F extends Record<string, Field>,
|
|
110
|
+
const K extends RequiredTextKeys<F>,
|
|
111
|
+
>(app: string | undefined, declaration: { name: N; key: K; fields: F; title?: string }): Collection<N, F, K> {
|
|
102
112
|
checkCollection(declaration);
|
|
103
113
|
const { key, fields, title } = declaration;
|
|
104
|
-
// Frame 2 of `callerFile` is this function's caller: the data file.
|
|
105
|
-
const app = appOfFile(callerFile());
|
|
106
114
|
const name = qualified(app, declaration.name);
|
|
107
115
|
const record = z.strictObject(
|
|
108
116
|
Object.fromEntries(
|
package/src/config.ts
CHANGED
|
@@ -1,3 +1,14 @@
|
|
|
1
|
+
import { configuredSecrets } from "./credentials.ts";
|
|
2
|
+
import { currentRun } from "./run.ts";
|
|
3
|
+
import {
|
|
4
|
+
connectionCatalog,
|
|
5
|
+
ConnectionRegistry,
|
|
6
|
+
connectionDriver,
|
|
7
|
+
connectionOps,
|
|
8
|
+
type ConnectionDefinition,
|
|
9
|
+
type ConnectionRule,
|
|
10
|
+
} from "./connection.ts";
|
|
11
|
+
import { checkSecretConfiguration, type SecretConfiguration } from "./secret-store.ts";
|
|
1
12
|
import { existsSync, readFileSync } from "node:fs";
|
|
2
13
|
import { dirname, join, resolve } from "node:path";
|
|
3
14
|
import { pathToFileURL } from "node:url";
|
|
@@ -81,6 +92,10 @@ export interface SanomaConfig {
|
|
|
81
92
|
* only, for tests.
|
|
82
93
|
*/
|
|
83
94
|
ledger?: LedgerStore;
|
|
95
|
+
/** Credential storage and explicit bindings. Omitted: encrypted Postgres, no environment fallback. */
|
|
96
|
+
secrets?: SecretConfiguration;
|
|
97
|
+
connections?: readonly ConnectionDefinition[];
|
|
98
|
+
connectionRules?: readonly ConnectionRule[];
|
|
84
99
|
/** Postgres for the runtime. Defaults to `SANOMA_DATABASE_URL`, then `DEFAULT_DATABASE_URL`. */
|
|
85
100
|
databaseUrl?: string;
|
|
86
101
|
/** The file that defined the config, absolute, when known. Set by `defineConfig` from its call site. */
|
|
@@ -118,6 +133,9 @@ export interface SanomaConfig {
|
|
|
118
133
|
|
|
119
134
|
/** A config, checked, with everything a worker, client or app derives from it. */
|
|
120
135
|
export interface ResolvedConfig {
|
|
136
|
+
secrets: SecretConfiguration;
|
|
137
|
+
connections: ConnectionRegistry;
|
|
138
|
+
connectionPool: LazyPool;
|
|
121
139
|
/**
|
|
122
140
|
* Scopes workflows, queues and versions in the system database: the `name` of
|
|
123
141
|
* `<root>/package.json` when it names one, else "sanoma".
|
|
@@ -252,6 +270,7 @@ export function rootOf(config: Pick<SanomaConfig, "root" | "file">): string | un
|
|
|
252
270
|
* `startApp`, the `sanoma` bin when it loads the config.
|
|
253
271
|
*/
|
|
254
272
|
export function resolveConfig(config: SanomaConfig): ResolvedConfig {
|
|
273
|
+
checkSecretConfiguration(config.secrets ?? {});
|
|
255
274
|
if (!Array.isArray(config.connectors)) {
|
|
256
275
|
throw new Error("The config needs `connectors`: the connectors its workflows call, as their packages export them");
|
|
257
276
|
}
|
|
@@ -280,6 +299,12 @@ export function resolveConfig(config: SanomaConfig): ResolvedConfig {
|
|
|
280
299
|
const hostTyped = [...types.values()].some((type) => type.host);
|
|
281
300
|
// Built-in: the collections' operations, `data.<name>.<op>`, on the config's database.
|
|
282
301
|
const databaseUrl = resolveDatabaseUrl(config);
|
|
302
|
+
const connectionPool = lazyPool(databaseUrl, "connections");
|
|
303
|
+
const connections = new ConnectionRegistry(
|
|
304
|
+
connectionCatalog(config.connectors, config.connections, config.connectionRules),
|
|
305
|
+
() => connectionPool.db(),
|
|
306
|
+
configuredSecrets(config.secrets ?? {}, () => connectionPool.db()),
|
|
307
|
+
);
|
|
283
308
|
const declared = [...collections.values()];
|
|
284
309
|
const dataStore = declared.length ? lazyPool(databaseUrl, "data") : undefined;
|
|
285
310
|
// Built-in, in every config: a change's operations, which the template's policy names
|
|
@@ -287,6 +312,7 @@ export function resolveConfig(config: SanomaConfig): ResolvedConfig {
|
|
|
287
312
|
const ops = indexConnectors([
|
|
288
313
|
...running,
|
|
289
314
|
changeOps,
|
|
315
|
+
connectionOps,
|
|
290
316
|
appsListOps,
|
|
291
317
|
...(hostTyped ? [sourceOps] : []),
|
|
292
318
|
...(dataStore ? [collectionsConnector(declared)] : []),
|
|
@@ -299,6 +325,7 @@ export function resolveConfig(config: SanomaConfig): ResolvedConfig {
|
|
|
299
325
|
[
|
|
300
326
|
...driversByVendor.values(),
|
|
301
327
|
changeDriver(remotes),
|
|
328
|
+
connectionDriver(connections),
|
|
302
329
|
appsListDriver,
|
|
303
330
|
...(hostTyped ? [sourceDriver] : []),
|
|
304
331
|
...(dataStore ? [collectionsDriver(declared, dataStore)] : []),
|
|
@@ -326,7 +353,7 @@ export function resolveConfig(config: SanomaConfig): ResolvedConfig {
|
|
|
326
353
|
const connectors = connectorsOf(config);
|
|
327
354
|
// The types the data files may declare: the vendors' and the people's, whose resources are left out.
|
|
328
355
|
const declarable = resourceTypesOf(connectors);
|
|
329
|
-
const read = () => readDeclared(root, connectors, declarable, types);
|
|
356
|
+
const read = () => readDeclared(root, connectors, declarable, types, connections);
|
|
330
357
|
if (types.size) {
|
|
331
358
|
names.set(
|
|
332
359
|
DRIFT_WORKFLOW,
|
|
@@ -343,6 +370,9 @@ export function resolveConfig(config: SanomaConfig): ResolvedConfig {
|
|
|
343
370
|
if (call.uses.length) names.set(CALL_WORKFLOW, call);
|
|
344
371
|
const { policy, policies, problems: policyProblems } = policyAt(root, { ops, workflows: names, collections });
|
|
345
372
|
return {
|
|
373
|
+
secrets: config.secrets ?? {},
|
|
374
|
+
connections,
|
|
375
|
+
connectionPool,
|
|
346
376
|
appName,
|
|
347
377
|
databaseUrl,
|
|
348
378
|
// The built-in's code and operations are part of the app's version, like the config's own.
|
|
@@ -495,9 +525,9 @@ export async function declaredWakeup(config: SanomaConfig): Promise<DeclaredWake
|
|
|
495
525
|
const connectors = connectorsOf(config);
|
|
496
526
|
const types = resourceTypesOf(connectors);
|
|
497
527
|
if (![...types.values()].some((type) => type.wakeup)) return undefined;
|
|
498
|
-
// Loaded when it is needed, not with the worker
|
|
499
|
-
const {
|
|
500
|
-
const { resources } =
|
|
528
|
+
// Loaded when it is needed, not with the worker.
|
|
529
|
+
const { loadDataFiles } = await import("./resources.ts");
|
|
530
|
+
const { resources } = loadDataFiles(rootOf(config), connectors, types);
|
|
501
531
|
const typeOf = (r: DeclaredResource) => types.get(`${r.vendor}.${r.type}`)!;
|
|
502
532
|
const declared = resources.find((r) => typeOf(r).wakeup);
|
|
503
533
|
if (!declared) return undefined;
|
|
@@ -523,15 +553,38 @@ async function readDeclared(
|
|
|
523
553
|
connectors: readonly Connector<any, any>[],
|
|
524
554
|
declarable: ReadonlyMap<string, Resource>,
|
|
525
555
|
types: ReadonlyMap<string, Resource>,
|
|
556
|
+
connections: ConnectionRegistry,
|
|
526
557
|
): Promise<Omit<ApplyDeclared, "rebinds">> {
|
|
527
|
-
// Loaded when a run reads the files, not with the worker
|
|
528
|
-
const {
|
|
529
|
-
const { resources, bindings, orphans, problems } =
|
|
558
|
+
// Loaded when a run reads the files, not with the worker.
|
|
559
|
+
const { loadDataFiles } = await import("./resources.ts");
|
|
560
|
+
const { resources, bindings, orphans, problems } = loadDataFiles(root, connectors, declarable);
|
|
561
|
+
// Read inside the data-file durable step: every operation of the plan uses one pinned account.
|
|
562
|
+
const run = currentRun.getStore();
|
|
563
|
+
if (run && !run.sandbox) {
|
|
564
|
+
for (const resource of resources.filter((r) => types.has(`${r.vendor}.${r.type}`))) {
|
|
565
|
+
try {
|
|
566
|
+
const selected = await connections.select(resource.vendor, resource.connection, run.actor.id);
|
|
567
|
+
if (selected) {
|
|
568
|
+
const pinned = { vendor: selected.vendor, $connection: selected.id, revision: selected.revision };
|
|
569
|
+
const locked = bindings[resource.id]?.connection;
|
|
570
|
+
if (locked && (locked.$connection !== pinned.$connection || locked.revision !== pinned.revision))
|
|
571
|
+
throw new Error("The bound resource's account changed; explicitly unbind before using a new account");
|
|
572
|
+
resource.connection = pinned;
|
|
573
|
+
}
|
|
574
|
+
} catch {
|
|
575
|
+
problems.push({
|
|
576
|
+
file: resource.file,
|
|
577
|
+
message: `${resource.id}: its connection is unavailable or changed; reconnect and explicitly rebind`,
|
|
578
|
+
});
|
|
579
|
+
resources.splice(resources.indexOf(resource), 1);
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
}
|
|
530
583
|
return {
|
|
531
584
|
// Only `types`' resources have a vendor object to check or apply: not the people's (vendorless).
|
|
532
585
|
resources: resources
|
|
533
586
|
.filter((r) => types.has(`${r.vendor}.${r.type}`))
|
|
534
|
-
.map(({ id, vendor, type, name, identity, binding, desired, refs }) => ({
|
|
587
|
+
.map(({ id, vendor, type, name, identity, binding, desired, refs, connection }) => ({
|
|
535
588
|
id,
|
|
536
589
|
vendor,
|
|
537
590
|
type,
|
|
@@ -540,6 +593,7 @@ async function readDeclared(
|
|
|
540
593
|
...(binding !== undefined && { binding }),
|
|
541
594
|
desired: withoutWriteOnly(types.get(`${vendor}.${type}`)!.fields, desired),
|
|
542
595
|
refs,
|
|
596
|
+
...(connection && { connection }),
|
|
543
597
|
})),
|
|
544
598
|
bindings,
|
|
545
599
|
orphans,
|