@sanoma-ai/workflows 0.1.0 → 0.2.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 +72 -21
- package/dist/apply-steps.d.ts +6 -0
- package/dist/apply-steps.js +93 -0
- package/dist/apply-steps.js.map +1 -0
- package/dist/apply.d.ts +197 -0
- package/dist/apply.js +470 -0
- package/dist/apply.js.map +1 -0
- package/dist/call.d.ts +4 -0
- package/dist/call.js +19 -4
- package/dist/call.js.map +1 -1
- package/dist/client.d.ts +38 -2
- package/dist/client.js +67 -10
- package/dist/client.js.map +1 -1
- package/dist/config.d.ts +3 -3
- package/dist/config.js +33 -16
- package/dist/config.js.map +1 -1
- package/dist/credentials.d.ts +25 -0
- package/dist/credentials.js +73 -5
- package/dist/credentials.js.map +1 -1
- package/dist/datafile.d.ts +11 -2
- package/dist/datafile.js +56 -2
- package/dist/datafile.js.map +1 -1
- package/dist/describe.d.ts +11 -5
- package/dist/describe.js +13 -5
- package/dist/describe.js.map +1 -1
- package/dist/drift.d.ts +64 -12
- package/dist/drift.js +118 -41
- package/dist/drift.js.map +1 -1
- package/dist/expectations.d.ts +2 -2
- package/dist/expectations.js +28 -3
- package/dist/expectations.js.map +1 -1
- package/dist/host.d.ts +56 -0
- package/dist/host.js +45 -0
- package/dist/host.js.map +1 -0
- package/dist/index.d.ts +6 -2
- package/dist/index.js +5 -1
- package/dist/index.js.map +1 -1
- package/dist/lockfile.d.ts +36 -0
- package/dist/lockfile.js +69 -0
- package/dist/lockfile.js.map +1 -0
- package/dist/op.d.ts +3 -0
- package/dist/op.js.map +1 -1
- package/dist/resource.d.ts +188 -22
- package/dist/resource.js +173 -28
- package/dist/resource.js.map +1 -1
- package/dist/resources.d.ts +18 -3
- package/dist/resources.js +53 -6
- package/dist/resources.js.map +1 -1
- package/dist/run.d.ts +14 -0
- package/dist/run.js +6 -1
- package/dist/run.js.map +1 -1
- package/dist/sandbox.js +5 -1
- package/dist/sandbox.js.map +1 -1
- package/dist/scenario.d.ts +12 -3
- package/dist/scenario.js +33 -8
- package/dist/scenario.js.map +1 -1
- package/dist/secret.d.ts +42 -0
- package/dist/secret.js +58 -0
- package/dist/secret.js.map +1 -0
- package/dist/shared.d.ts +8 -0
- package/dist/shared.js +7 -0
- package/dist/shared.js.map +1 -1
- package/dist/source.d.ts +58 -0
- package/dist/source.js +54 -0
- package/dist/source.js.map +1 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/worker.d.ts +3 -0
- package/dist/worker.js +13 -4
- package/dist/worker.js.map +1 -1
- package/dist/writer.d.ts +35 -0
- package/dist/writer.js +201 -0
- package/dist/writer.js.map +1 -0
- package/package.json +6 -1
- package/src/apply-steps.ts +110 -0
- package/src/apply.ts +626 -0
- package/src/call.ts +19 -4
- package/src/client.ts +83 -13
- package/src/config.ts +48 -25
- package/src/credentials.ts +90 -5
- package/src/datafile.ts +76 -4
- package/src/describe.ts +36 -16
- package/src/drift.ts +155 -45
- package/src/expectations.ts +30 -4
- package/src/host.ts +96 -0
- package/src/index.ts +24 -1
- package/src/lockfile.ts +95 -0
- package/src/op.ts +3 -0
- package/src/resource.ts +270 -47
- package/src/resources.ts +63 -8
- package/src/run.ts +15 -0
- package/src/sandbox.ts +5 -1
- package/src/scenario.ts +50 -13
- package/src/secret.ts +92 -0
- package/src/shared.ts +10 -0
- package/src/source.ts +82 -0
- package/src/version.ts +1 -1
- package/src/worker.ts +16 -5
- package/src/writer.ts +225 -0
package/src/client.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { deleteCredential, storeCredential, storedCredentials } from "./credenti
|
|
|
9
9
|
import { dbosClient, lazyPool, type LazyPool } from "./db.ts";
|
|
10
10
|
import { type CredentialStatus, credentialsOf } from "./env.ts";
|
|
11
11
|
import { type ApprovalState, notApprover, Principal, type WorkflowDefinition } from "./define.ts";
|
|
12
|
+
import { APPLY_WORKFLOW, type ApplyInput, type ApplyReport } from "./apply.ts";
|
|
12
13
|
import { DRIFT_WORKFLOW, type DriftReport } from "./drift.ts";
|
|
13
14
|
import { parseOrThrow, SanomaError } from "./errors.ts";
|
|
14
15
|
import { type LedgerRecord, type OwnedLedger, ownedLedger } from "./ledger.ts";
|
|
@@ -335,31 +336,55 @@ export class SanomaClient {
|
|
|
335
336
|
* `invalid_input` when the config's connectors declare no resource types, so it has none.
|
|
336
337
|
*/
|
|
337
338
|
async drift(options: StartOptions): Promise<string> {
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
339
|
+
return this.start(this.builtIn(DRIFT_WORKFLOW), {}, options);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/** The report of a drift run once it ends: as `applyReport` for apply. */
|
|
343
|
+
async driftReport(runId: string): Promise<DriftReport> {
|
|
344
|
+
return (await this.reportOf(runId, DRIFT_WORKFLOW)) as DriftReport;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Starts the built-in apply workflow, which reads the config's data files itself, with `input`
|
|
349
|
+
* (`only`, `bind`, `answers`; `{}` for all). Returns the run id; `applyReport` waits for its
|
|
350
|
+
* report. Throws `invalid_input` when the config's connectors declare no resource types.
|
|
351
|
+
*/
|
|
352
|
+
async apply(input: ApplyInput, options: StartOptions): Promise<string> {
|
|
353
|
+
return this.start(this.builtIn(APPLY_WORKFLOW), input, options);
|
|
346
354
|
}
|
|
347
355
|
|
|
348
356
|
/**
|
|
349
|
-
* The report of
|
|
357
|
+
* The report of an apply run once it ends. Throws `run_not_found`, `invalid_input` for another
|
|
350
358
|
* workflow's run, the run's error when it failed, and `run_running` when it has not ended
|
|
351
359
|
* within 30 s.
|
|
352
360
|
*/
|
|
353
|
-
async
|
|
361
|
+
async applyReport(runId: string): Promise<ApplyReport> {
|
|
362
|
+
return (await this.reportOf(runId, APPLY_WORKFLOW)) as ApplyReport;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/** The built-in workflow `name`; `invalid_input` when the config has none, as its connectors declare no resource types. */
|
|
366
|
+
private builtIn(name: string) {
|
|
367
|
+
const workflow = this.config.workflows.get(name);
|
|
368
|
+
if (!workflow) {
|
|
369
|
+
throw new SanomaError(
|
|
370
|
+
"invalid_input",
|
|
371
|
+
`The config's connectors declare no resource types, so it has no built-in ${name} workflow`,
|
|
372
|
+
);
|
|
373
|
+
}
|
|
374
|
+
return workflow;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/** The output of a run of the built-in `workflow` once it ends. */
|
|
378
|
+
private async reportOf(runId: string, workflow: string): Promise<unknown> {
|
|
354
379
|
const row = await this.mustExist(runId, { loadOutput: true });
|
|
355
|
-
if (row.workflowName !==
|
|
356
|
-
throw new SanomaError("invalid_input", `Run ${runId} is a run of ${row.workflowName}, not of ${
|
|
380
|
+
if (row.workflowName !== workflow) {
|
|
381
|
+
throw new SanomaError("invalid_input", `Run ${runId} is a run of ${row.workflowName}, not of ${workflow}`, {
|
|
357
382
|
runId,
|
|
358
383
|
workflow: row.workflowName,
|
|
359
384
|
});
|
|
360
385
|
}
|
|
361
386
|
// A finished run's report came with its row; else wait for the run.
|
|
362
|
-
return
|
|
387
|
+
return row.status === "SUCCESS" ? row.output : await this.awaitResult(runId, 30_000);
|
|
363
388
|
}
|
|
364
389
|
|
|
365
390
|
/**
|
|
@@ -395,6 +420,42 @@ export class SanomaClient {
|
|
|
395
420
|
await deleteCredential(await this.credentialsDb(), name);
|
|
396
421
|
}
|
|
397
422
|
|
|
423
|
+
/**
|
|
424
|
+
* Stores a secret as `by`, and tells the workers: a value under any variable's name, declared by
|
|
425
|
+
* a driver or not, such as a data file's `secret("NAME")` or the app's own (its GitHub App's
|
|
426
|
+
* keys). A name a driver declares is checked as `setCredential` checks it; any other name must
|
|
427
|
+
* look like a variable's, and the value must not be empty (`invalid_input`, never the value).
|
|
428
|
+
*/
|
|
429
|
+
async setSecret(name: string, value: string, options: { by: string }): Promise<void> {
|
|
430
|
+
if ([...credentialsOf(this.drivers, () => undefined).values()].flat().some((c) => c.name === name)) {
|
|
431
|
+
return this.setCredential(name, value, options);
|
|
432
|
+
}
|
|
433
|
+
const setBy = actorOf(options);
|
|
434
|
+
const problem = !VARIABLE.test(name) ? "That is not a variable name" : !value ? "Enter a value" : undefined;
|
|
435
|
+
if (problem) {
|
|
436
|
+
const path = VARIABLE.test(name) ? "value" : "name";
|
|
437
|
+
throw new SanomaError("invalid_input", problem, { issues: [{ path: [path], message: problem, code: "custom" }] });
|
|
438
|
+
}
|
|
439
|
+
await storeCredential(await this.credentialsDb(), { name, value, setBy });
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* The values of these secrets, as a driver's `secrets.get` reads them: this process's
|
|
444
|
+
* environment, which wins, else the stored value, with who set it and when. A name with neither
|
|
445
|
+
* is left out. For server code: a page is told whether one is set, never its value.
|
|
446
|
+
*/
|
|
447
|
+
async secrets(names: readonly string[]): Promise<Map<string, StoredSecret>> {
|
|
448
|
+
const rows = await storedCredentials(await this.credentialsDb(), names);
|
|
449
|
+
const found = new Map<string, StoredSecret>();
|
|
450
|
+
for (const name of names) {
|
|
451
|
+
const env = process.env[name];
|
|
452
|
+
const row = rows.get(name);
|
|
453
|
+
if (env && env !== row?.value) found.set(name, { value: env, source: "environment" });
|
|
454
|
+
else if (row) found.set(name, { ...row, source: "stored" });
|
|
455
|
+
}
|
|
456
|
+
return found;
|
|
457
|
+
}
|
|
458
|
+
|
|
398
459
|
/**
|
|
399
460
|
* Each vendor's declared variables and their statuses now: from this process's environment,
|
|
400
461
|
* which wins, else from the stored credentials, with `source` saying which (a value equal to
|
|
@@ -518,6 +579,15 @@ const asJson = (v: unknown): unknown => (v === undefined ? undefined : JSON.pars
|
|
|
518
579
|
/** True when the two are the same JSON, whatever the order of their keys. */
|
|
519
580
|
const sameJson = (a: unknown, b: unknown): boolean => isDeepStrictEqual(asJson(a), asJson(b));
|
|
520
581
|
|
|
582
|
+
/** A secret's value as `SanomaClient.secrets` reads it, and where from. */
|
|
583
|
+
export interface StoredSecret {
|
|
584
|
+
value: string;
|
|
585
|
+
source: "environment" | "stored";
|
|
586
|
+
/** Who stored it (`setSecret`'s `by`), and when, as an ISO 8601 time. */
|
|
587
|
+
setBy?: string;
|
|
588
|
+
setAt?: string;
|
|
589
|
+
}
|
|
590
|
+
|
|
521
591
|
/** What an environment variable's name looks like. */
|
|
522
592
|
const VARIABLE = /^[A-Z_][A-Z0-9_]*$/;
|
|
523
593
|
|
package/src/config.ts
CHANGED
|
@@ -2,8 +2,10 @@ import { existsSync, readFileSync, statSync } from "node:fs";
|
|
|
2
2
|
import { dirname, join, resolve } from "node:path";
|
|
3
3
|
import { pathToFileURL } from "node:url";
|
|
4
4
|
import { DBOS } from "@dbos-inc/dbos-sdk";
|
|
5
|
+
import { type ApplyDeclared, applyWorkflow } from "./apply.ts";
|
|
6
|
+
import { applySteps } from "./apply-steps.ts";
|
|
5
7
|
import { callerFile, type Use, type WorkflowDefinition } from "./define.ts";
|
|
6
|
-
import { DRIFT_WORKFLOW,
|
|
8
|
+
import { DRIFT_WORKFLOW, driftWorkflow } from "./drift.ts";
|
|
7
9
|
import { statusesOf, unconfigured } from "./env.ts";
|
|
8
10
|
import type { Fake } from "./fake.ts";
|
|
9
11
|
import type { LedgerStore } from "./ledger.ts";
|
|
@@ -21,7 +23,9 @@ import {
|
|
|
21
23
|
VENDOR,
|
|
22
24
|
} from "./op.ts";
|
|
23
25
|
import type { Policy } from "./policy.ts";
|
|
24
|
-
import { type Resource, resourceTypesOf, withoutWriteOnly } from "./resource.ts";
|
|
26
|
+
import { isChangeOp, type Resource, resourceTypesOf, withoutWriteOnly } from "./resource.ts";
|
|
27
|
+
import { APPLY_WORKFLOW, BUILT_IN_WORKFLOWS } from "./shared.ts";
|
|
28
|
+
import { sourceDriver, sourceOps } from "./source.ts";
|
|
25
29
|
import type { Steps } from "./steps.ts";
|
|
26
30
|
import { computeVersion } from "./version.ts";
|
|
27
31
|
|
|
@@ -86,7 +90,7 @@ export interface ResolvedConfig {
|
|
|
86
90
|
version: string;
|
|
87
91
|
/** The DBOS queue runs are started on: `sanoma:<appName>`. */
|
|
88
92
|
queueName: string;
|
|
89
|
-
/** The operations the connectors declare, by id. */
|
|
93
|
+
/** The operations the connectors declare, by id, and the built-in `sanoma.source.resolve` when one declares a host type. */
|
|
90
94
|
ops: Map<string, Op>;
|
|
91
95
|
/** The drivers' functions, by operation id, each guarded by its driver's `env` (`indexDrivers`). */
|
|
92
96
|
drivers: Map<string, DriverFn>;
|
|
@@ -104,8 +108,8 @@ export interface ResolvedConfig {
|
|
|
104
108
|
/** The steps scenarios can use besides the generic ones: each connector's (`withSetup`), once each, then the config's own. */
|
|
105
109
|
steps: readonly Steps[];
|
|
106
110
|
/**
|
|
107
|
-
* By name; each checked against `ops` and `drivers`. The config's, and the built-
|
|
108
|
-
* (`DRIFT_WORKFLOW`) when its connectors declare resource types.
|
|
111
|
+
* By name; each checked against `ops` and `drivers`. The config's, and the built-ins `drift`
|
|
112
|
+
* (`DRIFT_WORKFLOW`) and `apply` (`APPLY_WORKFLOW`) when its connectors declare resource types.
|
|
109
113
|
*/
|
|
110
114
|
workflows: Map<string, WorkflowDefinition<any, any>>;
|
|
111
115
|
policy: Policy;
|
|
@@ -185,18 +189,19 @@ export function resolveConfig(config: SanomaConfig): ResolvedConfig {
|
|
|
185
189
|
const root = rootOf(config);
|
|
186
190
|
const appName = appNameAt(root);
|
|
187
191
|
const scenarios = scenariosAt(root);
|
|
188
|
-
const
|
|
192
|
+
const types = resourceTypesOf(config.connectors);
|
|
193
|
+
// Built-in: `sanoma.source.resolve`, which apply and drift call for a host's revision.
|
|
194
|
+
const hosts = [...types.values()].some((type) => type.host);
|
|
195
|
+
const ops = indexConnectors([...config.connectors, ...(hosts ? [sourceOps] : [])]);
|
|
189
196
|
const driversByVendor = new Map([...makersOf(config.connectors, "driver")].map(([vendor, make]) => [vendor, make()]));
|
|
190
|
-
const drivers = indexDrivers([...driversByVendor.values()], ops);
|
|
197
|
+
const drivers = indexDrivers([...driversByVendor.values(), ...(hosts ? [sourceDriver] : [])], ops);
|
|
191
198
|
const fakes = makersOf(config.connectors, "fake");
|
|
192
199
|
const names = new Map<string, WorkflowDefinition<any, any>>();
|
|
193
200
|
for (const wf of config.workflows) {
|
|
194
|
-
if (wf.name
|
|
195
|
-
throw new Error(
|
|
196
|
-
`A workflow is named "${DRIFT_WORKFLOW}", the name of the built-in drift workflow; name it otherwise`,
|
|
197
|
-
);
|
|
201
|
+
if (BUILT_IN_WORKFLOWS.has(wf.name)) {
|
|
202
|
+
throw new Error(`A workflow is named "${wf.name}", the name of a built-in workflow; name it otherwise`);
|
|
198
203
|
}
|
|
199
|
-
checkUses(wf, ops, drivers);
|
|
204
|
+
checkUses(wf, ops, drivers, types);
|
|
200
205
|
const other = names.get(wf.name);
|
|
201
206
|
if (other && other !== wf) {
|
|
202
207
|
throw new Error(
|
|
@@ -205,12 +210,14 @@ export function resolveConfig(config: SanomaConfig): ResolvedConfig {
|
|
|
205
210
|
}
|
|
206
211
|
names.set(wf.name, wf);
|
|
207
212
|
}
|
|
208
|
-
// Built-in: checking the resources the data files declare against their vendors.
|
|
209
|
-
const types = resourceTypesOf(config.connectors);
|
|
213
|
+
// Built-in: checking the resources the data files declare against their vendors, and applying them.
|
|
210
214
|
if (types.size) {
|
|
211
|
-
const
|
|
212
|
-
|
|
213
|
-
|
|
215
|
+
const read = () => readDeclared(root, config.connectors, types);
|
|
216
|
+
names.set(
|
|
217
|
+
DRIFT_WORKFLOW,
|
|
218
|
+
driftWorkflow(types, ops, drivers, () => DBOS.runStep(read, { name: "drift:data-files" })),
|
|
219
|
+
);
|
|
220
|
+
names.set(APPLY_WORKFLOW, applyWorkflow(types, ops, drivers, applySteps(appName, root, read)));
|
|
214
221
|
}
|
|
215
222
|
return {
|
|
216
223
|
appName,
|
|
@@ -280,26 +287,32 @@ function scenariosAt(root: string | undefined): URL | undefined {
|
|
|
280
287
|
}
|
|
281
288
|
|
|
282
289
|
/**
|
|
283
|
-
* The data files under `root
|
|
284
|
-
*
|
|
290
|
+
* The data files under `root` and the lockfile beside them, in one read, as a drift or apply run
|
|
291
|
+
* records them: each resource with its binding, without its write-only values (a secret marker
|
|
292
|
+
* names one, and stays), which a run never compares and never records, the bindings, the orphans
|
|
293
|
+
* and the problems.
|
|
285
294
|
*/
|
|
286
295
|
async function readDeclared(
|
|
287
296
|
root: string | undefined,
|
|
288
297
|
connectors: readonly Connector<any, any>[],
|
|
289
298
|
types: ReadonlyMap<string, Resource>,
|
|
290
|
-
): Promise<
|
|
291
|
-
// Loaded when a
|
|
299
|
+
): Promise<Omit<ApplyDeclared, "rebinds">> {
|
|
300
|
+
// Loaded when a run reads the files, not with the worker: the reader parses with oxc-parser.
|
|
292
301
|
const { readDataFiles } = await import("./resources.ts");
|
|
293
|
-
const { resources, problems } = readDataFiles(root, connectors, types);
|
|
302
|
+
const { resources, bindings, orphans, problems } = readDataFiles(root, connectors, types);
|
|
294
303
|
return {
|
|
295
|
-
resources: resources.map(({ id, vendor, type, name, desired, refs }) => ({
|
|
304
|
+
resources: resources.map(({ id, vendor, type, name, identity, binding, desired, refs }) => ({
|
|
296
305
|
id,
|
|
297
306
|
vendor,
|
|
298
307
|
type,
|
|
299
308
|
name,
|
|
309
|
+
...(identity !== undefined && { identity }),
|
|
310
|
+
...(binding !== undefined && { binding }),
|
|
300
311
|
desired: withoutWriteOnly(types.get(`${vendor}.${type}`)!.fields, desired),
|
|
301
312
|
refs,
|
|
302
313
|
})),
|
|
314
|
+
bindings,
|
|
315
|
+
orphans,
|
|
303
316
|
problems,
|
|
304
317
|
};
|
|
305
318
|
}
|
|
@@ -362,13 +375,23 @@ function guarded(vendor: string, env: DriverEnv, fn: DriverFn): DriverFn {
|
|
|
362
375
|
* Every operation a workflow uses must be one the config's connectors declare, with a driver.
|
|
363
376
|
* The workflow normally imports the same connector object; a copy is accepted only if it
|
|
364
377
|
* declares the same effect and retry setting, and the worker's declaration is used either way.
|
|
378
|
+
* None may change a declared resource (`isChangeOp`): only the built-in apply workflow does.
|
|
365
379
|
*/
|
|
366
|
-
function checkUses(
|
|
380
|
+
function checkUses(
|
|
381
|
+
wf: WorkflowDefinition<any, any>,
|
|
382
|
+
ops: Map<string, Op>,
|
|
383
|
+
impls: Map<string, unknown>,
|
|
384
|
+
types: ReadonlyMap<string, Resource>,
|
|
385
|
+
) {
|
|
367
386
|
const problems: string[] = [];
|
|
368
387
|
for (const op of (wf.uses as readonly Use[]).filter(isOp)) {
|
|
369
388
|
const known = ops.get(op.id);
|
|
370
389
|
if (!known) problems.push(`${op.id} is not declared by any connector in \`connectors\``);
|
|
371
|
-
else if (
|
|
390
|
+
else if (isChangeOp(known, types)) {
|
|
391
|
+
problems.push(
|
|
392
|
+
`${op.id} changes a declared resource, which only ${APPLY_WORKFLOW} does: change the data file instead`,
|
|
393
|
+
);
|
|
394
|
+
} else if (known !== op && (known.effect !== op.effect || known.idempotent !== op.idempotent)) {
|
|
372
395
|
problems.push(
|
|
373
396
|
`${op.id} is declared with effect "${op.effect}"${op.idempotent ? " (idempotent)" : ""}, ` +
|
|
374
397
|
`but its connector says "${known.effect}"${known.idempotent ? " (idempotent)" : ""}`,
|
package/src/credentials.ts
CHANGED
|
@@ -1,20 +1,73 @@
|
|
|
1
|
+
import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
|
|
1
2
|
import { setTimeout as sleep } from "node:timers/promises";
|
|
2
3
|
import { Client } from "pg";
|
|
3
4
|
import { credentialsOf } from "./env.ts";
|
|
4
5
|
import { CREDENTIALS_TABLE as TABLE, type Db, prepareDatabase } from "./db.ts";
|
|
5
6
|
import { info, warn } from "./log.ts";
|
|
6
7
|
import type { Driver } from "./op.ts";
|
|
8
|
+
import type { Secrets } from "./secret.ts";
|
|
7
9
|
import { errorMessage } from "./shared.ts";
|
|
8
10
|
|
|
9
11
|
// Where the credentials set from the app live: one table in the config's database, beside DBOS's
|
|
10
12
|
// own (made by `prepareDatabase` in db.ts). A value is read here only, by the worker to load it
|
|
11
|
-
// into its environment and by the client to check it,
|
|
12
|
-
//
|
|
13
|
-
//
|
|
13
|
+
// into its environment or hand it to a driver (`storedSecrets`) and by the client to check it,
|
|
14
|
+
// and leaves none of them. The write (`storeCredential`, `ensureCredential`) and the read
|
|
15
|
+
// (`storedCredentials`) are the only places that touch `value`, which holds it encrypted
|
|
16
|
+
// (`encrypt`) under `SANOMA_SECRETS_KEY`.
|
|
14
17
|
|
|
15
18
|
/** Where a change is announced, with the variable's name as the payload. */
|
|
16
19
|
const CHANNEL = "sanoma_credentials";
|
|
17
20
|
|
|
21
|
+
/** The variable holding the key the stored values are encrypted with: 32 bytes, base64. */
|
|
22
|
+
export const SECRETS_KEY = "SANOMA_SECRETS_KEY";
|
|
23
|
+
|
|
24
|
+
/** What starts an encrypted value; a value without it is a plaintext one stored before encryption, read as it is. */
|
|
25
|
+
const V1 = "v1:";
|
|
26
|
+
const CIPHER = "aes-256-gcm";
|
|
27
|
+
const TAG_BYTES = 16;
|
|
28
|
+
|
|
29
|
+
/** The key from the environment, or an error naming the variable. */
|
|
30
|
+
function secretsKey(): Buffer {
|
|
31
|
+
const text = process.env[SECRETS_KEY];
|
|
32
|
+
if (!text) {
|
|
33
|
+
throw new Error(
|
|
34
|
+
`${SECRETS_KEY} is not set: it encrypts the stored credentials; set it to 32 random bytes, base64 (\`openssl rand -base64 32\`)`,
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
const key = Buffer.from(text, "base64");
|
|
38
|
+
if (key.length !== 32) throw new Error(`${SECRETS_KEY} must be 32 bytes, base64; it is ${key.length} bytes`);
|
|
39
|
+
return key;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* `name`'s value encrypted for the table: `v1:<iv, base64>:<ciphertext and tag, base64>`,
|
|
44
|
+
* AES-256-GCM, with the name as associated data, so a value moved to another row does not decrypt.
|
|
45
|
+
*/
|
|
46
|
+
export function encrypt(name: string, value: string): string {
|
|
47
|
+
const iv = randomBytes(12);
|
|
48
|
+
const cipher = createCipheriv(CIPHER, secretsKey(), iv).setAAD(Buffer.from(name));
|
|
49
|
+
const sealed = Buffer.concat([cipher.update(value, "utf8"), cipher.final(), cipher.getAuthTag()]);
|
|
50
|
+
return `${V1}${iv.toString("base64")}:${sealed.toString("base64")}`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The value `name`'s row holds: decrypted, or a plaintext one as it is (it is encrypted on its next write). */
|
|
54
|
+
export function decrypt(name: string, stored: string): string {
|
|
55
|
+
if (!stored.startsWith(V1)) return stored;
|
|
56
|
+
const key = secretsKey();
|
|
57
|
+
const [iv = "", body = ""] = stored.slice(V1.length).split(":");
|
|
58
|
+
const sealed = Buffer.from(body, "base64");
|
|
59
|
+
try {
|
|
60
|
+
const decipher = createDecipheriv(CIPHER, key, Buffer.from(iv, "base64")).setAAD(Buffer.from(name));
|
|
61
|
+
decipher.setAuthTag(sealed.subarray(-TAG_BYTES));
|
|
62
|
+
return Buffer.concat([decipher.update(sealed.subarray(0, -TAG_BYTES)), decipher.final()]).toString("utf8");
|
|
63
|
+
} catch (err) {
|
|
64
|
+
throw new Error(
|
|
65
|
+
`the stored ${name} cannot be decrypted: ${SECRETS_KEY} is not the key it was stored with, or the row was not stored as ${name}`,
|
|
66
|
+
{ cause: err },
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
18
71
|
/** The stored rows of these names, values included: for loading into an environment or checking, never for showing. */
|
|
19
72
|
export async function storedCredentials(
|
|
20
73
|
db: Db,
|
|
@@ -24,7 +77,9 @@ export async function storedCredentials(
|
|
|
24
77
|
`SELECT name, value, set_by, set_at FROM ${TABLE} WHERE name = ANY($1)`,
|
|
25
78
|
[names],
|
|
26
79
|
);
|
|
27
|
-
return new Map(
|
|
80
|
+
return new Map(
|
|
81
|
+
rows.map((r) => [r.name, { value: decrypt(r.name, r.value), setBy: r.set_by, setAt: r.set_at.toISOString() }]),
|
|
82
|
+
);
|
|
28
83
|
}
|
|
29
84
|
|
|
30
85
|
/** Upserts the value and announces it, in one statement, so a worker never hears of a change it cannot read. */
|
|
@@ -36,10 +91,40 @@ export async function storeCredential(
|
|
|
36
91
|
`WITH stored AS (INSERT INTO ${TABLE} (name, value, set_by) VALUES ($1, $2, $3) ` +
|
|
37
92
|
"ON CONFLICT (name) DO UPDATE SET value = EXCLUDED.value, set_by = EXCLUDED.set_by, set_at = now() " +
|
|
38
93
|
`RETURNING name) SELECT pg_notify('${CHANNEL}', name) FROM stored`,
|
|
39
|
-
[name, value, setBy],
|
|
94
|
+
[name, encrypt(name, value), setBy],
|
|
40
95
|
);
|
|
41
96
|
}
|
|
42
97
|
|
|
98
|
+
/**
|
|
99
|
+
* The stored value of `name`; when there is none, `make()`'s, stored first and announced. Of two
|
|
100
|
+
* at once, the first stored wins, and both return it.
|
|
101
|
+
*/
|
|
102
|
+
export async function ensureCredential(
|
|
103
|
+
db: Db,
|
|
104
|
+
{ name, make, setBy }: { name: string; make: () => string; setBy: string },
|
|
105
|
+
): Promise<string> {
|
|
106
|
+
const stored = (await storedCredentials(db, [name])).get(name);
|
|
107
|
+
if (stored) return stored.value;
|
|
108
|
+
await db.query(
|
|
109
|
+
`WITH stored AS (INSERT INTO ${TABLE} (name, value, set_by) VALUES ($1, $2, $3) ON CONFLICT (name) DO NOTHING ` +
|
|
110
|
+
`RETURNING name) SELECT pg_notify('${CHANNEL}', name) FROM stored`,
|
|
111
|
+
[name, encrypt(name, make()), setBy],
|
|
112
|
+
);
|
|
113
|
+
return (await storedCredentials(db, [name])).get(name)!.value;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* The secrets store a live driver is given (`CallContext.secrets`): a variable the environment
|
|
118
|
+
* sets wins, as it does for a driver's `env`; else the stored value. `ensure` stores what it
|
|
119
|
+
* makes as `setBy`.
|
|
120
|
+
*/
|
|
121
|
+
export function storedSecrets(db: () => Promise<Db>, setBy: string): Secrets {
|
|
122
|
+
return {
|
|
123
|
+
get: async (name) => process.env[name] || (await storedCredentials(await db(), [name])).get(name)?.value,
|
|
124
|
+
ensure: async (name, make) => process.env[name] || ensureCredential(await db(), { name, make, setBy }),
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
43
128
|
/** Deletes the row, announcing it when there was one. */
|
|
44
129
|
export async function deleteCredential(db: Db, name: string): Promise<void> {
|
|
45
130
|
await db.query(
|
package/src/datafile.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { basename, dirname, join } from "node:path";
|
|
2
2
|
import type { Node } from "./ast.ts";
|
|
3
3
|
import { inside, nearestDir, RELATIVE } from "./paths.ts";
|
|
4
|
+
import { type Secret, SECRET_NAME } from "./secret.ts";
|
|
4
5
|
|
|
5
6
|
// The one definition of what a data file may hold, over an ESTree program from oxc: the reader
|
|
6
7
|
// (`resources.ts`), `lintResources` (`lint.ts`) and the oxlint plugin (`plugin.ts`) all
|
|
@@ -22,8 +23,26 @@ export class Reference {
|
|
|
22
23
|
}
|
|
23
24
|
}
|
|
24
25
|
|
|
25
|
-
|
|
26
|
-
|
|
26
|
+
const REFERENCE = Symbol.for("sanoma.reference");
|
|
27
|
+
Object.defineProperty(Reference.prototype, REFERENCE, { value: true });
|
|
28
|
+
|
|
29
|
+
/** True for a `Reference`, made by any copy of this package: read by a registered symbol, not `instanceof`. */
|
|
30
|
+
export const isReference = (value: unknown): value is Reference =>
|
|
31
|
+
typeof value === "object" && value !== null && REFERENCE in value;
|
|
32
|
+
|
|
33
|
+
/** What `secret("NAME")` stands for in a data file: its marker, `{ $secret: "NAME", generate?: true }`. */
|
|
34
|
+
export type SecretRef = Secret;
|
|
35
|
+
|
|
36
|
+
/** A value in a data file: a literal, a reference to a declared resource, or a secret's marker. */
|
|
37
|
+
export type DataValue =
|
|
38
|
+
| null
|
|
39
|
+
| boolean
|
|
40
|
+
| number
|
|
41
|
+
| string
|
|
42
|
+
| Reference
|
|
43
|
+
| SecretRef
|
|
44
|
+
| DataValue[]
|
|
45
|
+
| { [field: string]: DataValue };
|
|
27
46
|
|
|
28
47
|
/** An `import { name } from "…"`: of a connector's constructors (`from "…/resources"`), or of another data file's resource. */
|
|
29
48
|
export interface DataFileImport {
|
|
@@ -61,12 +80,16 @@ export interface DataFile {
|
|
|
61
80
|
/** A connector's constructors entry: a package specifier that ends in `/resources`. */
|
|
62
81
|
const CONSTRUCTORS = /^(@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*\/resources$/;
|
|
63
82
|
|
|
83
|
+
/** Where a data file imports `secret` from; checked before `CONSTRUCTORS`, which they match too. */
|
|
84
|
+
export const SECRET_ENTRIES: ReadonlySet<string> = new Set(["sanoma/resources", "@sanoma-ai/workflows/resources"]);
|
|
85
|
+
const SECRET_CALL = '`secret("NAME")` or `secret("NAME", { generate: true })`, NAME upper case (A-Z, 0-9 and _)';
|
|
86
|
+
|
|
64
87
|
const SHAPE =
|
|
65
88
|
"a data file holds only imports, `export const <name> = <vendor>.<type>({ … })` and `export default [ … ]`";
|
|
66
89
|
const IMPORTS =
|
|
67
90
|
"import resource constructors from a connector's resources entry " +
|
|
68
91
|
'(`import { github } from "@sanoma-ai/connector-github/resources"`) ' +
|
|
69
|
-
'and resources from other data files (`import { site } from "./github.ts"`)';
|
|
92
|
+
'and resources from other data files (`import { site } from "./github.ts"`), and `secret` from "sanoma/resources"';
|
|
70
93
|
const VALUES = "write a string, number, boolean, null, object or array, or name a declared resource";
|
|
71
94
|
|
|
72
95
|
/**
|
|
@@ -94,6 +117,8 @@ export function readDataFile(
|
|
|
94
117
|
const exports: DataFileExport[] = [];
|
|
95
118
|
// Names a refused import or declaration binds: reported once, where they are bound.
|
|
96
119
|
const refused = new Set<string>();
|
|
120
|
+
// The local names of `secret`.
|
|
121
|
+
const secrets = new Set<string>();
|
|
97
122
|
|
|
98
123
|
// Imports and top-level names first, each once: imports are hoisted, and a reference may name
|
|
99
124
|
// a resource declared further down. An unexported const is refused where it is declared, not
|
|
@@ -163,6 +188,21 @@ export function readDataFile(
|
|
|
163
188
|
fail(s, `import "${spec}" is not allowed in a data file: it imports nothing, and a data file runs no code`);
|
|
164
189
|
return;
|
|
165
190
|
}
|
|
191
|
+
if (SECRET_ENTRIES.has(spec)) {
|
|
192
|
+
for (const sp of s.specifiers) {
|
|
193
|
+
if (
|
|
194
|
+
sp.type === "ImportSpecifier" &&
|
|
195
|
+
sp.importKind !== "type" &&
|
|
196
|
+
(sp.imported.name ?? sp.imported.value) === "secret"
|
|
197
|
+
) {
|
|
198
|
+
secrets.add(sp.local.name);
|
|
199
|
+
} else {
|
|
200
|
+
fail(sp, `a data file imports only \`secret\` from "${spec}": \`import { secret } from "${spec}"\``);
|
|
201
|
+
refused.add(sp.local.name);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
166
206
|
for (const sp of s.specifiers) {
|
|
167
207
|
if (sp.type !== "ImportSpecifier") {
|
|
168
208
|
const how = sp.type === "ImportNamespaceSpecifier" ? `import * as ${sp.local.name}` : `import ${sp.local.name}`;
|
|
@@ -332,6 +372,10 @@ export function readDataFile(
|
|
|
332
372
|
);
|
|
333
373
|
}
|
|
334
374
|
case "CallExpression":
|
|
375
|
+
if (node.callee.type === "Identifier" && secrets.has(node.callee.name)) return readSecret(node);
|
|
376
|
+
if (node.callee.type === "Identifier" && node.callee.name === "secret" && !bound.has("secret")) {
|
|
377
|
+
return refuse(node, `import secret to name one: \`import { secret } from "sanoma/resources"\``);
|
|
378
|
+
}
|
|
335
379
|
return refuse(
|
|
336
380
|
node,
|
|
337
381
|
node.callee.type === "MemberExpression" &&
|
|
@@ -369,12 +413,36 @@ export function readDataFile(
|
|
|
369
413
|
return refuse(p, "a method or accessor is not allowed in a data file: a field holds a value");
|
|
370
414
|
}
|
|
371
415
|
if (p.computed) return refuse(p.key, "a computed key is not allowed in a data file: write the field's name");
|
|
372
|
-
const key =
|
|
416
|
+
const key = keyName(p.key);
|
|
373
417
|
if (key === undefined) return refuse(p.key, "write the field's name, or a string");
|
|
418
|
+
if (key === "$secret") return refuse(p.key, `name a secret with ${SECRET_CALL}`);
|
|
374
419
|
const v = value(p.value);
|
|
375
420
|
return v === undefined ? undefined : [key, v];
|
|
376
421
|
}
|
|
377
422
|
|
|
423
|
+
/** A `secret(…)` call's marker: literal arguments only. */
|
|
424
|
+
function readSecret(node: Node): SecretRef | undefined {
|
|
425
|
+
const [name, options, ...extra] = node.arguments;
|
|
426
|
+
if (name?.type !== "Literal" || typeof name.value !== "string" || extra.length) {
|
|
427
|
+
return refuse(node, `write the secret's name as a string: ${SECRET_CALL}`);
|
|
428
|
+
}
|
|
429
|
+
if (!SECRET_NAME.test(name.value))
|
|
430
|
+
return refuse(name, `${JSON.stringify(name.value)} is not a secret's name: ${SECRET_CALL}`);
|
|
431
|
+
if (options === undefined) return { $secret: name.value };
|
|
432
|
+
const [generate, ...others] = options.type === "ObjectExpression" ? options.properties : [];
|
|
433
|
+
if (
|
|
434
|
+
others.length ||
|
|
435
|
+
generate?.type !== "Property" ||
|
|
436
|
+
generate.computed ||
|
|
437
|
+
(generate.key.name ?? generate.key.value) !== "generate" ||
|
|
438
|
+
generate.value.type !== "Literal" ||
|
|
439
|
+
typeof generate.value.value !== "boolean"
|
|
440
|
+
) {
|
|
441
|
+
return refuse(options, `a secret's options are \`{ generate: true }\` or none: ${SECRET_CALL}`);
|
|
442
|
+
}
|
|
443
|
+
return generate.value.value ? { $secret: name.value, generate: true } : { $secret: name.value };
|
|
444
|
+
}
|
|
445
|
+
|
|
378
446
|
function reference(node: Node): Reference | undefined {
|
|
379
447
|
const name: string = node.name;
|
|
380
448
|
if (name === "undefined")
|
|
@@ -399,6 +467,10 @@ export function readDataFile(
|
|
|
399
467
|
return { imports, exports, declared: [...declared], problems };
|
|
400
468
|
}
|
|
401
469
|
|
|
470
|
+
/** A property's name: an identifier's, or a literal's as a string; undefined for any other key. */
|
|
471
|
+
export const keyName = (key: Node): string | undefined =>
|
|
472
|
+
key.type === "Identifier" ? key.name : key.type === "Literal" ? String(key.value) : undefined;
|
|
473
|
+
|
|
402
474
|
/** A node's kind, for a message: `a function declaration`, `an if statement`. */
|
|
403
475
|
function describe(node: Node): string {
|
|
404
476
|
const words = String(node.type)
|