@sanoma-ai/workflows 0.11.17 → 0.11.18
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 +13 -9
- package/dist/call.d.ts +35 -4
- package/dist/call.js +128 -40
- package/dist/call.js.map +1 -1
- package/dist/client.d.ts +17 -8
- package/dist/client.js +90 -51
- package/dist/client.js.map +1 -1
- package/dist/collection.d.ts +25 -7
- package/dist/collection.js +236 -91
- package/dist/collection.js.map +1 -1
- package/dist/config.d.ts +8 -5
- package/dist/config.js +10 -10
- package/dist/config.js.map +1 -1
- package/dist/db/company.d.ts +20 -0
- package/dist/db/company.js +9 -1
- package/dist/db/company.js.map +1 -1
- package/dist/describe.js +1 -1
- package/dist/describe.js.map +1 -1
- package/dist/description.js +1 -1
- package/dist/description.js.map +1 -1
- package/dist/errors.d.ts +2 -1
- package/dist/errors.js +3 -2
- package/dist/errors.js.map +1 -1
- package/dist/grid.js +3 -2
- package/dist/grid.js.map +1 -1
- package/dist/host-derive.d.ts +13 -8
- package/dist/host-derive.js +26 -13
- package/dist/host-derive.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/ledger.d.ts +7 -0
- package/dist/ledger.js +32 -2
- package/dist/ledger.js.map +1 -1
- package/dist/local-sandbox.d.ts +8 -1
- package/dist/local-sandbox.js +3 -2
- package/dist/local-sandbox.js.map +1 -1
- package/dist/op.d.ts +3 -2
- package/dist/op.js.map +1 -1
- package/dist/resources.d.ts +1 -1
- package/dist/resources.js +9 -6
- package/dist/resources.js.map +1 -1
- package/dist/runner-box.d.ts +23 -0
- package/dist/runner-box.js +181 -0
- package/dist/runner-box.js.map +1 -0
- package/dist/runner-protocol.d.ts +73 -0
- package/dist/runner-protocol.js +34 -0
- package/dist/runner-protocol.js.map +1 -0
- package/dist/sandbox-contract.d.ts +14 -5
- package/dist/sandbox-contract.js.map +1 -1
- package/dist/sandbox-runner.cjs +7 -0
- package/dist/scenario.js +16 -7
- package/dist/scenario.js.map +1 -1
- package/dist/schema.js +6 -3
- package/dist/schema.js.map +1 -1
- package/dist/secret.d.ts +2 -1
- package/dist/secret.js +3 -2
- package/dist/secret.js.map +1 -1
- package/dist/shared.d.ts +39 -5
- package/dist/shared.js +13 -1
- package/dist/shared.js.map +1 -1
- package/dist/sites.js +10 -8
- package/dist/sites.js.map +1 -1
- package/dist/source.d.ts +21 -0
- package/dist/source.js.map +1 -1
- package/drizzle/company/0003_ledger_target.sql +2 -0
- package/drizzle/company/meta/0003_snapshot.json +845 -0
- package/drizzle/company/meta/_journal.json +7 -0
- package/package.json +5 -2
- package/src/call.ts +181 -60
- package/src/client.ts +118 -67
- package/src/collection.ts +265 -97
- package/src/config.ts +20 -20
- package/src/db/company.ts +9 -1
- package/src/describe.ts +1 -1
- package/src/description.ts +1 -1
- package/src/errors.ts +3 -2
- package/src/grid.ts +3 -1
- package/src/host-derive.ts +38 -18
- package/src/index.ts +2 -0
- package/src/ledger.ts +42 -2
- package/src/local-sandbox.ts +3 -2
- package/src/op.ts +3 -2
- package/src/resources.ts +8 -6
- package/src/runner-box.ts +218 -0
- package/src/runner-protocol.ts +61 -0
- package/src/sandbox-contract.ts +11 -4
- package/src/sandbox-runner.ts +187 -0
- package/src/scenario.ts +16 -6
- package/src/schema.ts +14 -3
- package/src/secret.ts +6 -2
- package/src/shared.ts +40 -4
- package/src/sites.ts +11 -7
- package/src/source.ts +14 -0
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { createServer } from "node:http";
|
|
2
|
+
import type { AddressInfo } from "node:net";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
5
|
+
import { type WebSocket, WebSocketServer } from "ws";
|
|
6
|
+
import { localBox } from "./local-sandbox.ts";
|
|
7
|
+
import {
|
|
8
|
+
FILE,
|
|
9
|
+
frame,
|
|
10
|
+
RUNNER_HEALTH,
|
|
11
|
+
RUNNER_PORT,
|
|
12
|
+
RUNNER_PROTOCOL,
|
|
13
|
+
type RunnerAnswer,
|
|
14
|
+
type RunnerHealth,
|
|
15
|
+
type RunnerRequest,
|
|
16
|
+
STDERR,
|
|
17
|
+
STDIN,
|
|
18
|
+
STDOUT,
|
|
19
|
+
unframe,
|
|
20
|
+
} from "./runner-protocol.ts";
|
|
21
|
+
import type { Process } from "./sandbox-contract.ts";
|
|
22
|
+
import { errorMessage } from "./shared.ts";
|
|
23
|
+
|
|
24
|
+
// The runner: the server of ours a backend starts inside a remote box, which `runnerBox` talks to
|
|
25
|
+
// over a WebSocket (the protocol is in `runner-protocol.ts`). It serves this machine as the local
|
|
26
|
+
// backend's box does (`localBox`, whose code it bundles): commands are `bash -c` in process groups
|
|
27
|
+
// of their own, a relative path or `cwd` is under the home. Built into one file,
|
|
28
|
+
// `dist/sandbox-runner.cjs` (`ws` bundled in), run as `node sandbox-runner.cjs`.
|
|
29
|
+
//
|
|
30
|
+
// It listens on `SANOMA_RUNNER_HOST:SANOMA_RUNNER_PORT` (default `0.0.0.0:7700`) and asks no
|
|
31
|
+
// credential: the vendor's edge authenticates every request to the port, the upgrade too
|
|
32
|
+
// (Agent37: the API key, on a port never made public). Anything inside the box already runs as
|
|
33
|
+
// the box's user, so a secret of the runner's own would guard nothing.
|
|
34
|
+
|
|
35
|
+
const port = Number(process.env.SANOMA_RUNNER_PORT ?? RUNNER_PORT);
|
|
36
|
+
const host = process.env.SANOMA_RUNNER_HOST ?? "0.0.0.0";
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* How long an exited command's output may go quiet before its exit is sent, in ms: `localBox` has
|
|
40
|
+
* waited for its pipes already, unless what it left running holds them. Output held back for a
|
|
41
|
+
* slow connection is not quiet.
|
|
42
|
+
*/
|
|
43
|
+
const LAST_OUTPUT = 50;
|
|
44
|
+
/** How much a connection may hold unsent before a command's output waits for it, in bytes. */
|
|
45
|
+
const UNSENT = 1 << 20;
|
|
46
|
+
/** How often a connection is pinged: Agent37's edge closes one silent for 15 minutes. */
|
|
47
|
+
const BEAT = 30_000;
|
|
48
|
+
|
|
49
|
+
// Its commands' environment is its own, but for where it listens.
|
|
50
|
+
const { SANOMA_RUNNER_PORT: _, SANOMA_RUNNER_HOST: __, ...own } = process.env;
|
|
51
|
+
const box = localBox("runner", homedir(), own as Record<string, string>);
|
|
52
|
+
|
|
53
|
+
const server = createServer((req, res) => {
|
|
54
|
+
if (req.url !== RUNNER_HEALTH) return void res.writeHead(404).end();
|
|
55
|
+
res
|
|
56
|
+
.writeHead(200, { "content-type": "application/json" })
|
|
57
|
+
.end(JSON.stringify({ protocol: RUNNER_PROTOCOL, pid: process.pid } satisfies RunnerHealth));
|
|
58
|
+
});
|
|
59
|
+
new WebSocketServer({ server })
|
|
60
|
+
.on("connection", serve)
|
|
61
|
+
.on("error", (error) => console.error(`the runner's WebSocket server: ${error.message}`));
|
|
62
|
+
server.listen(port, host, () => {
|
|
63
|
+
const { address, port: bound } = server.address() as AddressInfo;
|
|
64
|
+
console.log(`sanoma runner listening on ${address}:${bound}`);
|
|
65
|
+
});
|
|
66
|
+
// Asked to end (the box shuts down): what it started ends with it.
|
|
67
|
+
process.on("SIGTERM", () => void box.stop().finally(() => process.exit(0)));
|
|
68
|
+
|
|
69
|
+
/** One client's connection: its commands, by the ids it gave them. */
|
|
70
|
+
function serve(ws: WebSocket) {
|
|
71
|
+
// Promises: a command's kill and its stdin may come before it has started, and wait for it.
|
|
72
|
+
const processes = new Map<number, Promise<Process>>();
|
|
73
|
+
const inputs = new Map<number, Promise<WritableStreamDefaultWriter<Uint8Array>>>();
|
|
74
|
+
/** The writes whose bytes are yet to come. */
|
|
75
|
+
const writes = new Map<number, { path: string; mode?: number }>();
|
|
76
|
+
const send = (data: Uint8Array | string) => ws.readyState === ws.OPEN && ws.send(data);
|
|
77
|
+
const say = (answer: RunnerAnswer) => send(JSON.stringify(answer));
|
|
78
|
+
const beat = setInterval(() => ws.ping(), BEAT);
|
|
79
|
+
// A frame ws refuses (one too large, one malformed) closes this connection, not the runner.
|
|
80
|
+
ws.on("error", (error) => console.error(`a connection: ${error.message}`));
|
|
81
|
+
ws.on("close", () => {
|
|
82
|
+
clearInterval(beat);
|
|
83
|
+
// A dropped connection kills nothing: its commands go on, their stdin ended, their output dropped.
|
|
84
|
+
for (const id of inputs.keys()) endInput(id);
|
|
85
|
+
});
|
|
86
|
+
ws.on("message", (data: Buffer, binary) => {
|
|
87
|
+
// A frame that is not the protocol's is answered, never a crash of the runner.
|
|
88
|
+
if (binary) {
|
|
89
|
+
if (data.length < 5) return say({ op: "error", message: "a binary frame is an id, a stream and bytes" });
|
|
90
|
+
return received(unframe(data));
|
|
91
|
+
}
|
|
92
|
+
let request: RunnerRequest;
|
|
93
|
+
try {
|
|
94
|
+
request = JSON.parse(String(data));
|
|
95
|
+
} catch {
|
|
96
|
+
return say({ op: "error", message: "a text frame is JSON" });
|
|
97
|
+
}
|
|
98
|
+
if (typeof request !== "object" || request === null) return say({ op: "error", message: "a request is an object" });
|
|
99
|
+
handle(request).catch((error: unknown) => say({ op: "error", id: request.id, message: errorMessage(error) }));
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
async function handle(request: RunnerRequest): Promise<unknown> {
|
|
103
|
+
switch (request.op) {
|
|
104
|
+
case "run": {
|
|
105
|
+
const { id, command, cwd, env, stdin } = request;
|
|
106
|
+
const starting = box.run(command, { cwd, env, stdin });
|
|
107
|
+
processes.set(id, starting);
|
|
108
|
+
if (stdin) {
|
|
109
|
+
const input = starting.then((started) => started.stdin.getWriter());
|
|
110
|
+
// A run that fails to start is answered below; its stdin's rejection is not another.
|
|
111
|
+
input.catch(() => {});
|
|
112
|
+
inputs.set(id, input);
|
|
113
|
+
}
|
|
114
|
+
try {
|
|
115
|
+
const started = await starting;
|
|
116
|
+
const moved = { at: Date.now(), done: false };
|
|
117
|
+
const pumped = Promise.all([
|
|
118
|
+
pump(id, STDOUT, started.stdout, moved),
|
|
119
|
+
pump(id, STDERR, started.stderr, moved),
|
|
120
|
+
]).finally(() => (moved.done = true));
|
|
121
|
+
const { exitCode } = await started.wait();
|
|
122
|
+
// What it printed last goes out first, unless what it left running holds its pipes.
|
|
123
|
+
moved.at = Date.now();
|
|
124
|
+
while (!moved.done && Date.now() - moved.at < LAST_OUTPUT) await Promise.race([pumped, delay(10)]);
|
|
125
|
+
return say({ op: "exit", id, code: exitCode });
|
|
126
|
+
} finally {
|
|
127
|
+
processes.delete(id);
|
|
128
|
+
inputs.delete(id);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
case "stdin-end":
|
|
132
|
+
return endInput(request.id);
|
|
133
|
+
case "kill":
|
|
134
|
+
return processes.get(request.id)?.then((started) => started.kill(request.signal));
|
|
135
|
+
case "read": {
|
|
136
|
+
const file = await box.read(request.path);
|
|
137
|
+
return file ? send(frame(request.id, FILE, file)) : say({ op: "absent", id: request.id });
|
|
138
|
+
}
|
|
139
|
+
case "write":
|
|
140
|
+
return writes.set(request.id, request);
|
|
141
|
+
case "stop":
|
|
142
|
+
await box.stop();
|
|
143
|
+
return say({ op: "ok", id: request.id });
|
|
144
|
+
default:
|
|
145
|
+
throw new Error(`no such request: ${(request as { op?: unknown }).op}`);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function received({ id, stream, bytes }: ReturnType<typeof unframe>) {
|
|
150
|
+
if (stream === STDIN)
|
|
151
|
+
return void inputs
|
|
152
|
+
.get(id)
|
|
153
|
+
?.then((writer) => writer.write(bytes))
|
|
154
|
+
.catch(() => {});
|
|
155
|
+
const write = writes.get(id);
|
|
156
|
+
if (stream !== FILE || !write) return;
|
|
157
|
+
writes.delete(id);
|
|
158
|
+
box.write(write.path, bytes, { mode: write.mode }).then(
|
|
159
|
+
() => say({ op: "ok", id }),
|
|
160
|
+
(error: unknown) => say({ op: "error", id, message: errorMessage(error) }),
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Ends the stdin of the command `id`, once it has started. */
|
|
165
|
+
function endInput(id: number) {
|
|
166
|
+
const input = inputs.get(id);
|
|
167
|
+
inputs.delete(id);
|
|
168
|
+
return input?.then((writer) => writer.close()).catch(() => {});
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Sends what `from` prints, until it ends or fails, keeping in `moved` when it last sent or waited to. */
|
|
172
|
+
async function pump(id: number, stream: number, from: ReadableStream<Uint8Array>, moved: { at: number }) {
|
|
173
|
+
try {
|
|
174
|
+
for await (const chunk of from) {
|
|
175
|
+
// Its output waits for a slow connection, and so the command does, its pipe full.
|
|
176
|
+
while (ws.readyState === ws.OPEN && ws.bufferedAmount > UNSENT) {
|
|
177
|
+
moved.at = Date.now();
|
|
178
|
+
await delay(10);
|
|
179
|
+
}
|
|
180
|
+
send(frame(id, stream, chunk));
|
|
181
|
+
moved.at = Date.now();
|
|
182
|
+
}
|
|
183
|
+
} catch {
|
|
184
|
+
// A pipe that failed has nothing more to say.
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
package/src/scenario.ts
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
type Step as GherkinStep,
|
|
13
13
|
} from "@cucumber/messages";
|
|
14
14
|
import type { z } from "zod";
|
|
15
|
+
import { targetOf } from "./call.ts";
|
|
15
16
|
import type { RunSummary, SanomaClient } from "./client.ts";
|
|
16
17
|
import type { Approver, Principal } from "./define.ts";
|
|
17
18
|
import type { ResolvedConfig } from "./config.ts";
|
|
@@ -44,10 +45,12 @@ import {
|
|
|
44
45
|
DATA_VENDOR,
|
|
45
46
|
declaresPeople,
|
|
46
47
|
DRIFT_WORKFLOW,
|
|
48
|
+
entryOpsOf,
|
|
47
49
|
errorMessage,
|
|
48
50
|
fitsOp,
|
|
49
51
|
flatten,
|
|
50
52
|
groupNamesOf,
|
|
53
|
+
isBatch,
|
|
51
54
|
isEnded,
|
|
52
55
|
principalIdsOf,
|
|
53
56
|
problemLine,
|
|
@@ -446,6 +449,12 @@ function rulesOf(scope: Scope): { rules: Rule[]; parameterTypes: ParameterTypeSp
|
|
|
446
449
|
{ name: "field", regexp: /\S+/, transformer: (s) => s },
|
|
447
450
|
];
|
|
448
451
|
const opOf = (id: unknown) => scope.ops.get(id as string)!;
|
|
452
|
+
/** An operation a `Then` step reads the ledger for: never a batch, which the ledger records as its entries. */
|
|
453
|
+
const recordedOp = (id: unknown): string => {
|
|
454
|
+
const op = opOf(id);
|
|
455
|
+
if (isBatch(op)) throw new Error(`${op.id} is recorded as its entries: name ${entryOpsOf(op).join(" or ")}`);
|
|
456
|
+
return op.id;
|
|
457
|
+
};
|
|
449
458
|
const workflowOf = (name: unknown) => scope.workflows.get(name as string)!;
|
|
450
459
|
// The people as the root's data files declare them when a step names one (`readPeople`, read
|
|
451
460
|
// again only once a file changed): while they declare no one, any id and any group go.
|
|
@@ -596,33 +605,33 @@ function rulesOf(scope: Scope): { rules: Rule[]; parameterTypes: ParameterTypeSp
|
|
|
596
605
|
source: "{op} was called with",
|
|
597
606
|
takes: TAKES_OBJECT,
|
|
598
607
|
read: ([op], pickle) =>
|
|
599
|
-
expecting({ op: op
|
|
608
|
+
expecting({ op: recordedOp(op), input: fieldsFrom(pickle, opOf(op).input, inputOf(op), true), called: true }),
|
|
600
609
|
},
|
|
601
610
|
...[true, false].map((called): Rule => ({
|
|
602
611
|
kind: "then",
|
|
603
612
|
source: `{op} was ${called ? "" : "not "}called`,
|
|
604
|
-
read: ([op]) => expecting({ op: op
|
|
613
|
+
read: ([op]) => expecting({ op: recordedOp(op), called }),
|
|
605
614
|
})),
|
|
606
615
|
{
|
|
607
616
|
kind: "then",
|
|
608
617
|
source: "{op} was denied",
|
|
609
|
-
read: ([op]) => expecting({ op: op
|
|
618
|
+
read: ([op]) => expecting({ op: recordedOp(op), decision: "denied" }),
|
|
610
619
|
},
|
|
611
620
|
{
|
|
612
621
|
kind: "then",
|
|
613
622
|
source: "{op} was denied with {string}",
|
|
614
|
-
read: ([op, reason]) => expecting({ op: op
|
|
623
|
+
read: ([op, reason]) => expecting({ op: recordedOp(op), decision: "denied", reason: reason as string }),
|
|
615
624
|
},
|
|
616
625
|
{
|
|
617
626
|
kind: "then",
|
|
618
627
|
source: "{op} was held for {who}",
|
|
619
|
-
read: ([op, who]) => expecting({ op: op
|
|
628
|
+
read: ([op, who]) => expecting({ op: recordedOp(op), decision: "held", approver: who as string }),
|
|
620
629
|
},
|
|
621
630
|
{
|
|
622
631
|
kind: "then",
|
|
623
632
|
source: "{op} was held for group {string}",
|
|
624
633
|
read: ([op, group]) =>
|
|
625
|
-
expecting({ op: op
|
|
634
|
+
expecting({ op: recordedOp(op), decision: "held", approver: { group: groupNamed(group as string) } }),
|
|
626
635
|
},
|
|
627
636
|
{
|
|
628
637
|
kind: "then",
|
|
@@ -1207,6 +1216,7 @@ function testWorld(built: Built, { driver, fake, live }: FeatureTarget) {
|
|
|
1207
1216
|
record({
|
|
1208
1217
|
type: "op.called",
|
|
1209
1218
|
op: op.id,
|
|
1219
|
+
...targetOf(op, input),
|
|
1210
1220
|
effect: op.effect,
|
|
1211
1221
|
input,
|
|
1212
1222
|
decision: { kind: "allow" },
|
package/src/schema.ts
CHANGED
|
@@ -13,7 +13,15 @@ import { recordSchemaOf } from "./collection.ts";
|
|
|
13
13
|
import type { ResolvedConfig } from "./config.ts";
|
|
14
14
|
import { jsonSchemaOf } from "./define.ts";
|
|
15
15
|
import { isObject } from "./resource.ts";
|
|
16
|
-
import {
|
|
16
|
+
import {
|
|
17
|
+
CHANGE_MERGE,
|
|
18
|
+
DATA_VENDOR,
|
|
19
|
+
EFFECTS,
|
|
20
|
+
groupNamesOf,
|
|
21
|
+
isBatch,
|
|
22
|
+
type PeopleResource,
|
|
23
|
+
WORKFLOW_ACTION,
|
|
24
|
+
} from "./shared.ts";
|
|
17
25
|
|
|
18
26
|
// The Cedar schema a company's policies are validated and decided against: made from the
|
|
19
27
|
// resolved config and the people, in memory for the evaluator, and written as text to
|
|
@@ -90,7 +98,8 @@ const WRITES = new Set(["save", "delete"]);
|
|
|
90
98
|
* - `entity Group enum [...]`, the declared groups, `agents` and `visitors`, so a policy naming another fails
|
|
91
99
|
* validation; `entity Principal in [Group] { onBehalfOf?: Principal }`; `Collection`;
|
|
92
100
|
* `Target in [Collection]`; `Workflow`;
|
|
93
|
-
* - an action per operation id
|
|
101
|
+
* - an action per operation id (a collection's `saveMany` excepted: the policy decides each of
|
|
102
|
+
* its entries as a `save` or `delete`), in its effect's group (`effect:money`), on a `Target`, with
|
|
94
103
|
* `context` `{ input, before? }`: `input` from the operation's input schema, `before` the record
|
|
95
104
|
* as it was for a collection's `save` and `delete`; `sanoma.change.merge` on a `Change`, whose
|
|
96
105
|
* attributes are a change's facts (`ChangeFacts`), so a rule reads `resource.touches`;
|
|
@@ -112,7 +121,9 @@ function buildSchema(
|
|
|
112
121
|
});
|
|
113
122
|
const actions: CedarSchemaJson[string]["actions"] = { [START]: {} };
|
|
114
123
|
for (const effect of EFFECTS) actions[`effect:${effect}`] = {};
|
|
115
|
-
|
|
124
|
+
// A collection's batch is no action: the policy decides each of its entries as a save or a delete.
|
|
125
|
+
const ops = [...resolved.ops.values()].filter((op) => !isBatch(op));
|
|
126
|
+
for (const op of ops.toSorted((a, b) => a.id.localeCompare(b.id))) {
|
|
116
127
|
const collection =
|
|
117
128
|
op.vendor === DATA_VENDOR && WRITES.has(op.name) ? resolved.collections.get(op.resource) : undefined;
|
|
118
129
|
const before = collection && typeOf(recordSchemaOf(collection));
|
package/src/secret.ts
CHANGED
|
@@ -101,14 +101,18 @@ export type Resolved<T> = T extends Secret
|
|
|
101
101
|
|
|
102
102
|
/**
|
|
103
103
|
* `value` with each secret marker in it replaced by the stored value: generated and stored first
|
|
104
|
-
* for one with `generate` and none stored. A marker naming nothing stored fails the call, for good
|
|
104
|
+
* for one with `generate` and none stored. A marker naming nothing stored fails the call, for good,
|
|
105
|
+
* before the driver changes anything at the vendor (`secret_not_set`, which the ledger tells).
|
|
105
106
|
*/
|
|
106
107
|
export async function resolveSecrets<T>(value: T, secrets: Secrets): Promise<Resolved<T>> {
|
|
107
108
|
const resolve = async (v: unknown): Promise<unknown> => {
|
|
108
109
|
if (isSecret(v)) {
|
|
109
110
|
const stored = v.generate ? await secrets.ensure(v.$secret, generateSecret) : await secrets.get(v.$secret);
|
|
110
111
|
if (stored === undefined) {
|
|
111
|
-
throw new DriverError(
|
|
112
|
+
throw new DriverError(
|
|
113
|
+
`The secret ${v.$secret} has no value: store it (or, where sanoma.config.ts binds it, set what the binding reads), then try again`,
|
|
114
|
+
{ retryable: false, vendorCode: "secret_not_set" },
|
|
115
|
+
);
|
|
112
116
|
}
|
|
113
117
|
return stored;
|
|
114
118
|
}
|
package/src/shared.ts
CHANGED
|
@@ -55,6 +55,14 @@ export const APPLY_WORKFLOW = "apply";
|
|
|
55
55
|
*/
|
|
56
56
|
export const DATA_VENDOR = "data";
|
|
57
57
|
|
|
58
|
+
/** The one operation of a collection that stands for others: `saveMany`, each entry a `save` or `delete`. */
|
|
59
|
+
export const BATCH = "saveMany";
|
|
60
|
+
export const isBatch = (op: Pick<OpEntry, "vendor" | "name">): boolean =>
|
|
61
|
+
op.vendor === DATA_VENDOR && op.name === BATCH;
|
|
62
|
+
/** The ids of the operations a batch's entries are: its collection's `save` and `delete`. */
|
|
63
|
+
export const entryOpsOf = (op: Pick<OpEntry, "resource">): string[] =>
|
|
64
|
+
["save", "delete"].map((name) => `${DATA_VENDOR}.${op.resource}.${name}`);
|
|
65
|
+
|
|
58
66
|
/** Every effect an operation may have, each an action group (`effect:<effect>`) of the Cedar schema. */
|
|
59
67
|
export const EFFECTS = ["read", "write", "publish", "send", "money", "access"] as const satisfies readonly Effect[];
|
|
60
68
|
|
|
@@ -68,11 +76,16 @@ export const WORKFLOW_ACTION = "workflow:";
|
|
|
68
76
|
*/
|
|
69
77
|
export function previewRequests(
|
|
70
78
|
workflow: Pick<WorkflowEntry, "name" | "ops">,
|
|
71
|
-
ops: ReadonlyMap<string, Pick<OpEntry, "effect">>,
|
|
79
|
+
ops: ReadonlyMap<string, Pick<OpEntry, "effect" | "vendor" | "resource" | "name">>,
|
|
72
80
|
): Omit<PreviewRequest, "principal">[] {
|
|
81
|
+
// A batch is asked as its entries, the save and the delete the policy decides: no rule names it.
|
|
82
|
+
const asked = workflow.ops.flatMap((id) => {
|
|
83
|
+
const op = ops.get(id);
|
|
84
|
+
return op && isBatch(op) ? entryOpsOf(op) : [id];
|
|
85
|
+
});
|
|
73
86
|
return [
|
|
74
87
|
{ action: { id: `${WORKFLOW_ACTION}${workflow.name}` } },
|
|
75
|
-
...
|
|
88
|
+
...[...new Set(asked)].map((id) => {
|
|
76
89
|
const effect = ops.get(id)?.effect;
|
|
77
90
|
return { action: effect === undefined ? { id } : { id, effect }, inRun: true };
|
|
78
91
|
}),
|
|
@@ -203,6 +216,12 @@ type Range<V> = { gt?: V; gte?: V; lt?: V; lte?: V };
|
|
|
203
216
|
/** How many records a collection's `list` answers: `default` when the query names no `limit`, `max` at most. */
|
|
204
217
|
export const LIST_LIMIT = { default: 100, max: 1000 } as const;
|
|
205
218
|
|
|
219
|
+
/** How many entries (records and deletes together) a collection's `saveMany` takes at most. */
|
|
220
|
+
export const BATCH_LIMIT = 1000;
|
|
221
|
+
|
|
222
|
+
/** What a collection's `delete` answers, and `saveMany` for each of its deletes: whether there was a record. */
|
|
223
|
+
export type Deleted = { key: string; deleted: boolean };
|
|
224
|
+
|
|
206
225
|
/** The kinds of value a field holds: text, a number, true or false, a date (ISO 8601) or a person's id. */
|
|
207
226
|
export const FIELD_TYPES = ["text", "number", "boolean", "date", "person"] as const;
|
|
208
227
|
|
|
@@ -673,10 +692,27 @@ export interface WorkflowFn<I, O> {
|
|
|
673
692
|
/** An operation a site may call directly (a read, or a collection's write), as the person on the page. */
|
|
674
693
|
export type OpFn<I, O> = (input: I) => Promise<O>;
|
|
675
694
|
|
|
676
|
-
/** A collection as a site's `api.data.<name>` has it: its
|
|
695
|
+
/** A collection as a site's `api.data.<name>` has it: its six operations (`Saved`, `ListQuery`, `Deleted`). */
|
|
677
696
|
export interface CollectionApi<R> {
|
|
678
697
|
get: OpFn<{ key: string }, Saved<R> | null>;
|
|
679
698
|
list(query?: ListQuery<R>): Promise<Saved<R>[]>;
|
|
699
|
+
/** How many records there are, or match `where` (as `list` takes it). */
|
|
700
|
+
count(query?: Pick<ListQuery<R>, "where">): Promise<{ count: number }>;
|
|
680
701
|
save: OpFn<{ record: R; ifAbsent?: boolean; ifRecord?: Record<string, unknown> }, Saved<R>>;
|
|
681
|
-
|
|
702
|
+
/**
|
|
703
|
+
* Saves and deletes together, all or none, at most `BATCH_LIMIT` entries, each key once: the
|
|
704
|
+
* records' receipts then the deletes' answers, in the order sent. The policy decides each entry
|
|
705
|
+
* as its own `save` or `delete`; a deny refuses the batch (`policy_denied`, with the `denied`
|
|
706
|
+
* keys), a hold holds it whole, and a record an entry's `ifRecord` or `ifAbsent` refuses refuses
|
|
707
|
+
* it (status 409, `record_changed`) with every such entry in `conflicts: { key, current }[]`.
|
|
708
|
+
*/
|
|
709
|
+
saveMany: OpFn<
|
|
710
|
+
{
|
|
711
|
+
records?: { record: R; ifAbsent?: boolean; ifRecord?: Record<string, unknown> }[];
|
|
712
|
+
deletes?: { key: string; ifRecord?: Record<string, unknown> }[];
|
|
713
|
+
},
|
|
714
|
+
(Saved<R> | Deleted)[]
|
|
715
|
+
>;
|
|
716
|
+
/** Deletes the record, or with `ifRecord` only while it still matches (status 409, `record_changed`, otherwise). */
|
|
717
|
+
delete: OpFn<{ key: string; ifRecord?: Record<string, unknown> }, Deleted>;
|
|
682
718
|
}
|
package/src/sites.ts
CHANGED
|
@@ -16,6 +16,8 @@ import {
|
|
|
16
16
|
BUILT_IN_WORKFLOWS,
|
|
17
17
|
DATA_VENDOR,
|
|
18
18
|
DOMAIN_NAME,
|
|
19
|
+
entryOpsOf,
|
|
20
|
+
isBatch,
|
|
19
21
|
LOCALE,
|
|
20
22
|
SITE_NAME,
|
|
21
23
|
siteKey,
|
|
@@ -80,7 +82,7 @@ export function readSites(
|
|
|
80
82
|
*/
|
|
81
83
|
const refused = (call: SiteCall, login: string | undefined): string | undefined => {
|
|
82
84
|
const why = `a visitor's call to ${KIND_NAMES[call.kind]} "${call.target}" is refused`;
|
|
83
|
-
let
|
|
85
|
+
let actions: string[];
|
|
84
86
|
let group: string;
|
|
85
87
|
if (call.kind === "workflow") {
|
|
86
88
|
if (BUILT_IN_WORKFLOWS.has(call.target)) return `${why}: a visitor may not start the runtime's own workflows`;
|
|
@@ -88,22 +90,24 @@ export function readSites(
|
|
|
88
90
|
if (call.method !== undefined && call.method !== "start") {
|
|
89
91
|
return `${why}: a visitor reads only the result of a run it started`;
|
|
90
92
|
}
|
|
91
|
-
[
|
|
93
|
+
[actions, group] = [[`${WORKFLOW_ACTION}${call.target}`], START];
|
|
92
94
|
} else {
|
|
93
|
-
action = call.kind === "op" ? call.target : `${DATA_VENDOR}.${call.target}.${call.method}`;
|
|
95
|
+
const action = call.kind === "op" ? call.target : `${DATA_VENDOR}.${call.target}.${call.method}`;
|
|
94
96
|
const op = ops.get(action);
|
|
95
97
|
if (!op) return undefined;
|
|
96
|
-
|
|
98
|
+
// A batch is its entries to the policy: the save and the delete each need a permit.
|
|
99
|
+
[actions, group] = [isBatch(op) ? entryOpsOf(op) : [action], `effect:${op.effect}`];
|
|
97
100
|
}
|
|
98
101
|
if (!permits) return undefined;
|
|
99
102
|
const [scopes, principal] =
|
|
100
103
|
login === undefined
|
|
101
104
|
? [[ANONYMOUS], `== Sanoma::Principal::"${ANONYMOUS}"`]
|
|
102
105
|
: [[ANONYMOUS, VISITORS], `in Sanoma::Group::"${VISITORS}"`];
|
|
103
|
-
|
|
106
|
+
const missing = actions.filter((action) => !permits(new Set(scopes), action, group));
|
|
107
|
+
if (!missing.length) return undefined;
|
|
104
108
|
return (
|
|
105
|
-
`${why}: no permit in policies/ names ${scopes.join(" or ")} for ${
|
|
106
|
-
`Add: permit (principal ${principal}, action == Sanoma::Action::"${
|
|
109
|
+
`${why}: no permit in policies/ names ${scopes.join(" or ")} for ${missing.join(" or ")}. ` +
|
|
110
|
+
`Add: ${missing.map((a) => `permit (principal ${principal}, action == Sanoma::Action::"${a}", resource);`).join(" ")}`
|
|
107
111
|
);
|
|
108
112
|
};
|
|
109
113
|
|
package/src/source.ts
CHANGED
|
@@ -40,6 +40,20 @@ export interface Source {
|
|
|
40
40
|
diff?(id: string): Promise<DraftDiff>;
|
|
41
41
|
/** Closes the draft `id`: its checkout removed and its branch deleted, whatever it held; nothing when it is gone. */
|
|
42
42
|
closeDraft?(id: string): Promise<void>;
|
|
43
|
+
/**
|
|
44
|
+
* The draft `id`'s branch as a git bundle (`git bundle create`), for a clone elsewhere (an agent
|
|
45
|
+
* box's) to fetch: its commits since `since`, a commit that clone has, or the whole branch when
|
|
46
|
+
* `since` is absent or a commit this repository lacks; no bytes when the clone has them all.
|
|
47
|
+
* The branch's head beside it. A bundle holds commits and the branch's name alone: no remote,
|
|
48
|
+
* no config, no credential.
|
|
49
|
+
*/
|
|
50
|
+
bundle?(id: string, opts?: { since?: string }): Promise<{ bytes: Uint8Array; head: string }>;
|
|
51
|
+
/**
|
|
52
|
+
* Takes a bundle of the draft `id`'s branch made elsewhere (an agent box's new commits on it):
|
|
53
|
+
* its commits fetched, then the draft's checkout fast-forwarded to them. Refused unless that is
|
|
54
|
+
* a fast-forward that leaves what the checkout holds uncommitted in place. The branch's head after.
|
|
55
|
+
*/
|
|
56
|
+
unbundle?(id: string, bytes: Uint8Array): Promise<{ head: string }>;
|
|
43
57
|
/**
|
|
44
58
|
* `sanoma check` on the draft `id`, as a child in its checkout, one at a time in the process. A
|
|
45
59
|
* check that gives no result (a config that does not load, no install, a timeout) is a result
|