@sanoma-ai/workflows 0.2.0 → 0.4.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 +121 -62
- package/dist/approvals.d.ts +11 -6
- package/dist/approvals.js +38 -10
- package/dist/approvals.js.map +1 -1
- package/dist/call-workflow.d.ts +16 -0
- package/dist/call-workflow.js +35 -0
- package/dist/call-workflow.js.map +1 -0
- package/dist/call.d.ts +33 -4
- package/dist/call.js +107 -42
- package/dist/call.js.map +1 -1
- package/dist/cedar.d.ts +87 -0
- package/dist/cedar.js +365 -0
- package/dist/cedar.js.map +1 -0
- package/dist/client.d.ts +85 -7
- package/dist/client.js +289 -25
- package/dist/client.js.map +1 -1
- package/dist/collection.d.ts +107 -0
- package/dist/collection.js +306 -0
- package/dist/collection.js.map +1 -0
- package/dist/config.d.ts +42 -14
- package/dist/config.js +82 -18
- package/dist/config.js.map +1 -1
- package/dist/constructors.d.ts +2 -0
- package/dist/constructors.js +5 -0
- package/dist/constructors.js.map +1 -0
- package/dist/data.d.ts +2 -0
- package/dist/data.js +3 -0
- package/dist/data.js.map +1 -0
- package/dist/datafile.d.ts +28 -2
- package/dist/datafile.js +118 -19
- package/dist/datafile.js.map +1 -1
- package/dist/db.d.ts +5 -3
- package/dist/db.js +88 -21
- package/dist/db.js.map +1 -1
- package/dist/define.d.ts +10 -4
- package/dist/define.js +1 -5
- package/dist/define.js.map +1 -1
- package/dist/describe.d.ts +45 -5
- package/dist/describe.js +72 -7
- package/dist/describe.js.map +1 -1
- package/dist/direct.d.ts +9 -0
- package/dist/direct.js +11 -0
- package/dist/direct.js.map +1 -0
- package/dist/errors.d.ts +16 -1
- package/dist/errors.js +30 -1
- package/dist/errors.js.map +1 -1
- package/dist/expectations.d.ts +3 -3
- package/dist/expectations.js +16 -7
- package/dist/expectations.js.map +1 -1
- package/dist/index.d.ts +8 -4
- package/dist/index.js +10 -4
- package/dist/index.js.map +1 -1
- package/dist/ledger.d.ts +12 -1
- package/dist/ledger.js +18 -11
- package/dist/ledger.js.map +1 -1
- package/dist/lint.d.ts +5 -4
- package/dist/lint.js +24 -7
- package/dist/lint.js.map +1 -1
- package/dist/op.d.ts +6 -0
- package/dist/op.js.map +1 -1
- package/dist/outline.d.ts +2 -0
- package/dist/outline.js +1 -1
- package/dist/outline.js.map +1 -1
- package/dist/paths.d.ts +4 -0
- package/dist/paths.js +17 -3
- package/dist/paths.js.map +1 -1
- package/dist/person.d.ts +61 -0
- package/dist/person.js +69 -0
- package/dist/person.js.map +1 -0
- package/dist/plugin.js +1 -1
- package/dist/plugin.js.map +1 -1
- package/dist/policy.d.ts +51 -55
- package/dist/policy.js +14 -36
- package/dist/policy.js.map +1 -1
- package/dist/resource.d.ts +21 -5
- package/dist/resource.js +12 -3
- package/dist/resource.js.map +1 -1
- package/dist/resources.d.ts +58 -6
- package/dist/resources.js +119 -26
- package/dist/resources.js.map +1 -1
- package/dist/run.d.ts +17 -1
- package/dist/run.js.map +1 -1
- package/dist/scenario.d.ts +16 -9
- package/dist/scenario.js +93 -20
- package/dist/scenario.js.map +1 -1
- package/dist/scenarios.d.ts +10 -6
- package/dist/scenarios.js +8 -4
- package/dist/scenarios.js.map +1 -1
- package/dist/schema.d.ts +32 -0
- package/dist/schema.js +225 -0
- package/dist/schema.js.map +1 -0
- package/dist/sdk.d.ts +34 -0
- package/dist/sdk.js +168 -0
- package/dist/sdk.js.map +1 -0
- package/dist/shared.d.ts +181 -5
- package/dist/shared.js +160 -7
- package/dist/shared.js.map +1 -1
- package/dist/sites.d.ts +52 -0
- package/dist/sites.js +174 -0
- package/dist/sites.js.map +1 -0
- package/dist/version.d.ts +3 -2
- package/dist/version.js +3 -2
- package/dist/version.js.map +1 -1
- package/dist/worker.d.ts +3 -2
- package/dist/worker.js +24 -4
- package/dist/worker.js.map +1 -1
- package/oxlint.json +5 -16
- package/package.json +8 -3
- package/src/approvals.ts +49 -13
- package/src/call-workflow.ts +44 -0
- package/src/call.ts +157 -45
- package/src/cedar.ts +493 -0
- package/src/client.ts +368 -37
- package/src/collection.ts +431 -0
- package/src/config.ts +131 -34
- package/src/constructors.ts +4 -0
- package/src/data.ts +10 -0
- package/src/datafile.ts +141 -21
- package/src/db.ts +90 -22
- package/src/define.ts +11 -9
- package/src/describe.ts +113 -10
- package/src/direct.ts +15 -0
- package/src/errors.ts +36 -1
- package/src/expectations.ts +17 -8
- package/src/index.ts +33 -2
- package/src/ledger.ts +35 -17
- package/src/lint.ts +29 -12
- package/src/op.ts +6 -0
- package/src/outline.ts +1 -1
- package/src/paths.ts +18 -3
- package/src/person.ts +75 -0
- package/src/plugin.ts +1 -1
- package/src/policy.ts +59 -63
- package/src/resource.ts +35 -8
- package/src/resources.ts +165 -24
- package/src/run.ts +18 -1
- package/src/scenario.ts +128 -33
- package/src/scenarios.ts +15 -8
- package/src/schema.ts +261 -0
- package/src/sdk.ts +211 -0
- package/src/shared.ts +271 -8
- package/src/sites.ts +220 -0
- package/src/version.ts +3 -2
- package/src/worker.ts +29 -6
package/src/index.ts
CHANGED
|
@@ -47,6 +47,9 @@ export {
|
|
|
47
47
|
withoutWriteOnly,
|
|
48
48
|
} from "./resource.ts";
|
|
49
49
|
export { defineConfig, resolveConfig, resolveDatabaseUrl, type ResolvedConfig, type SanomaConfig } from "./config.ts";
|
|
50
|
+
// `sanoma` itself (the people types' constructors) is at `@sanoma-ai/workflows/resources`, for data files.
|
|
51
|
+
// `collection()` itself is at `@sanoma-ai/workflows/data`, for `resources/data.ts`; this tells one apart, as the launcher does.
|
|
52
|
+
export { isCollection } from "./collection.ts";
|
|
50
53
|
export { type CredentialStatus, credentialsOf } from "./env.ts";
|
|
51
54
|
export {
|
|
52
55
|
APPLY_WORKFLOW,
|
|
@@ -57,6 +60,8 @@ export {
|
|
|
57
60
|
type PlanAnswer,
|
|
58
61
|
type PlanStep,
|
|
59
62
|
} from "./apply.ts";
|
|
63
|
+
export { CALL_WORKFLOW, CallInput } from "./call-workflow.ts";
|
|
64
|
+
export { isDirect } from "./direct.ts";
|
|
60
65
|
export {
|
|
61
66
|
DRIFT_WORKFLOW,
|
|
62
67
|
type DriftDeclared,
|
|
@@ -73,20 +78,32 @@ export {
|
|
|
73
78
|
approve,
|
|
74
79
|
approvedFor,
|
|
75
80
|
type Decision,
|
|
76
|
-
definePolicy,
|
|
77
81
|
deny,
|
|
78
82
|
type Policy,
|
|
79
83
|
type PolicyCall,
|
|
80
84
|
type PolicyOp,
|
|
81
85
|
policyOpOf,
|
|
86
|
+
type PreviewAnswer,
|
|
87
|
+
type PreviewRequest,
|
|
82
88
|
type RecordedDecision,
|
|
83
89
|
} from "./policy.ts";
|
|
84
90
|
export {
|
|
91
|
+
cedarPolicy,
|
|
92
|
+
type CedarPolicySource,
|
|
93
|
+
loadPolicies,
|
|
94
|
+
POLICIES_DIR,
|
|
95
|
+
type PolicyProblem,
|
|
96
|
+
readPolicyFiles,
|
|
97
|
+
validatePolicies,
|
|
98
|
+
} from "./cedar.ts";
|
|
99
|
+
export {
|
|
100
|
+
ApprovalPendingError,
|
|
85
101
|
errorCode,
|
|
86
102
|
type ErrorCode,
|
|
87
103
|
type ErrorInfo,
|
|
88
104
|
type InputIssue,
|
|
89
105
|
invalidInput,
|
|
106
|
+
NotAdmittedError,
|
|
90
107
|
PolicyDeniedError,
|
|
91
108
|
RejectedError,
|
|
92
109
|
SanomaError,
|
|
@@ -116,12 +133,26 @@ export {
|
|
|
116
133
|
} from "./secret.ts";
|
|
117
134
|
export { resolveRevision, SOURCE_RESOLVE, type Source } from "./source.ts";
|
|
118
135
|
export type { HostCapabilities, HostLifetime, HostOutputs, HostSpec } from "./host.ts";
|
|
119
|
-
export {
|
|
136
|
+
export {
|
|
137
|
+
type CallOptions,
|
|
138
|
+
SanomaClient,
|
|
139
|
+
type RunsFilter,
|
|
140
|
+
type RunSummary,
|
|
141
|
+
type StartOptions,
|
|
142
|
+
type StoredSecret,
|
|
143
|
+
} from "./client.ts";
|
|
120
144
|
// A raw DBOS client on the runtime's database, for tests and tools that read DBOS directly.
|
|
121
145
|
export { dbosClient } from "./db.ts";
|
|
122
146
|
// Also at `@sanoma-ai/workflows/shared`, which a browser bundle can import.
|
|
123
147
|
export {
|
|
148
|
+
AGENTS,
|
|
124
149
|
approverLabel,
|
|
150
|
+
approverOf,
|
|
151
|
+
declaresPeople,
|
|
152
|
+
type PeopleResource,
|
|
153
|
+
personOf,
|
|
154
|
+
principalOf,
|
|
155
|
+
RUNTIME_AGENTS,
|
|
125
156
|
credentialReady,
|
|
126
157
|
DEFAULT_SLEEP_NAME,
|
|
127
158
|
ENDED_STATUSES,
|
package/src/ledger.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { appendFile, mkdir, readFile, truncate } from "node:fs/promises";
|
|
|
3
3
|
import { join, resolve } from "node:path";
|
|
4
4
|
import { setTimeout as delay } from "node:timers/promises";
|
|
5
5
|
import { isDeepStrictEqual } from "node:util";
|
|
6
|
-
import { LEDGER_TABLE as TABLE, lazyPool, pgCode } from "./db.ts";
|
|
6
|
+
import { type Db, LEDGER_TABLE as TABLE, lazyPool, pgCode } from "./db.ts";
|
|
7
7
|
import type { Approver, Principal } from "./define.ts";
|
|
8
8
|
import { type ErrorInfo, isFinal, keepCode } from "./errors.ts";
|
|
9
9
|
import { shown, warn } from "./log.ts";
|
|
@@ -80,13 +80,23 @@ export type LedgerRecord = {
|
|
|
80
80
|
op?: string;
|
|
81
81
|
/** The held call's `seq`, which its `op.called` record has, for a policy request. */
|
|
82
82
|
opSeq?: number;
|
|
83
|
+
/** The policies that held the call, by id, for a policy request whose policy names them. */
|
|
84
|
+
reasons?: string[];
|
|
83
85
|
}
|
|
84
86
|
/** A message the approval ignored: from someone other than the approver, or not a decision. */
|
|
85
87
|
| { type: "approval.refused"; approval: string; by?: string; reason: string }
|
|
86
|
-
| {
|
|
88
|
+
| {
|
|
89
|
+
type: "approval.decided";
|
|
90
|
+
approval: string;
|
|
91
|
+
decision: "approve" | "reject";
|
|
92
|
+
by: string;
|
|
93
|
+
note?: string;
|
|
94
|
+
/** Set when `by` started the run: nobody else could decide (`mayDecide`). */
|
|
95
|
+
alone?: true;
|
|
96
|
+
}
|
|
87
97
|
/**
|
|
88
98
|
* A `ctx.sleep` began; `until` is when it ends, in ms since the epoch: the time asked for, or
|
|
89
|
-
* for a duration the
|
|
99
|
+
* for a duration the record's `at` plus the duration, as the runtime saw it. Written
|
|
90
100
|
* for a time already past too, which waits not at all.
|
|
91
101
|
*/
|
|
92
102
|
| {
|
|
@@ -292,25 +302,13 @@ export function postgresLedger(databaseUrl: string): LedgerStore & { close(): Pr
|
|
|
292
302
|
|
|
293
303
|
return {
|
|
294
304
|
async append(record) {
|
|
295
|
-
const line = lineOf(record);
|
|
296
305
|
const conn = await pool.db();
|
|
297
|
-
|
|
298
|
-
.query(
|
|
299
|
-
`INSERT INTO ${TABLE} (id, run_id, seq, record) VALUES ($1, $2, $3, $4::jsonb) ON CONFLICT (id) DO NOTHING RETURNING n`,
|
|
300
|
-
[record.id, record.runId, record.seq, line],
|
|
301
|
-
)
|
|
302
|
-
.catch((err: unknown) => {
|
|
303
|
-
if (DATA_EXCEPTION.test(pgCode(err))) {
|
|
304
|
-
throw final(`Ledger record ${record.id} can't be stored: ${errorMessage(err)}`, err);
|
|
305
|
-
}
|
|
306
|
-
throw err;
|
|
307
|
-
});
|
|
308
|
-
if (inserted.rows.length) return;
|
|
306
|
+
if (await insertRecord(conn, record)) return;
|
|
309
307
|
// Another write of the id was first: the record it stored stands.
|
|
310
308
|
const { rows } = await conn.query<{ record: LedgerRecord }>(`SELECT record FROM ${TABLE} WHERE id = $1`, [
|
|
311
309
|
record.id,
|
|
312
310
|
]);
|
|
313
|
-
if (rows[0]) warnIfDifferent(rows[0].record,
|
|
311
|
+
if (rows[0]) warnIfDifferent(rows[0].record, lineOf(record));
|
|
314
312
|
},
|
|
315
313
|
async read(runId) {
|
|
316
314
|
const { rows } = await (
|
|
@@ -323,6 +321,26 @@ export function postgresLedger(databaseUrl: string): LedgerStore & { close(): Pr
|
|
|
323
321
|
};
|
|
324
322
|
}
|
|
325
323
|
|
|
324
|
+
/**
|
|
325
|
+
* Stores the record in the Postgres ledger's table through `db`, unless one with its id is
|
|
326
|
+
* stored: true when this one was. What `postgresLedger` appends with, and a direct write
|
|
327
|
+
* (`SanomaClient.call`) inside the transaction that saves its record.
|
|
328
|
+
*/
|
|
329
|
+
export async function insertRecord(db: Db, record: LedgerRecord): Promise<boolean> {
|
|
330
|
+
const inserted = await db
|
|
331
|
+
.query(
|
|
332
|
+
`INSERT INTO ${TABLE} (id, run_id, seq, record) VALUES ($1, $2, $3, $4::jsonb) ON CONFLICT (id) DO NOTHING RETURNING n`,
|
|
333
|
+
[record.id, record.runId, record.seq, lineOf(record)],
|
|
334
|
+
)
|
|
335
|
+
.catch((err: unknown) => {
|
|
336
|
+
if (DATA_EXCEPTION.test(pgCode(err))) {
|
|
337
|
+
throw final(`Ledger record ${record.id} can't be stored: ${errorMessage(err)}`, err);
|
|
338
|
+
}
|
|
339
|
+
throw err;
|
|
340
|
+
});
|
|
341
|
+
return inserted.rows.length > 0;
|
|
342
|
+
}
|
|
343
|
+
|
|
326
344
|
/** Keeps records in memory, for tests. */
|
|
327
345
|
export function memoryLedger(): LedgerStore {
|
|
328
346
|
const runs = new Map<string, Map<string, LedgerRecord>>();
|
package/src/lint.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
+
import { realpathSync } from "node:fs";
|
|
1
2
|
import { basename, dirname, join } from "node:path";
|
|
2
3
|
import { byPosition, childrenOf, type LintProblem, type Node, parsed, type SourceFile } from "./ast.ts";
|
|
3
|
-
import { readDataFile } from "./datafile.ts";
|
|
4
|
+
import { COLLECTIONS_FILE, readDataFile } from "./datafile.ts";
|
|
4
5
|
import { constsOf, outlineBody, workflowLiterals } from "./outline.ts";
|
|
5
6
|
import { inside, nearestDir, RELATIVE } from "./paths.ts";
|
|
6
7
|
// From src/ and from dist/ alike, the package's own oxlint.json: the one list of allowed names.
|
|
@@ -33,7 +34,7 @@ const REFUSED_PACKAGES: [RegExp, string][] = [
|
|
|
33
34
|
];
|
|
34
35
|
|
|
35
36
|
/**
|
|
36
|
-
* What a workflow
|
|
37
|
+
* What a workflow may import from an author package, besides types: the one list,
|
|
37
38
|
* `allowImportNames` of oxlint.json's `@sanoma-ai/workflows` entry (`sanoma`'s generated
|
|
38
39
|
* `oxlint.json` derives its own entry from it). Everything else is
|
|
39
40
|
* refused: the client, the worker and the app could start runs or approve the run's own
|
|
@@ -46,7 +47,7 @@ const WORKFLOW_IMPORTS: readonly string[] =
|
|
|
46
47
|
const ALLOWED_NAMES = new Set(WORKFLOW_IMPORTS);
|
|
47
48
|
|
|
48
49
|
const NOT_ALLOWED =
|
|
49
|
-
`a workflow
|
|
50
|
+
`a workflow imports only ${WORKFLOW_IMPORTS.join(", ")} and types from ` +
|
|
50
51
|
`${AUTHOR_PACKAGES.join(" or ")}: the rest could start runs, approve its own approvals, forge the ledger or read credentials`;
|
|
51
52
|
|
|
52
53
|
/**
|
|
@@ -57,11 +58,12 @@ const NOT_ALLOWED =
|
|
|
57
58
|
const ERROR_CLASSES = new Set(["DriverError", "SanomaError", "PolicyDeniedError", "RejectedError"]);
|
|
58
59
|
|
|
59
60
|
/**
|
|
60
|
-
* Checks a workflow
|
|
61
|
+
* Checks a workflow file. Imports allowed: the names in `WORKFLOW_IMPORTS` and any
|
|
61
62
|
* type from `@sanoma-ai/workflows` or `sanoma`, `@sanoma-ai/connector-<vendor>`, `zod`, and relative files. With
|
|
62
|
-
* `filename`, a relative import must stay inside the file's nearest `workflows/` or
|
|
63
|
-
*
|
|
64
|
-
*
|
|
63
|
+
* `filename`, a relative import must stay inside the file's nearest `workflows/` directory, or its
|
|
64
|
+
* own directory when it has none; the one file outside it a workflow may import is the company's
|
|
65
|
+
* `resources/data.ts`, its collections (by real path). No
|
|
66
|
+
* `instanceof` against the runtime's error classes: read `errorCode(err)`.
|
|
65
67
|
*
|
|
66
68
|
* In each workflow's `run` (the literal `defineWorkflow` is called with), it reports what the
|
|
67
69
|
* outline cannot draw, as the worker refuses to start with (`outlineBody`): `ctx` used other
|
|
@@ -82,7 +84,7 @@ export function lintWorkflow(input: string | SourceFile, path?: string): LintPro
|
|
|
82
84
|
const read = typeof input === "string" ? parsed(input, filename ?? "workflow.ts") : input;
|
|
83
85
|
const { program, at } = read;
|
|
84
86
|
const problems = [...read.problems];
|
|
85
|
-
const root = filename === undefined ? undefined : nearestDir(filename, ["workflows"
|
|
87
|
+
const root = filename === undefined ? undefined : nearestDir(filename, ["workflows"]);
|
|
86
88
|
|
|
87
89
|
const checkSource = (node: Node) => {
|
|
88
90
|
const spec: string = node.source.value;
|
|
@@ -197,12 +199,27 @@ type RestrictedPath = { name: string; allowImportNames?: string[] };
|
|
|
197
199
|
|
|
198
200
|
function whyRefused(spec: string, filename: string | undefined, root: string | undefined): string | undefined {
|
|
199
201
|
if (RELATIVE.test(spec)) {
|
|
200
|
-
if (filename === undefined || root === undefined
|
|
201
|
-
const
|
|
202
|
-
|
|
203
|
-
|
|
202
|
+
if (filename === undefined || root === undefined) return undefined;
|
|
203
|
+
const target = join(dirname(filename), spec);
|
|
204
|
+
if (inside(root, target)) return undefined;
|
|
205
|
+
const tree = basename(root) === "workflows";
|
|
206
|
+
// The collections, which a workflow names in `uses`: the one data file the runtime imports too.
|
|
207
|
+
if (tree && realPath(target) === realPath(join(dirname(root), "resources", COLLECTIONS_FILE))) return undefined;
|
|
208
|
+
return (
|
|
209
|
+
`it reaches outside ${tree ? `${basename(root)}/` : "this directory"}, toward the config and its drivers; ${THROUGH_CTX}` +
|
|
210
|
+
(tree ? "; of the files outside it, only resources/data.ts (the collections) may be imported" : "")
|
|
211
|
+
);
|
|
204
212
|
}
|
|
205
213
|
for (const [pattern, reason] of REFUSED_PACKAGES) if (pattern.test(spec)) return reason;
|
|
206
214
|
if (ALLOWED_PACKAGE.test(spec)) return undefined;
|
|
207
215
|
return "import only from `sanoma` (or `@sanoma-ai/workflows`), a `@sanoma-ai/connector-*` package, zod, or a file beside this one";
|
|
208
216
|
}
|
|
217
|
+
|
|
218
|
+
/** A path's real path, or the path itself when it does not exist. */
|
|
219
|
+
function realPath(path: string): string {
|
|
220
|
+
try {
|
|
221
|
+
return realpathSync(path);
|
|
222
|
+
} catch {
|
|
223
|
+
return path;
|
|
224
|
+
}
|
|
225
|
+
}
|
package/src/op.ts
CHANGED
|
@@ -238,6 +238,12 @@ export interface CallContext {
|
|
|
238
238
|
attempt: number;
|
|
239
239
|
/** The secrets store, for the values `secret()` markers name: `resolveSecrets(input, call.secrets)`. In memory for a fake. */
|
|
240
240
|
secrets: Secrets;
|
|
241
|
+
/**
|
|
242
|
+
* Who the call is made as: `run:<runId>` for a call in a run; the person's id for a direct call
|
|
243
|
+
* (`SanomaClient.call`) and in a run of the built-in `call` workflow. A collection's save
|
|
244
|
+
* records it as the record's `updatedBy`.
|
|
245
|
+
*/
|
|
246
|
+
by?: string;
|
|
241
247
|
}
|
|
242
248
|
|
|
243
249
|
export type DriverFn<I = any, O = unknown> = (input: I, call: CallContext) => Promise<O>;
|
package/src/outline.ts
CHANGED
|
@@ -576,7 +576,7 @@ function ctxPath(callee: Node, ctx: string): string[] | undefined {
|
|
|
576
576
|
}
|
|
577
577
|
|
|
578
578
|
/** A string literal's value, or a template literal's with nothing interpolated. */
|
|
579
|
-
function stringValue(node: Node): string | undefined {
|
|
579
|
+
export function stringValue(node: Node): string | undefined {
|
|
580
580
|
const n = unwrap(node);
|
|
581
581
|
if (n?.type === "Literal" && typeof n.value === "string") return n.value;
|
|
582
582
|
if (n?.type === "TemplateLiteral" && n.expressions.length === 0) return n.quasis[0]?.value.cooked ?? undefined;
|
package/src/paths.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { readdirSync, statSync } from "node:fs";
|
|
2
|
+
import { basename, dirname, isAbsolute, join, relative, sep } from "node:path";
|
|
2
3
|
|
|
3
|
-
// Where an import may reach, as the lints and the data-file reader all decide it,
|
|
4
|
-
//
|
|
4
|
+
// Where an import may reach, as the lints and the data-file reader all decide it, the one walk up
|
|
5
|
+
// the directories and the one walk down (`filesUnder`). Node's `path` and `fs` only, no parser:
|
|
6
|
+
// the oxlint plugin loads it.
|
|
5
7
|
|
|
6
8
|
/** A relative import specifier: `./x.ts`, `../y.ts`. */
|
|
7
9
|
export const RELATIVE = /^\.\.?\//;
|
|
@@ -30,3 +32,16 @@ export function inside(dir: string, path: string): boolean {
|
|
|
30
32
|
const rel = relative(dir, path);
|
|
31
33
|
return rel === "" || (rel.split(sep)[0] !== ".." && !isAbsolute(rel));
|
|
32
34
|
}
|
|
35
|
+
|
|
36
|
+
/** True when `path` is a directory. */
|
|
37
|
+
export const isDirectory = (path: string): boolean => statSync(path, { throwIfNoEntry: false })?.isDirectory() ?? false;
|
|
38
|
+
|
|
39
|
+
/** The files under `dir` whose name `keep` takes, sorted, outside `node_modules`; none when `dir` is not a directory. */
|
|
40
|
+
export function filesUnder(dir: string, keep: (name: string) => boolean): string[] {
|
|
41
|
+
if (!isDirectory(dir)) return [];
|
|
42
|
+
return readdirSync(dir, { recursive: true, withFileTypes: true })
|
|
43
|
+
.filter((d) => d.isFile() && keep(d.name))
|
|
44
|
+
.map((d) => join(d.parentPath, d.name))
|
|
45
|
+
.filter((path) => !relative(dir, path).split(sep).includes("node_modules"))
|
|
46
|
+
.toSorted();
|
|
47
|
+
}
|
package/src/person.ts
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { defineConnector } from "./op.ts";
|
|
3
|
+
import { defineResource } from "./resource.ts";
|
|
4
|
+
|
|
5
|
+
// Who exists in a company: people, agents and groups, declared in data files like any resource,
|
|
6
|
+
// as the runtime's own vendor's types (`sanoma.person`, `sanoma.agent`, `sanoma.group`, imported
|
|
7
|
+
// from "sanoma/resources"). They are `vendorless`: the repo is all there is of them, so they have
|
|
8
|
+
// no driver and no drift. A membership is written on a group only, its members references to the
|
|
9
|
+
// declared people and agents; every agent is in the group `agents` besides. Who a principal is,
|
|
10
|
+
// read from them, is `principalOf` (`shared.ts`).
|
|
11
|
+
|
|
12
|
+
/** An id or a group's name: one word, as a scenario names an approver. */
|
|
13
|
+
const WORD = z.string().regex(/^\S+$/, 'is one word, such as "ada"');
|
|
14
|
+
const NONE = { immutable: [], vendorOwned: [], writeOnly: [] };
|
|
15
|
+
|
|
16
|
+
const person = defineResource({
|
|
17
|
+
vendor: "sanoma",
|
|
18
|
+
type: "person",
|
|
19
|
+
title: "Person",
|
|
20
|
+
identity: "id",
|
|
21
|
+
schema: z.object({
|
|
22
|
+
id: WORD,
|
|
23
|
+
name: z.string().optional(),
|
|
24
|
+
/** Matched first by a sign-in that vouches for an email (GitHub's, its verified public one). */
|
|
25
|
+
email: z.email().optional(),
|
|
26
|
+
/** Their GitHub login, which GitHub sign-in matches, ignoring case, when no email does. */
|
|
27
|
+
github: z
|
|
28
|
+
.string()
|
|
29
|
+
.regex(/^[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?$/, "is a GitHub login, such as ada-ortiz")
|
|
30
|
+
.optional(),
|
|
31
|
+
}),
|
|
32
|
+
fields: NONE,
|
|
33
|
+
find: ({ id }) => id,
|
|
34
|
+
vendorless: true,
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const agent = defineResource({
|
|
38
|
+
vendor: "sanoma",
|
|
39
|
+
type: "agent",
|
|
40
|
+
title: "Agent",
|
|
41
|
+
identity: "id",
|
|
42
|
+
schema: z.object({ id: WORD, name: z.string().optional(), onBehalfOf: WORD.optional() }),
|
|
43
|
+
fields: { ...NONE, references: { onBehalfOf: { type: "sanoma.person" } } },
|
|
44
|
+
find: ({ id }) => id,
|
|
45
|
+
vendorless: true,
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const group = defineResource({
|
|
49
|
+
vendor: "sanoma",
|
|
50
|
+
type: "group",
|
|
51
|
+
title: "Group",
|
|
52
|
+
identity: "name",
|
|
53
|
+
schema: z.object({ name: WORD, members: z.array(WORD) }),
|
|
54
|
+
fields: {
|
|
55
|
+
...NONE,
|
|
56
|
+
unordered: ["members"],
|
|
57
|
+
references: { members: { type: ["sanoma.person", "sanoma.agent"] } },
|
|
58
|
+
},
|
|
59
|
+
find: ({ name }) => name,
|
|
60
|
+
vendorless: true,
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* What a data file declares the company's people with, imported from "sanoma/resources":
|
|
65
|
+
* `export const ada = sanoma.person({ id: "ada", name: "Ada Ortiz", github: "ada-ortiz" })`,
|
|
66
|
+
* `sanoma.agent({ id: "refund-bot", onBehalfOf: bo })` and
|
|
67
|
+
* `sanoma.group({ name: "finance", members: [ada, refundBot] })`.
|
|
68
|
+
*/
|
|
69
|
+
export const sanoma = { person, agent, group };
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The runtime's own connector of the people types: every config's, so its data files may declare
|
|
73
|
+
* them. Its package is `sanoma`, whose `/resources` entry exports `sanoma`.
|
|
74
|
+
*/
|
|
75
|
+
export const peopleConnector = defineConnector("sanoma", sanoma, { title: "Sanoma", package: "sanoma" });
|
package/src/plugin.ts
CHANGED
|
@@ -18,7 +18,7 @@ const plugin = {
|
|
|
18
18
|
type: "problem",
|
|
19
19
|
docs: {
|
|
20
20
|
description:
|
|
21
|
-
"A data file under resources/ holds only imports, `export const <name> = <vendor>.<type>({ … })` with literal fields, and `export default [ … ]`",
|
|
21
|
+
"A data file under resources/ holds only imports, `export const <name> = <vendor>.<type>({ … })` with literal fields, and `export default [ … ]`; resources/data.ts holds only collections, `collection({ … })` from sanoma/data",
|
|
22
22
|
},
|
|
23
23
|
},
|
|
24
24
|
create(context: Context) {
|
package/src/policy.ts
CHANGED
|
@@ -1,14 +1,10 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { type ApprovalState, Approver, Covers, type Principal, sameApprover } from "./define.ts";
|
|
1
|
+
import { type ApprovalState, type Approver, type Principal, sameApprover } from "./define.ts";
|
|
3
2
|
import type { Effect, Op } from "./op.ts";
|
|
4
3
|
|
|
5
4
|
/** An operation as a policy sees it: plain data, without its schemas. */
|
|
6
5
|
export type PolicyOp = Pick<Op, "id" | "vendor" | "resource" | "name" | "effect">;
|
|
7
6
|
|
|
8
|
-
/**
|
|
9
|
-
* An operation as a policy sees it, for a `PolicyCall`: what the runtime passes, so a policy
|
|
10
|
-
* test can build a call from the connector, `{ op: policyOpOf(shop.order.refund), ... }`.
|
|
11
|
-
*/
|
|
7
|
+
/** An operation as a policy sees it, for a `PolicyCall`: what the runtime passes. */
|
|
12
8
|
export const policyOpOf = ({ id, vendor, resource, name, effect }: Op): PolicyOp => ({
|
|
13
9
|
id,
|
|
14
10
|
vendor,
|
|
@@ -17,7 +13,7 @@ export const policyOpOf = ({ id, vendor, resource, name, effect }: Op): PolicyOp
|
|
|
17
13
|
effect,
|
|
18
14
|
});
|
|
19
15
|
|
|
20
|
-
/** What a policy sees for each operation a run calls
|
|
16
|
+
/** What a policy sees for each operation a run calls: plain data. */
|
|
21
17
|
export interface PolicyCall {
|
|
22
18
|
op: PolicyOp;
|
|
23
19
|
/** The same as `op.effect`. */
|
|
@@ -33,6 +29,8 @@ export interface PolicyCall {
|
|
|
33
29
|
* A policy request has `requestedBy: "policy"` and the `op` it held.
|
|
34
30
|
*/
|
|
35
31
|
run: { id: string; workflow: string; approvals: readonly ApprovalState[] };
|
|
32
|
+
/** For a collection's `save` or `delete`: the record as it was, when there was one. */
|
|
33
|
+
before?: unknown;
|
|
36
34
|
}
|
|
37
35
|
|
|
38
36
|
/**
|
|
@@ -44,88 +42,86 @@ export type Decision =
|
|
|
44
42
|
| { kind: "deny"; reason: string; reasons?: string[] }
|
|
45
43
|
| ApproveDecision;
|
|
46
44
|
|
|
47
|
-
/**
|
|
48
|
-
* Hold the call until the approver decides: a person's id, or `{ group }` for anyone in it.
|
|
49
|
-
* The approval covers the held operation, and `covers` adds others, so a policy can let their
|
|
50
|
-
* later calls through once it is approved.
|
|
51
|
-
*/
|
|
45
|
+
/** Hold the call until the approver decides: a person's id, or `{ group }` for anyone in it. */
|
|
52
46
|
interface ApproveDecision {
|
|
53
47
|
kind: "approve";
|
|
54
48
|
approver: Approver;
|
|
55
49
|
title?: string;
|
|
56
|
-
|
|
50
|
+
reasons?: string[];
|
|
57
51
|
}
|
|
58
52
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
/**
|
|
62
|
-
* Checks a policy's answer. Parsing also copies it, so nothing else the policy returned is kept,
|
|
63
|
-
* and turns `covers` into op ids.
|
|
64
|
-
*/
|
|
65
|
-
export const DecisionSchema = z.discriminatedUnion(
|
|
66
|
-
"kind",
|
|
67
|
-
[
|
|
68
|
-
z.object({ kind: z.literal("allow"), reasons: ReasonList }),
|
|
69
|
-
z.object({ kind: z.literal("deny"), reason: z.string({ error: "deny needs a reason" }), reasons: ReasonList }),
|
|
70
|
-
z.object({
|
|
71
|
-
kind: z.literal("approve"),
|
|
72
|
-
approver: Approver,
|
|
73
|
-
title: z.string({ error: "the title must be a string" }).optional(),
|
|
74
|
-
covers: Covers,
|
|
75
|
-
}),
|
|
76
|
-
],
|
|
77
|
-
{ error: "not a decision" },
|
|
78
|
-
);
|
|
79
|
-
|
|
80
|
-
/**
|
|
81
|
-
* A decision as checked and as the ledger records it: `covers` by op id, and the `version` of
|
|
82
|
-
* the policy that made it, when it has one.
|
|
83
|
-
*/
|
|
84
|
-
export type RecordedDecision = z.output<typeof DecisionSchema> & { policyVersion?: string };
|
|
53
|
+
/** A decision as the ledger records it, with the `version` of the policy that made it, when it has one. */
|
|
54
|
+
export type RecordedDecision = Decision & { policyVersion?: string };
|
|
85
55
|
|
|
86
56
|
export const allow = (reasons?: string[]): Decision =>
|
|
87
57
|
reasons === undefined ? { kind: "allow" } : { kind: "allow", reasons };
|
|
88
58
|
export const deny = (reason: string, reasons?: string[]): Decision =>
|
|
89
59
|
reasons === undefined ? { kind: "deny", reason } : { kind: "deny", reason, reasons };
|
|
90
60
|
|
|
91
|
-
/** Holds the call for `approver
|
|
92
|
-
export function approve(approver: Approver, { title,
|
|
61
|
+
/** Holds the call for `approver`; `reasons`, the rules that held it, as `allow` and `deny` take them. */
|
|
62
|
+
export function approve(approver: Approver, { title, reasons }: { title?: string; reasons?: string[] } = {}): Decision {
|
|
93
63
|
return {
|
|
94
64
|
kind: "approve",
|
|
95
65
|
approver,
|
|
96
66
|
...(title === undefined ? {} : { title }),
|
|
97
|
-
...(
|
|
67
|
+
...(reasons === undefined ? {} : { reasons }),
|
|
98
68
|
};
|
|
99
69
|
}
|
|
100
70
|
|
|
101
71
|
/**
|
|
102
|
-
* Decides whether a run may make an operation call: allow it, deny it (the run fails),
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
*
|
|
106
|
-
* and a replay reuses it, but a call interrupted before that is decided again. A policy
|
|
107
|
-
* must not have side effects.
|
|
72
|
+
* Decides whether a run may make an operation call: allow it, deny it (the run fails), or hold it
|
|
73
|
+
* until someone approves. A company's is made from its Cedar files (`cedarPolicy`); a repo with
|
|
74
|
+
* none has `allowAll`. Each decision is recorded once made, and a replay reuses it, but a call
|
|
75
|
+
* interrupted before that is decided again.
|
|
108
76
|
*/
|
|
109
|
-
export type Policy = ((call: PolicyCall) => Decision
|
|
110
|
-
/** Names this version of the policy
|
|
77
|
+
export type Policy = ((call: PolicyCall) => Decision) & {
|
|
78
|
+
/** Names this version of the policy: a hash of the Cedar policies' ids and text. */
|
|
111
79
|
readonly version?: string;
|
|
80
|
+
/** What the policy would answer for each request ahead of the call, its input unknown. */
|
|
81
|
+
readonly preview: (requests: readonly PreviewRequest[]) => PreviewAnswer[];
|
|
82
|
+
/**
|
|
83
|
+
* May `actor` start a run of `workflow` with `input` (the action `workflow:<name>`), given the
|
|
84
|
+
* run's `approvals` so far (none before it starts)?
|
|
85
|
+
*/
|
|
86
|
+
readonly start: (
|
|
87
|
+
actor: Principal,
|
|
88
|
+
workflow: string,
|
|
89
|
+
input: unknown,
|
|
90
|
+
approvals?: readonly ApprovalState[],
|
|
91
|
+
) => Decision;
|
|
112
92
|
};
|
|
113
93
|
|
|
114
|
-
/** A
|
|
115
|
-
export
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
94
|
+
/** A question asked ahead of a call: may `principal` take `action` (an operation, or `workflow:<name>` to start one) on `resource` (an operation's target)? */
|
|
95
|
+
export interface PreviewRequest {
|
|
96
|
+
principal: Principal;
|
|
97
|
+
action: { id: string; effect?: Effect };
|
|
98
|
+
resource?: string;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* A preview's answer: `depends` when the unknown input decides it. `rule` is the Cedar policy
|
|
103
|
+
* behind a hold, a deny or a dependence; `approver`, a hold's.
|
|
104
|
+
*/
|
|
105
|
+
export interface PreviewAnswer {
|
|
106
|
+
answer: "allow" | "hold" | "deny" | "depends";
|
|
107
|
+
rule?: { id: string; file: string; title?: string; reason?: string };
|
|
108
|
+
approver?: Approver;
|
|
125
109
|
}
|
|
126
110
|
|
|
127
|
-
/**
|
|
128
|
-
|
|
111
|
+
/** A policy that decides every call and start as `decide` does, and previews each as `answer`. */
|
|
112
|
+
const decidingAll = (decide: () => Decision, answer: "allow" | "deny"): Policy =>
|
|
113
|
+
Object.freeze(
|
|
114
|
+
Object.assign(decide, {
|
|
115
|
+
start: decide,
|
|
116
|
+
preview: (requests: readonly PreviewRequest[]): PreviewAnswer[] => requests.map(() => ({ answer })),
|
|
117
|
+
}),
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
/** Allows every operation call and run start: the policy of a repo with no `policies/**\/*.cedar`. */
|
|
121
|
+
export const allowAll: Policy = decidingAll(() => allow(), "allow");
|
|
122
|
+
|
|
123
|
+
/** Refuses every operation call and run start, and previews each as a deny, saying `reason`. */
|
|
124
|
+
export const denyAll = (reason: string): Policy => decidingAll(() => deny(reason), "deny");
|
|
129
125
|
|
|
130
126
|
/**
|
|
131
127
|
* True when an approval in the run was approved, covers the operation, and was addressed to
|