@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.
Files changed (144) hide show
  1. package/README.md +121 -62
  2. package/dist/approvals.d.ts +11 -6
  3. package/dist/approvals.js +38 -10
  4. package/dist/approvals.js.map +1 -1
  5. package/dist/call-workflow.d.ts +16 -0
  6. package/dist/call-workflow.js +35 -0
  7. package/dist/call-workflow.js.map +1 -0
  8. package/dist/call.d.ts +33 -4
  9. package/dist/call.js +107 -42
  10. package/dist/call.js.map +1 -1
  11. package/dist/cedar.d.ts +87 -0
  12. package/dist/cedar.js +365 -0
  13. package/dist/cedar.js.map +1 -0
  14. package/dist/client.d.ts +85 -7
  15. package/dist/client.js +289 -25
  16. package/dist/client.js.map +1 -1
  17. package/dist/collection.d.ts +107 -0
  18. package/dist/collection.js +306 -0
  19. package/dist/collection.js.map +1 -0
  20. package/dist/config.d.ts +42 -14
  21. package/dist/config.js +82 -18
  22. package/dist/config.js.map +1 -1
  23. package/dist/constructors.d.ts +2 -0
  24. package/dist/constructors.js +5 -0
  25. package/dist/constructors.js.map +1 -0
  26. package/dist/data.d.ts +2 -0
  27. package/dist/data.js +3 -0
  28. package/dist/data.js.map +1 -0
  29. package/dist/datafile.d.ts +28 -2
  30. package/dist/datafile.js +118 -19
  31. package/dist/datafile.js.map +1 -1
  32. package/dist/db.d.ts +5 -3
  33. package/dist/db.js +88 -21
  34. package/dist/db.js.map +1 -1
  35. package/dist/define.d.ts +10 -4
  36. package/dist/define.js +1 -5
  37. package/dist/define.js.map +1 -1
  38. package/dist/describe.d.ts +45 -5
  39. package/dist/describe.js +72 -7
  40. package/dist/describe.js.map +1 -1
  41. package/dist/direct.d.ts +9 -0
  42. package/dist/direct.js +11 -0
  43. package/dist/direct.js.map +1 -0
  44. package/dist/errors.d.ts +16 -1
  45. package/dist/errors.js +30 -1
  46. package/dist/errors.js.map +1 -1
  47. package/dist/expectations.d.ts +3 -3
  48. package/dist/expectations.js +16 -7
  49. package/dist/expectations.js.map +1 -1
  50. package/dist/index.d.ts +8 -4
  51. package/dist/index.js +10 -4
  52. package/dist/index.js.map +1 -1
  53. package/dist/ledger.d.ts +12 -1
  54. package/dist/ledger.js +18 -11
  55. package/dist/ledger.js.map +1 -1
  56. package/dist/lint.d.ts +5 -4
  57. package/dist/lint.js +24 -7
  58. package/dist/lint.js.map +1 -1
  59. package/dist/op.d.ts +6 -0
  60. package/dist/op.js.map +1 -1
  61. package/dist/outline.d.ts +2 -0
  62. package/dist/outline.js +1 -1
  63. package/dist/outline.js.map +1 -1
  64. package/dist/paths.d.ts +4 -0
  65. package/dist/paths.js +17 -3
  66. package/dist/paths.js.map +1 -1
  67. package/dist/person.d.ts +61 -0
  68. package/dist/person.js +69 -0
  69. package/dist/person.js.map +1 -0
  70. package/dist/plugin.js +1 -1
  71. package/dist/plugin.js.map +1 -1
  72. package/dist/policy.d.ts +51 -55
  73. package/dist/policy.js +14 -36
  74. package/dist/policy.js.map +1 -1
  75. package/dist/resource.d.ts +21 -5
  76. package/dist/resource.js +12 -3
  77. package/dist/resource.js.map +1 -1
  78. package/dist/resources.d.ts +58 -6
  79. package/dist/resources.js +119 -26
  80. package/dist/resources.js.map +1 -1
  81. package/dist/run.d.ts +17 -1
  82. package/dist/run.js.map +1 -1
  83. package/dist/scenario.d.ts +16 -9
  84. package/dist/scenario.js +93 -20
  85. package/dist/scenario.js.map +1 -1
  86. package/dist/scenarios.d.ts +10 -6
  87. package/dist/scenarios.js +8 -4
  88. package/dist/scenarios.js.map +1 -1
  89. package/dist/schema.d.ts +32 -0
  90. package/dist/schema.js +225 -0
  91. package/dist/schema.js.map +1 -0
  92. package/dist/sdk.d.ts +34 -0
  93. package/dist/sdk.js +168 -0
  94. package/dist/sdk.js.map +1 -0
  95. package/dist/shared.d.ts +181 -5
  96. package/dist/shared.js +160 -7
  97. package/dist/shared.js.map +1 -1
  98. package/dist/sites.d.ts +52 -0
  99. package/dist/sites.js +174 -0
  100. package/dist/sites.js.map +1 -0
  101. package/dist/version.d.ts +3 -2
  102. package/dist/version.js +3 -2
  103. package/dist/version.js.map +1 -1
  104. package/dist/worker.d.ts +3 -2
  105. package/dist/worker.js +24 -4
  106. package/dist/worker.js.map +1 -1
  107. package/oxlint.json +5 -16
  108. package/package.json +8 -3
  109. package/src/approvals.ts +49 -13
  110. package/src/call-workflow.ts +44 -0
  111. package/src/call.ts +157 -45
  112. package/src/cedar.ts +493 -0
  113. package/src/client.ts +368 -37
  114. package/src/collection.ts +431 -0
  115. package/src/config.ts +131 -34
  116. package/src/constructors.ts +4 -0
  117. package/src/data.ts +10 -0
  118. package/src/datafile.ts +141 -21
  119. package/src/db.ts +90 -22
  120. package/src/define.ts +11 -9
  121. package/src/describe.ts +113 -10
  122. package/src/direct.ts +15 -0
  123. package/src/errors.ts +36 -1
  124. package/src/expectations.ts +17 -8
  125. package/src/index.ts +33 -2
  126. package/src/ledger.ts +35 -17
  127. package/src/lint.ts +29 -12
  128. package/src/op.ts +6 -0
  129. package/src/outline.ts +1 -1
  130. package/src/paths.ts +18 -3
  131. package/src/person.ts +75 -0
  132. package/src/plugin.ts +1 -1
  133. package/src/policy.ts +59 -63
  134. package/src/resource.ts +35 -8
  135. package/src/resources.ts +165 -24
  136. package/src/run.ts +18 -1
  137. package/src/scenario.ts +128 -33
  138. package/src/scenarios.ts +15 -8
  139. package/src/schema.ts +261 -0
  140. package/src/sdk.ts +211 -0
  141. package/src/shared.ts +271 -8
  142. package/src/sites.ts +220 -0
  143. package/src/version.ts +3 -2
  144. 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 { SanomaClient, type RunsFilter, type RunSummary, type StartOptions, type StoredSecret } from "./client.ts";
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
- | { type: "approval.decided"; approval: string; decision: "approve" | "reject"; by: string; note?: string }
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 time the sleep started plus the duration, as the runtime saw it. Written
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
- const inserted = await conn
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, line);
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 or policy may import from an author package, besides types: the one list,
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 or policy imports only ${WORKFLOW_IMPORTS.join(", ")} and types from ` +
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 or policy file. Imports allowed: the names in `WORKFLOW_IMPORTS` and any
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 `policies/`
63
- * directory, or its own directory when it has neither. No `instanceof` against the runtime's
64
- * error classes: read `errorCode(err)`.
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", "policies"]);
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 || inside(root, join(dirname(filename), spec))) return undefined;
201
- const where =
202
- basename(root) === "workflows" || basename(root) === "policies" ? `${basename(root)}/` : "this directory";
203
- return `it reaches outside ${where}, toward the config and its drivers; ${THROUGH_CTX}`;
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 { basename, dirname, isAbsolute, relative, sep } from "node:path";
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, and the one
4
- // walk up the directories. Node's `path` only, no parser: the oxlint plugin loads it.
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 { z } from "zod";
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. Plain data, so a test can build one by hand. */
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
- covers?: Op[];
50
+ reasons?: string[];
57
51
  }
58
52
 
59
- const ReasonList = z.array(z.string(), { error: "reasons must be a list of strings" }).optional();
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`. `covers` lists other operations the approval also stands for. */
92
- export function approve(approver: Approver, { title, covers }: { title?: string; covers?: Op[] } = {}): Decision {
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
- ...(covers === undefined ? {} : { covers }),
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
- * or hold it until someone approves. Runs are replayed after a restart, so a policy must
104
- * decide the same way every time from the call alone: no clock, randomness or network.
105
- * `lintWorkflow` checks a policy file for these too. Each decision is recorded once made,
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 | Promise<Decision>) & {
110
- /** Names this version of the policy, from `definePolicy(fn, { version })`. */
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 policy, optionally named with a version so a reader can tell which rules decided a call. */
115
- export function definePolicy(
116
- policy: (call: PolicyCall) => Decision | Promise<Decision>,
117
- options: { version?: string } = {},
118
- ): Policy {
119
- if (typeof policy !== "function") throw new Error("definePolicy expects a function of the call");
120
- if (options.version !== undefined && (typeof options.version !== "string" || !options.version.trim())) {
121
- throw new Error("definePolicy: `version` must be a non-empty string");
122
- }
123
- const defined = (call: PolicyCall) => policy(call);
124
- return Object.freeze(options.version === undefined ? defined : Object.assign(defined, { version: options.version }));
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
- /** Allows every operation call. A config says so explicitly: `policy: allowAll`. */
128
- export const allowAll: Policy = definePolicy(() => allow());
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