@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/apply.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { ConnectionRef } from "./connection.ts";
|
|
1
2
|
import { z } from "zod";
|
|
2
3
|
import { type Approver, defineWorkflow, type WorkflowDefinition } from "./define.ts";
|
|
3
4
|
import {
|
|
@@ -63,7 +64,7 @@ export type ApplyInput = z.infer<typeof ApplyInput>;
|
|
|
63
64
|
* object its identity `found` or the input `given`, ask about a `gone` object or an `orphan`ed
|
|
64
65
|
* binding (unless `answer`ed), or `none`.
|
|
65
66
|
*/
|
|
66
|
-
export type PlanStep = { id: string; vendor: string; type: string } & (
|
|
67
|
+
export type PlanStep = { id: string; vendor: string; type: string; connection?: ConnectionRef } & (
|
|
67
68
|
| { action: "create"; planned: Record<string, unknown>; unknown: string[] }
|
|
68
69
|
| { action: "update" | "replace"; binding: string; fields: DriftField[]; replace: string[] }
|
|
69
70
|
| { action: "bind"; binding: string; source: "found" | "given" }
|
|
@@ -114,6 +115,7 @@ export interface ApplyDeclared extends DriftDeclared {
|
|
|
114
115
|
export interface PlannedCall {
|
|
115
116
|
op: string;
|
|
116
117
|
input: unknown;
|
|
118
|
+
connection?: ConnectionRef;
|
|
117
119
|
}
|
|
118
120
|
|
|
119
121
|
/** What apply does that is no vendor call, each a step of its own (`apply-steps.ts`). */
|
|
@@ -155,7 +157,7 @@ type DeleteOp = Op<string, string, "delete", { id: string }, { id: string; gone:
|
|
|
155
157
|
type ApplyUse = ImportOp | PlanOp | CreateOp | UpdateOp | DeleteOp | ResolveOp | "approval" | "sleep";
|
|
156
158
|
|
|
157
159
|
/** An operation as `ctx` gives it, called by its id. */
|
|
158
|
-
type Call = (input: unknown) => Promise<unknown>;
|
|
160
|
+
type Call = (input: unknown, options?: { connection?: ConnectionRef }) => Promise<unknown>;
|
|
159
161
|
|
|
160
162
|
/** What a step's resource is called in a problem: its file, and its export. */
|
|
161
163
|
const problemOf = (id: string, message: string): ResourceProblem => ({
|
|
@@ -369,13 +371,24 @@ function writesOf(
|
|
|
369
371
|
const made = { id: `new:${at}`, export: exportOf(keyOf(step)) };
|
|
370
372
|
const after = (types.get(at)?.afterCreate ?? [])
|
|
371
373
|
.filter((hook) => ops.get(hook.op)?.effect === "write")
|
|
372
|
-
.map((hook) => ({
|
|
373
|
-
|
|
374
|
+
.map((hook) => ({
|
|
375
|
+
op: hook.op,
|
|
376
|
+
input: hook.input(made),
|
|
377
|
+
...(hook.op.startsWith(`${step.vendor}.`) && step.connection && { connection: step.connection }),
|
|
378
|
+
}));
|
|
379
|
+
return [{ op: `${at}.create`, connection: step.connection, input: { desired: desired.get(step.id) } }, ...after];
|
|
374
380
|
}
|
|
375
|
-
if (run === "delete" && "binding" in step)
|
|
381
|
+
if (run === "delete" && "binding" in step)
|
|
382
|
+
return [{ op: `${at}.delete`, connection: step.connection, input: { id: step.binding } }];
|
|
376
383
|
if (run !== "update" || (step.action !== "update" && step.action !== "replace")) return [];
|
|
377
384
|
const expect = { action: step.action, replace: step.replace };
|
|
378
|
-
return [
|
|
385
|
+
return [
|
|
386
|
+
{
|
|
387
|
+
op: `${at}.update`,
|
|
388
|
+
connection: step.connection,
|
|
389
|
+
input: { id: step.binding, desired: desired.get(step.id), expect },
|
|
390
|
+
},
|
|
391
|
+
];
|
|
379
392
|
});
|
|
380
393
|
}
|
|
381
394
|
|
|
@@ -474,7 +487,12 @@ export function applyWorkflow(
|
|
|
474
487
|
if (!("id" in by)) found.set(resource.id, by);
|
|
475
488
|
else {
|
|
476
489
|
try {
|
|
477
|
-
found.set(
|
|
490
|
+
found.set(
|
|
491
|
+
resource.id,
|
|
492
|
+
observed(
|
|
493
|
+
await ctx[resource.vendor]![resource.type]!.import({ id: by.id }, { connection: resource.connection }),
|
|
494
|
+
),
|
|
495
|
+
);
|
|
478
496
|
} catch (err) {
|
|
479
497
|
if (isRunControlError(err)) throw err;
|
|
480
498
|
found.set(resource.id, { error: errorMessage(err) });
|
|
@@ -529,7 +547,11 @@ export function applyWorkflow(
|
|
|
529
547
|
: decide(resource, result, found.get(resource.id)!, input, desired.get(resource.id)!, types, usable);
|
|
530
548
|
if (next.ask !== undefined) {
|
|
531
549
|
try {
|
|
532
|
-
const answer = fromPlan(
|
|
550
|
+
const answer = fromPlan(
|
|
551
|
+
resource,
|
|
552
|
+
result,
|
|
553
|
+
await ctx[resource.vendor]![resource.type]!.plan(next.ask, { connection: resource.connection }),
|
|
554
|
+
);
|
|
533
555
|
if (answer.step) planned.push(answer.step);
|
|
534
556
|
if (answer.problem) problems.push(answer.problem);
|
|
535
557
|
} catch (err) {
|
|
@@ -547,6 +569,8 @@ export function applyWorkflow(
|
|
|
547
569
|
steps: [...planned, ...orphaned.steps],
|
|
548
570
|
};
|
|
549
571
|
|
|
572
|
+
for (const step of plan.steps)
|
|
573
|
+
step.connection = declarations.get(step.id)?.connection ?? locked[keyOf(step)]?.connection;
|
|
550
574
|
const writes = writesOf(plan, desired, types, ops);
|
|
551
575
|
const approver = writes.length ? await steps.holds(writes) : undefined;
|
|
552
576
|
if (approver !== undefined) {
|
|
@@ -569,24 +593,31 @@ export function applyWorkflow(
|
|
|
569
593
|
let state: State | undefined;
|
|
570
594
|
try {
|
|
571
595
|
if (todo === "create") {
|
|
572
|
-
const made = await ctx[step.vendor]![step.type]!.create(
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
596
|
+
const made = await ctx[step.vendor]![step.type]!.create(
|
|
597
|
+
{
|
|
598
|
+
desired: desiredOf(declared!, types, bindings, hosts, urls),
|
|
599
|
+
},
|
|
600
|
+
{ connection: step.connection },
|
|
601
|
+
);
|
|
602
|
+
bindings[key] = { type, id: made.id, ...(step.connection && { connection: step.connection }) };
|
|
576
603
|
state = made.state;
|
|
577
604
|
} else if (todo === "update" && (step.action === "update" || step.action === "replace")) {
|
|
578
605
|
const expect = { action: step.action, replace: step.replace };
|
|
579
|
-
const changed = await ctx[step.vendor]![step.type]!.update(
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
606
|
+
const changed = await ctx[step.vendor]![step.type]!.update(
|
|
607
|
+
{
|
|
608
|
+
id: binding,
|
|
609
|
+
desired: desiredOf(declared!, types, bindings, hosts, urls),
|
|
610
|
+
expect,
|
|
611
|
+
},
|
|
612
|
+
{ connection: step.connection },
|
|
613
|
+
);
|
|
614
|
+
bindings[key] = { type, id: changed.id, ...(step.connection && { connection: step.connection }) };
|
|
585
615
|
state = changed.state;
|
|
586
616
|
} else if (todo === "delete") {
|
|
587
|
-
await ctx[step.vendor]![step.type]!.delete({ id: binding });
|
|
617
|
+
await ctx[step.vendor]![step.type]!.delete({ id: binding }, { connection: step.connection });
|
|
588
618
|
delete bindings[key];
|
|
589
|
-
} else if (todo === "bind")
|
|
619
|
+
} else if (todo === "bind")
|
|
620
|
+
bindings[key] = { type, id: binding, ...(step.connection && { connection: step.connection }) };
|
|
590
621
|
else if (todo === "forget") delete bindings[key];
|
|
591
622
|
if (state && declared) answersAt(declared, state);
|
|
592
623
|
const after = bindings[key];
|
|
@@ -615,7 +646,9 @@ export function applyWorkflow(
|
|
|
615
646
|
) {
|
|
616
647
|
if (call > 0) await ctx.sleep({ seconds: AFTER_CREATE_SECONDS });
|
|
617
648
|
try {
|
|
618
|
-
output = await (ctx[vendor]![group] as unknown as Record<string, Call>)[name]!(given
|
|
649
|
+
output = await (ctx[vendor]![group] as unknown as Record<string, Call>)[name]!(given, {
|
|
650
|
+
connection: vendor === step.vendor ? step.connection : undefined,
|
|
651
|
+
});
|
|
619
652
|
failed = undefined;
|
|
620
653
|
} catch (err) {
|
|
621
654
|
if (isRunControlError(err) || !hook.until) throw err;
|
package/src/approvals.ts
CHANGED
|
@@ -82,6 +82,7 @@ export interface HeldCall {
|
|
|
82
82
|
op: string;
|
|
83
83
|
seq?: number;
|
|
84
84
|
input: unknown;
|
|
85
|
+
connection?: string;
|
|
85
86
|
reasons?: string[] | undefined;
|
|
86
87
|
}
|
|
87
88
|
|
|
@@ -109,7 +110,12 @@ export async function awaitApproval(
|
|
|
109
110
|
...(req.details === undefined ? {} : { details: req.details }),
|
|
110
111
|
requestedBy: held ? "policy" : "workflow",
|
|
111
112
|
covers,
|
|
112
|
-
...(held && {
|
|
113
|
+
...(held && {
|
|
114
|
+
op: held.op,
|
|
115
|
+
...heldOf(held),
|
|
116
|
+
input: held.input,
|
|
117
|
+
...(held.connection && { connection: held.connection }),
|
|
118
|
+
}),
|
|
113
119
|
status: "pending",
|
|
114
120
|
requestedAt: 0,
|
|
115
121
|
refused: [],
|
package/src/apps.ts
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import { existsSync, readdirSync } from "node:fs";
|
|
2
|
-
import { dirname, join,
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
2
|
+
import { dirname, join, resolve } from "node:path";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { childrenOf, type Node, readSource } from "./ast.ts";
|
|
5
|
+
import type { AppEntry } from "./description.ts";
|
|
6
|
+
import { exportsOf, type Loaded, loadModules } from "./load.ts";
|
|
7
|
+
import { stringValue } from "./outline.ts";
|
|
8
|
+
import { APPS_DIR, filesUnder, inside, isDirectory, RELATIVE, relPath } from "./paths.ts";
|
|
9
|
+
import type { ResourceProblem } from "./resources.ts";
|
|
7
10
|
import { APP_NAME } from "./shared.ts";
|
|
8
11
|
|
|
9
12
|
// A company's apps: each folder of `apps/` holds the kinds the root does (workflows, resources,
|
|
@@ -11,108 +14,74 @@ import { APP_NAME } from "./shared.ts";
|
|
|
11
14
|
// made here. What a folder declares is named with the folder (`qualified`): never written, read
|
|
12
15
|
// from where the declaration's file is (`appOfFile`).
|
|
13
16
|
|
|
14
|
-
|
|
15
|
-
export interface AppEntry {
|
|
16
|
-
/** The folder's name, which prefixes what it declares (`crm.contacts`) and is the app's title. */
|
|
17
|
-
name: string;
|
|
18
|
-
/** The folder, relative to the root, with `/`: `apps/crm`. */
|
|
19
|
-
dir: string;
|
|
20
|
-
/** `defineApp({ source })` in its `app.ts`: the repository it was taken from; absent for the company's own app. */
|
|
21
|
-
source?: string;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
/** What `app.ts` declares: the source repository's URL, when the app was taken from one. */
|
|
25
|
-
export interface AppDefinition {
|
|
26
|
-
source?: string;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
/**
|
|
30
|
-
* Declares an app in its folder's `app.ts`, read without running it: `defineApp({ source:
|
|
31
|
-
* "https://github.com/acme/crm" })`, the repository it was taken from; `defineApp({})` for an app
|
|
32
|
-
* the company made itself.
|
|
33
|
-
*/
|
|
34
|
-
export function defineApp(definition: AppDefinition): AppDefinition {
|
|
35
|
-
return Object.freeze({ ...definition });
|
|
36
|
-
}
|
|
17
|
+
export type { AppEntry };
|
|
37
18
|
|
|
38
19
|
/** What is wrong with a company's apps, at a file relative to the root. */
|
|
39
|
-
export type AppProblem =
|
|
20
|
+
export type AppProblem = ResourceProblem & { file: string };
|
|
40
21
|
|
|
41
22
|
/** The apps under `root` (`appsAt`), without what is wrong with them. */
|
|
42
23
|
export const readApps = (root: string | undefined): AppEntry[] => appsAt(root).apps;
|
|
43
24
|
|
|
44
25
|
/**
|
|
45
26
|
* The apps under `root`: each folder of `apps/` whose name is an `APP_NAME`, sorted, with what is
|
|
46
|
-
* wrong in its `app.ts` and each folder refused for its name as `problems`. `app.ts` is
|
|
47
|
-
*
|
|
48
|
-
*
|
|
27
|
+
* wrong in its `app.ts` and each folder refused for its name as `problems`. Each `app.ts` is
|
|
28
|
+
* loaded (`loadModules`, `defineApp` served from `sanoma`): its default export is
|
|
29
|
+
* `defineApp({ source })`, `source` a string when given.
|
|
49
30
|
*/
|
|
50
31
|
export function appsAt(root: string | undefined): { apps: AppEntry[]; problems: AppProblem[] } {
|
|
51
|
-
|
|
52
|
-
|
|
32
|
+
if (root === undefined) return { apps: [], problems: [] };
|
|
33
|
+
const dir = join(root, APPS_DIR);
|
|
34
|
+
if (!isDirectory(dir)) return { apps: [], problems: [] };
|
|
53
35
|
const apps: AppEntry[] = [];
|
|
54
36
|
const problems: AppProblem[] = [];
|
|
55
37
|
const folders = readdirSync(dir, { withFileTypes: true })
|
|
56
38
|
.filter((d) => d.isDirectory())
|
|
57
39
|
.map((d) => d.name)
|
|
58
40
|
.toSorted();
|
|
59
|
-
|
|
41
|
+
const named = folders.filter((folder) => APP_NAME.test(folder));
|
|
42
|
+
for (const name of folders.filter((folder) => !APP_NAME.test(folder))) {
|
|
60
43
|
const at = `${APPS_DIR}/${name}`;
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
44
|
+
problems.push({
|
|
45
|
+
file: at,
|
|
46
|
+
line: 1,
|
|
47
|
+
column: 1,
|
|
48
|
+
message: `${at} is not an app: an app's folder name is a lowercase letter, then lowercase letters and digits`,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
const files = named.map((name) => join(dir, name, "app.ts")).filter((path) => existsSync(path));
|
|
52
|
+
const loaded = files.length ? loadModules(root, files, []) : new Map<string, Loaded>();
|
|
53
|
+
for (const name of named) {
|
|
54
|
+
const file = `${APPS_DIR}/${name}/app.ts`;
|
|
55
|
+
const read = appDefinitionOf(file, loaded);
|
|
56
|
+
problems.push(...read.problems);
|
|
57
|
+
apps.push({
|
|
58
|
+
name,
|
|
59
|
+
dir: `${APPS_DIR}/${name}`,
|
|
60
|
+
...(read.source !== undefined && { source: read.source }),
|
|
61
|
+
...(loaded.has(file) && { at: { file, export: "default" } }),
|
|
62
|
+
});
|
|
73
63
|
}
|
|
64
|
+
problems.sort((a, b) => a.file.localeCompare(b.file));
|
|
74
65
|
return { apps, problems };
|
|
75
66
|
}
|
|
76
67
|
|
|
77
|
-
/**
|
|
78
|
-
const
|
|
68
|
+
/** What `app.ts`'s default export, `defineApp({ … })`, may say. */
|
|
69
|
+
const AppDefinitionSchema = z.strictObject({ source: z.string().optional() });
|
|
79
70
|
|
|
80
|
-
/** `app.ts`'s `source`,
|
|
81
|
-
function
|
|
82
|
-
|
|
83
|
-
const
|
|
84
|
-
if (
|
|
85
|
-
|
|
86
|
-
const
|
|
87
|
-
const
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
const options = unwrap(call?.arguments?.[0]);
|
|
93
|
-
const defineApps = new Set<string>();
|
|
94
|
-
for (const statement of file.program.body) {
|
|
95
|
-
if (statement.type !== "ImportDeclaration" || !APP_SOURCES.has(statement.source.value)) continue;
|
|
96
|
-
for (const s of statement.specifiers) {
|
|
97
|
-
if (s.type === "ImportSpecifier" && keyName(s.imported) === "defineApp") defineApps.add(s.local.name);
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
const written =
|
|
101
|
-
call?.type === "CallExpression" &&
|
|
102
|
-
callee?.type === "Identifier" &&
|
|
103
|
-
defineApps.has(callee.name) &&
|
|
104
|
-
(options === undefined || options.type === "ObjectExpression");
|
|
105
|
-
if (!written) {
|
|
106
|
-
problem(exported ?? file.program, unread);
|
|
107
|
-
return { problems };
|
|
71
|
+
/** `app.ts`'s `source`, as loading it gave, and what is wrong there; nothing for no file. */
|
|
72
|
+
function appDefinitionOf(file: string, answer: Map<string, Loaded>): { source?: string; problems: AppProblem[] } {
|
|
73
|
+
// A helper it imports threw: said at the helper.
|
|
74
|
+
const loaded = exportsOf(answer, file);
|
|
75
|
+
if (loaded === undefined) return { problems: [] };
|
|
76
|
+
if ("problem" in loaded) return { problems: [loaded.problem] };
|
|
77
|
+
const value = loaded.exports.find(([name]) => name === "default")?.[1];
|
|
78
|
+
const parsed = AppDefinitionSchema.safeParse(value);
|
|
79
|
+
if (!parsed.success) {
|
|
80
|
+
const why = z.prettifyError(parsed.error).replaceAll(/\s*\n\s*/g, " ");
|
|
81
|
+
const message = `its default export is \`defineApp({ source })\` from "sanoma", \`source\` a string: ${why}`;
|
|
82
|
+
return { problems: [{ file, export: "default", message }] };
|
|
108
83
|
}
|
|
109
|
-
|
|
110
|
-
(p: Node) => p.type === "Property" && !p.computed && keyName(p.key) === "source",
|
|
111
|
-
);
|
|
112
|
-
const source = property && stringValue(property.value);
|
|
113
|
-
if (property && source === undefined)
|
|
114
|
-
problem(property, "`source` is read without running app.ts: write it as a string");
|
|
115
|
-
return { ...(source !== undefined && { source }), problems };
|
|
84
|
+
return { ...(parsed.data.source !== undefined && { source: parsed.data.source }), problems: [] };
|
|
116
85
|
}
|
|
117
86
|
|
|
118
87
|
/** A source file under an app: `.ts` or `.tsx`, but a declaration. */
|
|
@@ -131,7 +100,7 @@ export function crossAppImports(root: string | undefined, apps: readonly AppEntr
|
|
|
131
100
|
for (const path of filesUnder(own, isSource)) {
|
|
132
101
|
const file = readSource(path);
|
|
133
102
|
if (typeof file === "string") continue;
|
|
134
|
-
const rel =
|
|
103
|
+
const rel = relPath(root, path);
|
|
135
104
|
const visit = (node: Node) => {
|
|
136
105
|
if (!node) return;
|
|
137
106
|
const specifier =
|
package/src/ast.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
2
|
import { parseSync, visitorKeys } from "oxc-parser";
|
|
3
|
+
import type { LintProblem } from "./description.ts";
|
|
3
4
|
import { errorMessage } from "./shared.ts";
|
|
4
5
|
|
|
5
6
|
// What the lints, the outline and the data-file reader share: one way to parse a file, walk it
|
|
@@ -30,6 +31,10 @@ const CODE_KEYS = new Map(
|
|
|
30
31
|
Object.entries(visitorKeys).map(([type, keys]) => [type, keys.filter((key) => !TYPE_KEYS.has(key))]),
|
|
31
32
|
);
|
|
32
33
|
|
|
34
|
+
/** A property's name: an identifier's, or a literal's as a string; undefined for any other key. */
|
|
35
|
+
export const keyName = (key: Node): string | undefined =>
|
|
36
|
+
key.type === "Identifier" ? key.name : key.type === "Literal" ? String(key.value) : undefined;
|
|
37
|
+
|
|
33
38
|
/** A node's children in source order, by oxc-parser's `visitorKeys`, without its types. A hole in a list is `null`. */
|
|
34
39
|
export const childrenOf = (node: Node): Node[] => (CODE_KEYS.get(node.type) ?? []).flatMap((key) => node[key] ?? []);
|
|
35
40
|
|
|
@@ -68,12 +73,7 @@ export function offsetOf(lineStarts: readonly number[], line: number, column: nu
|
|
|
68
73
|
return offset < next - 1 ? offset : undefined;
|
|
69
74
|
}
|
|
70
75
|
|
|
71
|
-
|
|
72
|
-
export interface LintProblem {
|
|
73
|
-
line: number;
|
|
74
|
-
column: number;
|
|
75
|
-
message: string;
|
|
76
|
-
}
|
|
76
|
+
export type { LintProblem };
|
|
77
77
|
|
|
78
78
|
/** A file parsed, its syntax errors as problems, and offsets as lines and columns from 1. */
|
|
79
79
|
export function parsed(source: string, filename: string) {
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { z } from "zod";
|
|
2
|
+
import { SecretStoreError, type SecretStoreCapabilities } from "./secret-store.ts";
|
|
3
|
+
|
|
4
|
+
/** Credential schemas and lifecycle are owned by authentication, never by the store provider. */
|
|
5
|
+
export type Authentication<S extends z.ZodType = z.ZodType> =
|
|
6
|
+
| {
|
|
7
|
+
kind: "api-key" | "fields" | "oauth2" | "signed";
|
|
8
|
+
version: number;
|
|
9
|
+
schema: S;
|
|
10
|
+
storage: { writable: boolean; maxBytes: number; conditionalWrites: boolean };
|
|
11
|
+
}
|
|
12
|
+
| { kind: "workload-identity"; storage?: never; version?: never; schema?: never };
|
|
13
|
+
|
|
14
|
+
export function defineAuthentication<S extends z.ZodType>(definition: Authentication<S>): Authentication<S> {
|
|
15
|
+
if (definition.kind === "workload-identity") {
|
|
16
|
+
if (definition.storage !== undefined || definition.schema !== undefined || definition.version !== undefined)
|
|
17
|
+
throw new SecretStoreError("invalid_configuration");
|
|
18
|
+
return Object.freeze({ kind: "workload-identity" });
|
|
19
|
+
}
|
|
20
|
+
if (
|
|
21
|
+
!["api-key", "fields", "oauth2", "signed"].includes(definition.kind) ||
|
|
22
|
+
!Number.isSafeInteger(definition.version) ||
|
|
23
|
+
definition.version < 1 ||
|
|
24
|
+
typeof definition.schema?.safeParse !== "function" ||
|
|
25
|
+
!definition.storage ||
|
|
26
|
+
!Number.isSafeInteger(definition.storage.maxBytes) ||
|
|
27
|
+
definition.storage.maxBytes < 1 ||
|
|
28
|
+
typeof definition.storage.writable !== "boolean" ||
|
|
29
|
+
typeof definition.storage.conditionalWrites !== "boolean"
|
|
30
|
+
) {
|
|
31
|
+
throw new SecretStoreError("invalid_configuration");
|
|
32
|
+
}
|
|
33
|
+
return Object.freeze({ ...definition, storage: Object.freeze({ ...definition.storage }) });
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Compatibility check before consent, including externally renewed (read-only) OAuth bundles. */
|
|
37
|
+
export function checkAuthenticationStore(
|
|
38
|
+
auth: Authentication,
|
|
39
|
+
store: SecretStoreCapabilities,
|
|
40
|
+
management: "sanoma" | "external",
|
|
41
|
+
): void {
|
|
42
|
+
if (auth.kind === "workload-identity") return;
|
|
43
|
+
if (auth.storage.maxBytes > store.maxBytes) throw new SecretStoreError("too_large");
|
|
44
|
+
if (auth.storage.writable && (management === "external" || !store.writable)) throw new SecretStoreError("read_only");
|
|
45
|
+
if (auth.storage.conditionalWrites && store.conditionalWrites === "none") throw new SecretStoreError("unsupported");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Serialized only at the trusted store boundary, never returned by an operation to a workflow. */
|
|
49
|
+
export function encodeCredentials<S extends z.ZodType>(auth: Authentication<S>, value: z.input<S>): string {
|
|
50
|
+
if (auth.kind === "workload-identity") throw new SecretStoreError("unsupported");
|
|
51
|
+
try {
|
|
52
|
+
const parsed = auth.schema.safeParse(value);
|
|
53
|
+
if (!parsed.success) throw new SecretStoreError("invalid_value");
|
|
54
|
+
const encoded = JSON.stringify({ version: auth.version, value: parsed.data });
|
|
55
|
+
if (Buffer.byteLength(encoded) > auth.storage.maxBytes) throw new SecretStoreError("too_large");
|
|
56
|
+
return encoded;
|
|
57
|
+
} catch (error) {
|
|
58
|
+
if (error instanceof SecretStoreError) throw new SecretStoreError(error.code);
|
|
59
|
+
throw new SecretStoreError("invalid_value");
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function decodeCredentials<S extends z.ZodType>(auth: Authentication<S>, encoded: string): z.output<S> {
|
|
64
|
+
if (auth.kind === "workload-identity") throw new SecretStoreError("unsupported");
|
|
65
|
+
try {
|
|
66
|
+
if (Buffer.byteLength(encoded) > auth.storage.maxBytes) throw new SecretStoreError("too_large");
|
|
67
|
+
const bundle = JSON.parse(encoded);
|
|
68
|
+
if (bundle?.version !== auth.version) throw new SecretStoreError("invalid_value");
|
|
69
|
+
return auth.schema.parse(bundle.value);
|
|
70
|
+
} catch (error) {
|
|
71
|
+
if (error instanceof SecretStoreError) throw new SecretStoreError(error.code);
|
|
72
|
+
throw new SecretStoreError("invalid_value");
|
|
73
|
+
}
|
|
74
|
+
}
|
package/src/call-workflow.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { connectionRefSchema, type ConnectionRef } from "./connection.ts";
|
|
1
2
|
import { z } from "zod";
|
|
2
3
|
import { defineWorkflow, type WorkflowDefinition } from "./define.ts";
|
|
3
4
|
import { isDirect } from "./direct.ts";
|
|
@@ -12,10 +13,10 @@ export { CALL_WORKFLOW } from "./shared.ts";
|
|
|
12
13
|
// held to the same lint: the call goes through `ctx`, under the policy, in the ledger.
|
|
13
14
|
|
|
14
15
|
/** An operation on `ctx`. */
|
|
15
|
-
type OpCall = (input: unknown) => Promise<unknown>;
|
|
16
|
+
type OpCall = (input: unknown, options?: { connection?: ConnectionRef }) => Promise<unknown>;
|
|
16
17
|
|
|
17
18
|
/** The call workflow's input: the operation's id, and its input. */
|
|
18
|
-
export const CallInput = z.object({ op: z.string(), input: z.unknown() });
|
|
19
|
+
export const CallInput = z.object({ op: z.string(), input: z.unknown(), connection: connectionRefSchema.optional() });
|
|
19
20
|
|
|
20
21
|
/**
|
|
21
22
|
* The call workflow for a config: it `uses` every operation that may be called directly and has
|
|
@@ -43,10 +44,10 @@ export function callWorkflow(
|
|
|
43
44
|
// Four segments for an app's collection: `data.<app>.<collection>.<op>`.
|
|
44
45
|
const [vendor = "", a = "", b = "", c] = input.op.split(".");
|
|
45
46
|
return c === undefined
|
|
46
|
-
? ctx[vendor]![a]![b]!(input.input)
|
|
47
|
+
? ctx[vendor]![a]![b]!(input.input, { connection: input.connection })
|
|
47
48
|
: (ctx as unknown as Record<string, Record<string, Record<string, Record<string, OpCall>>>>)[vendor]![a]![b]![
|
|
48
49
|
c
|
|
49
|
-
]!(input.input);
|
|
50
|
+
]!(input.input, { connection: input.connection });
|
|
50
51
|
},
|
|
51
52
|
});
|
|
52
53
|
}
|
package/src/call.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { ConnectionRef, ConnectionSnapshot } from "./connection.ts";
|
|
1
2
|
import { DBOS, DBOSWorkflowConflictError, Error as DBOSErrors } from "@dbos-inc/dbos-sdk";
|
|
2
3
|
import { awaitApproval, type CheckedApproval } from "./approvals.ts";
|
|
3
4
|
import { offsetOf } from "./ast.ts";
|
|
@@ -161,8 +162,10 @@ export function buildCtx(wf: WorkflowDefinition<any, any>, run: Run): any {
|
|
|
161
162
|
.split(".")
|
|
162
163
|
.slice(0, -1)
|
|
163
164
|
.reduce<Record<string, any>>((at, key) => (at[key] ??= {}), members);
|
|
164
|
-
group[op.name] = placed(op.id, (node, input) =>
|
|
165
|
-
serial(run, () =>
|
|
165
|
+
group[op.name] = placed(op.id, (node, input, options?: { connection?: ConnectionRef }) =>
|
|
166
|
+
serial(run, () =>
|
|
167
|
+
callOp(run, op.id, input, { node: node.path, name: named(node) ?? op.id }, options?.connection),
|
|
168
|
+
),
|
|
166
169
|
);
|
|
167
170
|
}
|
|
168
171
|
members.runId = run.id;
|
|
@@ -284,8 +287,19 @@ function targetOf(op: Op, input: unknown): { target?: string } {
|
|
|
284
287
|
* the running instance's people): while it holds or denies, the company's policy is not asked.
|
|
285
288
|
* Runs inside a step, so it must have no side effects of its own.
|
|
286
289
|
*/
|
|
287
|
-
export async function decide(
|
|
288
|
-
|
|
290
|
+
export async function decide(
|
|
291
|
+
run: Run,
|
|
292
|
+
op: Op,
|
|
293
|
+
input: unknown,
|
|
294
|
+
connection?: ConnectionSnapshot,
|
|
295
|
+
): Promise<RecordedDecision> {
|
|
296
|
+
const seen = {
|
|
297
|
+
id: run.id,
|
|
298
|
+
workflow: run.workflow,
|
|
299
|
+
approvals: structuredClone(run.approvals).filter(
|
|
300
|
+
(a) => a.requestedBy !== "policy" || a.connection === connection?.revision,
|
|
301
|
+
),
|
|
302
|
+
};
|
|
289
303
|
// Only a merge is the rule's: no other call reads the people's files.
|
|
290
304
|
const held =
|
|
291
305
|
op.id === CHANGE_MERGE ? runtimeHold(op.id, input, seen.approvals, readPeople(run.state.root).people) : undefined;
|
|
@@ -298,7 +312,7 @@ export async function decide(run: Run, op: Op, input: unknown): Promise<Recorded
|
|
|
298
312
|
{ idempotencyKey: `${run.id}:${run.seq}:before`, runId: run.id, opId: get, attempt: 1, secrets: secretsOf(run) },
|
|
299
313
|
),
|
|
300
314
|
);
|
|
301
|
-
const call = { ...callOf(op, input, run.actor, seen), ...before };
|
|
315
|
+
const call = { ...callOf(op, input, run.actor, seen), ...before, ...(connection && { connection }) };
|
|
302
316
|
return ask(policy, op.id, () => policy(call), run.state);
|
|
303
317
|
}
|
|
304
318
|
|
|
@@ -357,7 +371,7 @@ export function decideCall(
|
|
|
357
371
|
input: unknown,
|
|
358
372
|
actor: Principal,
|
|
359
373
|
run: PolicyCall["run"],
|
|
360
|
-
before: Pick<PolicyCall, "before"> = {},
|
|
374
|
+
before: Pick<PolicyCall, "before" | "connection"> = {},
|
|
361
375
|
): RecordedDecision {
|
|
362
376
|
const call = { ...callOf(op, input, actor, run), ...before };
|
|
363
377
|
return ask(policy, op.id, () => policy(call));
|
|
@@ -411,7 +425,7 @@ function ask(
|
|
|
411
425
|
return policy.version === undefined ? decision : { ...decision, policyVersion: policy.version };
|
|
412
426
|
}
|
|
413
427
|
|
|
414
|
-
async function callOp(run: Run, id: string, input: unknown, place: Placed) {
|
|
428
|
+
async function callOp(run: Run, id: string, input: unknown, place: Placed, ref?: ConnectionRef) {
|
|
415
429
|
// The worker's declaration, never the workflow's: its effect, schemas and retry setting.
|
|
416
430
|
const op = run.state.ops.get(id);
|
|
417
431
|
// A sandbox run calls its own fakes, never a vendor.
|
|
@@ -441,10 +455,36 @@ async function callOp(run: Run, id: string, input: unknown, place: Placed) {
|
|
|
441
455
|
// The decision is recorded as a step, so a replay reuses it instead of asking a policy
|
|
442
456
|
// that may have changed since. The policy must still be deterministic: a run that fails
|
|
443
457
|
// before the step is recorded asks again.
|
|
444
|
-
const
|
|
458
|
+
const connection = await DBOS.runStep(
|
|
459
|
+
async () => {
|
|
460
|
+
if (run.sandbox !== undefined) {
|
|
461
|
+
if (ref)
|
|
462
|
+
throw new SanomaError("invalid_input", "Named sandbox connections require explicit fake account state");
|
|
463
|
+
return undefined;
|
|
464
|
+
}
|
|
465
|
+
return run.state.connections?.select(op.vendor, ref, run.actor.id);
|
|
466
|
+
},
|
|
467
|
+
{ name: `connection:${op.id}` },
|
|
468
|
+
);
|
|
469
|
+
const decideStep = () =>
|
|
470
|
+
DBOS.runStep(
|
|
471
|
+
async () => {
|
|
472
|
+
await run.state.connections?.assertCurrent(connection, run.actor.id);
|
|
473
|
+
return decide(run, op, parsed, connection);
|
|
474
|
+
},
|
|
475
|
+
{ name: `policy:${op.id}` },
|
|
476
|
+
);
|
|
445
477
|
let decision = await decideStep();
|
|
446
478
|
const logged = recorded(op, parsed);
|
|
447
|
-
const call = {
|
|
479
|
+
const call = {
|
|
480
|
+
type: "op.called",
|
|
481
|
+
op: op.id,
|
|
482
|
+
...place,
|
|
483
|
+
effect: op.effect,
|
|
484
|
+
input: logged,
|
|
485
|
+
decision,
|
|
486
|
+
...(connection && { connection }),
|
|
487
|
+
} as const;
|
|
448
488
|
|
|
449
489
|
// Each hold is lifted in turn: once one is approved, the policy is asked again, with it.
|
|
450
490
|
for (;;) {
|
|
@@ -462,6 +502,7 @@ async function callOp(run: Run, id: string, input: unknown, place: Placed) {
|
|
|
462
502
|
seq,
|
|
463
503
|
input: logged,
|
|
464
504
|
reasons: decision.reasons,
|
|
505
|
+
connection: connection?.revision,
|
|
465
506
|
});
|
|
466
507
|
} catch (err) {
|
|
467
508
|
// A rejection or a withdrawal stops the call: it is recorded as failed, naming the approval.
|
|
@@ -486,6 +527,7 @@ async function callOp(run: Run, id: string, input: unknown, place: Placed) {
|
|
|
486
527
|
async () => {
|
|
487
528
|
const at = Date.now();
|
|
488
529
|
attempt = DBOS.stepStatus?.currentAttempt ?? 1;
|
|
530
|
+
await run.state.connections?.assertCurrent(connection, run.actor.id);
|
|
489
531
|
const secrets = secretsOf(run);
|
|
490
532
|
const context: CallContext = {
|
|
491
533
|
idempotencyKey: `${run.id}:${seq}`,
|
|
@@ -493,6 +535,8 @@ async function callOp(run: Run, id: string, input: unknown, place: Placed) {
|
|
|
493
535
|
opId: op.id,
|
|
494
536
|
attempt,
|
|
495
537
|
secrets,
|
|
538
|
+
principal: run.actor,
|
|
539
|
+
...(connection && { connection }),
|
|
496
540
|
// A call run is the person's direct call held for an approval: made as them, as it would have been.
|
|
497
541
|
by: run.workflow === CALL_WORKFLOW ? run.actor.id : `run:${run.id}`,
|
|
498
542
|
};
|
package/src/cedar.ts
CHANGED
|
@@ -403,7 +403,20 @@ export function cedarPolicy(
|
|
|
403
403
|
call.actor,
|
|
404
404
|
call.op.id,
|
|
405
405
|
call.target,
|
|
406
|
-
{
|
|
406
|
+
{
|
|
407
|
+
input: call.input,
|
|
408
|
+
...(call.before !== undefined && { before: call.before }),
|
|
409
|
+
...(call.connection && {
|
|
410
|
+
connection: {
|
|
411
|
+
id: call.connection.id,
|
|
412
|
+
vendor: call.connection.vendor,
|
|
413
|
+
method: call.connection.method,
|
|
414
|
+
account: call.connection.account,
|
|
415
|
+
revision: call.connection.revision,
|
|
416
|
+
scopes: call.connection.scopes,
|
|
417
|
+
},
|
|
418
|
+
}),
|
|
419
|
+
},
|
|
407
420
|
call.run.approvals,
|
|
408
421
|
call.run.workflow === CALL_WORKFLOW,
|
|
409
422
|
call.entity,
|