@sanoma-ai/workflows 0.4.2 → 0.5.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 (54) hide show
  1. package/README.md +18 -9
  2. package/dist/cedar.d.ts +23 -6
  3. package/dist/cedar.js +151 -44
  4. package/dist/cedar.js.map +1 -1
  5. package/dist/client.d.ts +3 -2
  6. package/dist/client.js +15 -6
  7. package/dist/client.js.map +1 -1
  8. package/dist/config.d.ts +6 -1
  9. package/dist/config.js +3 -1
  10. package/dist/config.js.map +1 -1
  11. package/dist/describe.js +37 -3
  12. package/dist/describe.js.map +1 -1
  13. package/dist/host.d.ts +2 -0
  14. package/dist/host.js.map +1 -1
  15. package/dist/index.d.ts +3 -3
  16. package/dist/index.js +3 -3
  17. package/dist/index.js.map +1 -1
  18. package/dist/op.d.ts +55 -3
  19. package/dist/op.js +6 -0
  20. package/dist/op.js.map +1 -1
  21. package/dist/policy.d.ts +9 -1
  22. package/dist/policy.js +18 -7
  23. package/dist/policy.js.map +1 -1
  24. package/dist/resources.d.ts +4 -2
  25. package/dist/resources.js +23 -11
  26. package/dist/resources.js.map +1 -1
  27. package/dist/scenario.d.ts +1 -1
  28. package/dist/scenario.js +4 -2
  29. package/dist/scenario.js.map +1 -1
  30. package/dist/schema.js +1 -1
  31. package/dist/shared.d.ts +27 -2
  32. package/dist/shared.js +52 -3
  33. package/dist/shared.js.map +1 -1
  34. package/dist/sites.d.ts +35 -6
  35. package/dist/sites.js +129 -25
  36. package/dist/sites.js.map +1 -1
  37. package/dist/version.d.ts +2 -2
  38. package/dist/version.js +2 -2
  39. package/dist/version.js.map +1 -1
  40. package/package.json +1 -1
  41. package/src/cedar.ts +152 -41
  42. package/src/client.ts +17 -4
  43. package/src/config.ts +8 -2
  44. package/src/describe.ts +46 -3
  45. package/src/host.ts +2 -0
  46. package/src/index.ts +9 -0
  47. package/src/op.ts +56 -3
  48. package/src/policy.ts +29 -10
  49. package/src/resources.ts +26 -10
  50. package/src/scenario.ts +5 -2
  51. package/src/schema.ts +1 -1
  52. package/src/shared.ts +61 -3
  53. package/src/sites.ts +162 -30
  54. package/src/version.ts +4 -2
package/src/policy.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { type ApprovalState, type Approver, type Principal, sameApprover } from "./define.ts";
2
2
  import type { Effect, Op } from "./op.ts";
3
+ import { CALL_WORKFLOW, isVisitor } from "./shared.ts";
3
4
 
4
5
  /** An operation as a policy sees it: plain data, without its schemas. */
5
6
  export type PolicyOp = Pick<Op, "id" | "vendor" | "resource" | "name" | "effect">;
@@ -96,6 +97,8 @@ export interface PreviewRequest {
96
97
  principal: Principal;
97
98
  action: { id: string; effect?: Effect };
98
99
  resource?: string;
100
+ /** Asked of an operation inside a run the principal started, not of a direct call: a visitor's permit is at the start. */
101
+ inRun?: boolean;
99
102
  }
100
103
 
101
104
  /**
@@ -108,20 +111,36 @@ export interface PreviewAnswer {
108
111
  approver?: Approver;
109
112
  }
110
113
 
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
+ /** Why a visitor is refused what no policy permits it: the runtime's own permit leaves visitors out. */
115
+ export const VISITOR_DENIED = "A visitor may do nothing until a policy permits it";
116
+
117
+ /**
118
+ * Allows every operation call and run start but a visitor's (`isVisitor`): the policy of a repo
119
+ * with no `policies/**\/*.cedar`. A visitor is refused a start and a direct call (a call of the
120
+ * `call` workflow), as the runtime's permit refuses them, and allowed every call inside a run.
121
+ */
122
+ export const allowAll: Policy = Object.freeze(
123
+ Object.assign(
124
+ (call: PolicyCall): Decision =>
125
+ isVisitor(call.actor) && call.run.workflow === CALL_WORKFLOW ? deny(VISITOR_DENIED) : allow(),
126
+ {
127
+ start: (actor: Principal): Decision => (isVisitor(actor) ? deny(VISITOR_DENIED) : allow()),
128
+ preview: (requests: readonly PreviewRequest[]): PreviewAnswer[] =>
129
+ requests.map(({ principal, inRun }) => ({ answer: isVisitor(principal) && !inRun ? "deny" : "allow" })),
130
+ },
131
+ ),
132
+ );
133
+
134
+ /** Refuses every operation call and run start, and previews each as a deny, saying `reason`. */
135
+ export const denyAll = (reason: string): Policy => {
136
+ const decide = () => deny(reason);
137
+ return Object.freeze(
114
138
  Object.assign(decide, {
115
139
  start: decide,
116
- preview: (requests: readonly PreviewRequest[]): PreviewAnswer[] => requests.map(() => ({ answer })),
140
+ preview: (requests: readonly PreviewRequest[]): PreviewAnswer[] => requests.map(() => ({ answer: "deny" })),
117
141
  }),
118
142
  );
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");
143
+ };
125
144
 
126
145
  /**
127
146
  * True when an approval in the run was approved, covers the operation, and was addressed to
package/src/resources.ts CHANGED
@@ -19,12 +19,16 @@ import { Principal } from "./define.ts";
19
19
  import { NotAdmittedError } from "./errors.ts";
20
20
  import {
21
21
  AGENTS,
22
+ ANONYMOUS,
23
+ ANONYMOUS_PRINCIPAL,
22
24
  declaresPeople,
25
+ isVisitor,
23
26
  type PeopleResource,
24
27
  personOf,
25
28
  principalOf,
26
29
  problemLine,
27
30
  RUNTIME_AGENTS,
31
+ VISITORS,
28
32
  } from "./shared.ts";
29
33
 
30
34
  /** A resource a data file declares, as read from the file without running it. */
@@ -122,6 +126,13 @@ const filesStamp = (root: string) =>
122
126
  })
123
127
  .join("\n");
124
128
 
129
+ /** The names no data file may give a group: what each is already. */
130
+ const RESERVED_GROUPS: ReadonlyMap<string, string> = new Map([
131
+ [AGENTS, "the group every agent is in"],
132
+ [VISITORS, "the group every visitor is in"],
133
+ [ANONYMOUS, "who a public page runs as when nobody is signed in"],
134
+ ]);
135
+
125
136
  /**
126
137
  * The people, agents and groups the data files under `root` declare (`sanoma.person`, `.agent`,
127
138
  * `.group`), read without running them, as `principalOf` takes them, with what is wrong in their
@@ -140,22 +151,21 @@ export function readPeople(root: string | undefined): DeclaredPeople {
140
151
  const ids = new Map<string, DeclaredResource>();
141
152
  for (const r of people) {
142
153
  if (r.type === "group") {
143
- if (r.name === AGENTS) {
144
- own.push({
145
- file: r.file,
146
- message: `${r.id} is named "${AGENTS}", the group every agent is in: give it another name`,
147
- });
148
- }
154
+ const reserved = RESERVED_GROUPS.get(r.name);
155
+ if (reserved)
156
+ own.push({ file: r.file, message: `${r.id} is named "${r.name}", ${reserved}: give it another name` });
149
157
  continue;
150
158
  }
151
159
  const id = r.name;
152
160
  const other = ids.get(id);
153
- if (other || RUNTIME_AGENTS.includes(id)) {
161
+ if (other || RUNTIME_AGENTS.includes(id) || isVisitor({ id })) {
154
162
  own.push({
155
163
  file: r.file,
156
164
  message: other
157
165
  ? `${r.id} has the id "${id}", which ${other.id} has: give each person and agent their own`
158
- : `${r.id} has the id "${id}", one of the runtime's own agents': give it another`,
166
+ : isVisitor({ id })
167
+ ? `${r.id} has the id "${id}", a visitor's (\`anonymous\` or \`visitor:<login>:<subject>\`): give it another`
168
+ : `${r.id} has the id "${id}", one of the runtime's own agents': give it another`,
159
169
  });
160
170
  }
161
171
  ids.set(id, r);
@@ -169,8 +179,10 @@ export function readPeople(root: string | undefined): DeclaredPeople {
169
179
  * Who `asking` is admitted as, by the people the data files declare (`readPeople`): the declared
170
180
  * principal (`principalOf`) of the id it names, a person's or an agent's; for a sign-in
171
181
  * (`signIn`), of the person it matches (`personOf`: email, GitHub login, then id), never an agent.
172
- * While no person is declared, whoever names an id is taken as given. Throws `NotAdmittedError`
173
- * for anyone else, and for everyone while the people's files have problems.
182
+ * While no person is declared, whoever names an id is taken as given. A visitor's id (`isVisitor`)
183
+ * is taken whatever the files say, with the groups stamped here (`visitors`, or none for
184
+ * `anonymous`), never through a sign-in: the staff logins admit no visitor. Throws
185
+ * `NotAdmittedError` for anyone else, and for everyone while the people's files have problems.
174
186
  */
175
187
  export function admit(
176
188
  asking: { id?: string; email?: string; github?: string; groups?: string[]; onBehalfOf?: string },
@@ -178,6 +190,10 @@ export function admit(
178
190
  signIn = false,
179
191
  ): Principal {
180
192
  const who = asking.github ?? asking.id ?? asking.email ?? "This sign-in";
193
+ if (asking.id !== undefined && isVisitor({ id: asking.id })) {
194
+ if (signIn) throw new NotAdmittedError(who);
195
+ return asking.id === ANONYMOUS ? ANONYMOUS_PRINCIPAL : { id: asking.id, groups: [VISITORS] };
196
+ }
181
197
  if (problems.length) {
182
198
  // The runtime's own agents are declared by no file, so a broken one does not stop them (`check`'s scenarios).
183
199
  if (!signIn && asking.id !== undefined && RUNTIME_AGENTS.includes(asking.id)) {
package/src/scenario.ts CHANGED
@@ -50,6 +50,7 @@ import {
50
50
  principalIdsOf,
51
51
  problemLine,
52
52
  RUNTIME_AGENTS,
53
+ visitorPrincipal,
53
54
  } from "./shared.ts";
54
55
  import type { Call, RunView, Seed } from "./steps.ts";
55
56
 
@@ -143,7 +144,7 @@ export interface Scenario {
143
144
  text: string;
144
145
  /** Every step in order, for a list and for marking graph nodes. */
145
146
  steps: ScenarioStep[];
146
- /** Who starts the run, by id, when a `Given <who> is acting` says: a declared person or agent. */
147
+ /** Who starts the run, by id, when a `Given <who> is acting` says: a declared person or agent, `anonymous` or a visitor. */
147
148
  startedBy?: string;
148
149
  given: Given[];
149
150
  input: unknown;
@@ -404,7 +405,7 @@ function rulesOf(scope: Scope): { rules: Rule[]; parameterTypes: ParameterTypeSp
404
405
  const workflowOf = (name: unknown) => scope.workflows.get(name as string)!;
405
406
  // The people as the root's data files declare them when a step names one (`readPeople`, read
406
407
  // again only once a file changed): while they declare no one, any id and any group go.
407
- /** `who`, as a run's start admits them (`admit`): a declared person or agent. */
408
+ /** `who`, as a run's start admits them (`admit`): a declared person or agent, or `anonymous`. */
408
409
  const actorNamed = (who: string) => {
409
410
  const declared = readPeople(scope.root);
410
411
  try {
@@ -434,6 +435,8 @@ function rulesOf(scope: Scope): { rules: Rule[]; parameterTypes: ParameterTypeSp
434
435
  };
435
436
  const rules: Rule[] = [
436
437
  { kind: "actor", source: "{who} is acting", read: ([who]) => actorNamed(who as string) },
438
+ // A visitor of a public site, as a login would vouch for one; `anonymous` is a `{who}`.
439
+ { kind: "actor", source: "a visitor is acting", read: () => visitorPrincipal("scenario", "visitor").id },
437
440
  {
438
441
  kind: "given",
439
442
  source: "{op} was called with",
package/src/schema.ts CHANGED
@@ -67,7 +67,7 @@ const WRITES = new Set(["save", "delete"]);
67
67
  /**
68
68
  * The Cedar schema for a resolved config and its people, namespace `Sanoma`:
69
69
  *
70
- * - `entity Group enum [...]`, the declared groups and `agents`, so a policy naming another fails
70
+ * - `entity Group enum [...]`, the declared groups, `agents` and `visitors`, so a policy naming another fails
71
71
  * validation; `entity Principal in [Group] { onBehalfOf?: Principal }`; `Collection`;
72
72
  * `Target in [Collection]`; `Workflow`;
73
73
  * - an action per operation id, in its effect's group (`effect:money`), on a `Target`, with
package/src/shared.ts CHANGED
@@ -55,6 +55,34 @@ export const WORKFLOW_ACTION = "workflow:";
55
55
  /** A site's folder name under `sites/`, its URL segment: what the app mounts and `readSites` reads. */
56
56
  export const SITE_NAME = /^[a-z][a-z0-9-]*$/;
57
57
 
58
+ /** A public site's domain: a host name of two labels or more, lowercase (`sanoma.example`). */
59
+ export const DOMAIN_NAME = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/;
60
+
61
+ /**
62
+ * The public sites' domains as a host hands them to the app in `SANOMA_DOMAINS` (`HostSpec`'s
63
+ * `domains`): `<site>=<hostname>`, comma-separated, by site name.
64
+ */
65
+ export const domainsText = (domains: Readonly<Record<string, string>>): string =>
66
+ Object.keys(domains)
67
+ .toSorted()
68
+ .map((site) => `${site}=${domains[site]}`)
69
+ .join(",");
70
+
71
+ /** The domains `domainsText` wrote, by site name; throws for a pair that is not `<site>=<hostname>`, or a site named twice. */
72
+ export function domainsOf(text: string): Record<string, string> {
73
+ const domains: Record<string, string> = {};
74
+ for (const pair of text.split(",")) {
75
+ const [site = "", domain = "", ...rest] = pair.trim().split("=");
76
+ if (!SITE_NAME.test(site) || !DOMAIN_NAME.test(domain) || rest.length || Object.hasOwn(domains, site)) {
77
+ throw new Error(
78
+ `SANOMA_DOMAINS has "${pair}": write <site>=<hostname>, each site once, such as www=sanoma.example`,
79
+ );
80
+ }
81
+ domains[site] = domain;
82
+ }
83
+ return domains;
84
+ }
85
+
58
86
  /**
59
87
  * A record of a collection as `get`, `list` and `save` answer it: its key, the record, and when
60
88
  * and by whom it was last saved (`updatedBy`: a person's id, or `run:<id>` for a run). A record
@@ -196,6 +224,7 @@ export function refusalOf(
196
224
  run: DecidedRun,
197
225
  people: readonly PeopleResource[],
198
226
  ): string | undefined {
227
+ if (isVisitor(by)) return "a visitor decides no approval";
199
228
  if (typeof approver === "string") {
200
229
  return approver === by.id ? undefined : `${by.id} is not the approver; ${approverLabel(approver)} is`;
201
230
  }
@@ -213,6 +242,34 @@ export type PeopleResource = Pick<DeclaredResource, "vendor" | "type" | "desired
213
242
  /** The group every agent is in, the runtime's own among them: no data file declares it. */
214
243
  export const AGENTS = "agents";
215
244
 
245
+ /** The group every visitor of a public site is in: no data file declares it. */
246
+ export const VISITORS = "visitors";
247
+
248
+ /** The principal a public page runs as when nobody is signed in: no groups. */
249
+ export const ANONYMOUS = "anonymous";
250
+
251
+ /** `visitor:<login connector's vendor>:<provider subject>`: the only form a visitor's id takes. */
252
+ const VISITOR_ID = /^visitor:[^\s:]+:\S+$/;
253
+
254
+ /**
255
+ * True for `anonymous` and for a visitor's id: a principal no data file declares and the staff
256
+ * logins never admit. Marked by the id alone, since a principal crosses to the worker as JSON.
257
+ */
258
+ export const isVisitor = (p: Pick<Principal, "id">): boolean => p.id === ANONYMOUS || VISITOR_ID.test(p.id);
259
+
260
+ /**
261
+ * The visitor a public site's login vouches for: `subject` from the provider behind the `login`
262
+ * connector, in `visitors`. Throws for a login or subject that would make an id `isVisitor` does not tell.
263
+ */
264
+ export function visitorPrincipal(login: string, subject: string): Principal {
265
+ const id = `visitor:${login}:${subject}`;
266
+ if (!VISITOR_ID.test(id)) throw new Error(`"${id}" is not a visitor's id: visitor:<login connector>:<subject>`);
267
+ return { id, groups: [VISITORS] };
268
+ }
269
+
270
+ /** Who a public page runs as when nobody is signed in. */
271
+ export const ANONYMOUS_PRINCIPAL: Principal = Object.freeze({ id: ANONYMOUS, groups: [] });
272
+
216
273
  /** The runtime's own agents (scenarios, the host check, scheduled runs), declared whether a data file names them or not. */
217
274
  export const RUNTIME_AGENTS: readonly string[] = ["scenarios", "host-check", "schedule"];
218
275
 
@@ -224,9 +281,9 @@ type PersonFields = { id: string; name?: string; email?: string; github?: string
224
281
  type AgentFields = { id: string; onBehalfOf?: string };
225
282
  type GroupFields = { name: string; members: string[] };
226
283
 
227
- /** The groups a policy may name and a principal may be in: the declared ones and `agents`, sorted. */
284
+ /** The groups a policy may name and a principal may be in: the declared ones, `agents` and `visitors`, sorted. */
228
285
  export const groupNamesOf = (resources: readonly PeopleResource[]): string[] =>
229
- [...new Set([...peopleOfType<GroupFields>(resources, "group").map((g) => g.name), AGENTS])].toSorted();
286
+ [...new Set([...peopleOfType<GroupFields>(resources, "group").map((g) => g.name), AGENTS, VISITORS])].toSorted();
230
287
 
231
288
  /** The declared people (`sanoma.person`), in the order declared: not agents, who never sign in. */
232
289
  export const personsOf = (resources: readonly PeopleResource[]): PersonFields[] =>
@@ -261,9 +318,10 @@ export function principalOf(id: string, resources: readonly PeopleResource[]): P
261
318
 
262
319
  /**
263
320
  * Who `name` is as an approver: a declared group's name (or `agents`) as `{ group }`, else a
264
- * declared person's or agent's id; undefined when it is neither.
321
+ * declared person's or agent's id; undefined when it is neither, and for `visitors`, who decide nothing.
265
322
  */
266
323
  export function approverOf(name: string, resources: readonly PeopleResource[]): Approver | undefined {
324
+ if (name === VISITORS) return undefined;
267
325
  if (groupNamesOf(resources).includes(name)) return { group: name };
268
326
  return principalOf(name, resources) && name;
269
327
  }
package/src/sites.ts CHANGED
@@ -1,11 +1,21 @@
1
1
  import { existsSync, readdirSync } from "node:fs";
2
2
  import { join, relative, sep } from "node:path";
3
3
  import { childrenOf, type LintProblem, type Node, readSource, type SourceFile } from "./ast.ts";
4
+ import { type CedarPolicySource, visitorPermits } from "./cedar.ts";
4
5
  import { keyName } from "./datafile.ts";
5
6
  import type { ConfigDescription } from "./describe.ts";
6
7
  import { stringValue, unwrap } from "./outline.ts";
7
8
  import { filesUnder, isDirectory } from "./paths.ts";
8
- import { apiNameOf, DATA_VENDOR, SITE_NAME } from "./shared.ts";
9
+ import { START } from "./schema.ts";
10
+ import {
11
+ ANONYMOUS,
12
+ apiNameOf,
13
+ BUILT_IN_WORKFLOWS,
14
+ DATA_VENDOR,
15
+ SITE_NAME,
16
+ VISITORS,
17
+ WORKFLOW_ACTION,
18
+ } from "./shared.ts";
9
19
 
10
20
  // What a company's sites call, read from their source with oxc and never run: the reverse index
11
21
  // a workflow's page shows as Used by, and the names `sanoma check` holds to the config.
@@ -19,7 +29,13 @@ export interface SiteCall {
19
29
  * `api` gives; that name itself when none is), the collection's name, or the operation's id.
20
30
  */
21
31
  target: string;
22
- /** `<RunForm workflow="…" />` from `@sanoma-ai/app/ui`, or a call on `api` from `sanoma/site`. */
32
+ /**
33
+ * What it calls of the workflow (`start`, or a read of its runs: `result`, `latest`, …) or the
34
+ * collection (`list`, `save`, …), as written; absent for a workflow called as itself or by
35
+ * `RunForm`, which starts it, and for an operation.
36
+ */
37
+ method?: string;
38
+ /** `<RunForm workflow="…" />` or a call on `api`, from `sanoma/site`. */
23
39
  via: "RunForm" | "api";
24
40
  /** The file, relative to the config's root, with `/`: `sites/sales/routes/index.tsx`. */
25
41
  file: string;
@@ -41,17 +57,26 @@ export interface SiteEntry {
41
57
  name: string;
42
58
  /** `defineSite({ title })` in its `site.ts`, when that is a string literal. */
43
59
  title?: string;
60
+ /** `defineSite({ public: true })`: served to visitors, not people. */
61
+ public?: true;
62
+ /** `defineSite({ login })`, the connector whose login half signs visitors in, as written: one that names none is among `problems`. */
63
+ login?: string;
64
+ /** The host a public site answers on: set by `startApp` from its `domains`, never by `readSites`. */
65
+ domain?: string;
44
66
  /** Its calls, by file and then in the file's order. */
45
67
  calls: SiteCall[];
46
- /** Each call to a workflow, a collection or an operation the config does not have, or to an operation a page may not call directly, at its file. */
68
+ /**
69
+ * Each call to a workflow, a collection or an operation the config does not have, or to an
70
+ * operation a page may not call directly, or, on a public site, that no policy lets a visitor
71
+ * make, at its file; then what is wrong in its `site.ts`.
72
+ */
47
73
  problems: (LintProblem & { file: string })[];
48
74
  }
49
75
 
50
76
  /** Where a site's code is: its pages and its components. */
51
77
  const SITE_DIRS = ["routes", "components"];
52
- /** Where `api` and `defineSite` come from: `sanoma/site`, which re-exports the app's. */
78
+ /** Where `api`, `RunForm` and `defineSite` come from: `sanoma/site`, which re-exports the app's. */
53
79
  const SITE_SOURCES: ReadonlySet<string> = new Set(["sanoma/site", "@sanoma-ai/app/site"]);
54
- const UI_SOURCES: ReadonlySet<string> = new Set(["@sanoma-ai/app/ui"]);
55
80
  const KIND_NAMES = { workflow: "workflow", collection: "collection", op: "operation" } as const;
56
81
  /** What makes a route from its id, whose first argument is the id. */
57
82
  const ROUTE_MAKERS: ReadonlySet<string> = new Set(["createFileRoute", "createLazyFileRoute"]);
@@ -60,17 +85,25 @@ const ROUTE_MAKERS: ReadonlySet<string> = new Set(["createFileRoute", "createLaz
60
85
  * The sites under `root`: each folder of `sites/` with a `routes/` whose name is a URL segment,
61
86
  * by name (as `@sanoma-ai/app/build`'s `sitesOf` mounts them). Reads `routes/**` and
62
87
  * `components/**` (`.ts` and `.tsx`, less tests and declarations) of each with oxc, never running
63
- * them: each `<RunForm workflow="…" />` whose `RunForm` is imported from `@sanoma-ai/app/ui`, and
64
- * each use of `api` imported from `sanoma/site`, called or passed on (`queryFn: api.data.leads.list`):
88
+ * them: each `<RunForm workflow="…" />` and each use of `api`, both imported from `sanoma/site`,
89
+ * called or passed on (`queryFn: api.data.leads.list`):
65
90
  * `api.<name>` and `api.<name>.<method>` are a workflow, `api.data.<collection>.<op>` a collection,
66
91
  * and `api.<vendor>.<resource>.<op>` an operation. A name that is not written out (a variable, a
67
92
  * template with holes, a computed member) is skipped, as is a use through a namespace import or a
68
93
  * destructured `api`. A call to what `description` does not have, or to an operation a page may
69
- * not call directly, is a problem.
94
+ * not call directly, is a problem. So is a `login` in `site.ts` that does not name one of
95
+ * `connectors.logins` (the vendors whose connector has a login half) on a public site. Given the
96
+ * company's `policies` (undefined while they have problems, which refuse every call), so is a
97
+ * public site's start or direct call that no company `permit` could let a visitor make
98
+ * (`visitorPermits`): one for `anonymous`, or on a site with a login, one for `anonymous` or the
99
+ * `visitors`, whatever its conditions. Whatever the policies, so is a public site's call of a
100
+ * built-in workflow, and its read of a workflow's runs but `.result`, which `callApi` refuses a visitor.
70
101
  */
71
102
  export function readSites(
72
103
  root: string | undefined,
73
104
  description: Pick<ConfigDescription, "workflows" | "collections" | "ops">,
105
+ connectors: SiteConnectors,
106
+ policies?: readonly CedarPolicySource[],
74
107
  ): SiteEntry[] {
75
108
  if (root === undefined) return [];
76
109
  const dir = join(root, "sites");
@@ -80,6 +113,40 @@ export function readSites(
80
113
  const ops = new Map(description.ops.map((op) => [op.id, op]));
81
114
  // A workflow's api name to its name: two of one api name are a problem on the workflows.
82
115
  const byApiName = new Map(description.workflows.map((wf) => [apiNameOf(wf.name), wf.name]));
116
+ const permits = policies && visitorPermits(policies);
117
+
118
+ /**
119
+ * Why a visitor's `call`, which exists, is certainly refused on a public site that has `login`:
120
+ * as `callApi` refuses it whatever the policy says, or as no permit could allow it.
121
+ */
122
+ const refused = (call: SiteCall, login: string | undefined): string | undefined => {
123
+ const why = `a visitor's call to ${KIND_NAMES[call.kind]} "${call.target}" is refused`;
124
+ let action: string;
125
+ let group: string;
126
+ if (call.kind === "workflow") {
127
+ if (BUILT_IN_WORKFLOWS.has(call.target)) return `${why}: a visitor may not start the runtime's own workflows`;
128
+ if (call.method === "result") return undefined;
129
+ if (call.method !== undefined && call.method !== "start") {
130
+ return `${why}: a visitor reads only the result of a run it started`;
131
+ }
132
+ [action, group] = [`${WORKFLOW_ACTION}${call.target}`, START];
133
+ } else {
134
+ action = call.kind === "op" ? call.target : `${DATA_VENDOR}.${call.target}.${call.method}`;
135
+ const op = ops.get(action);
136
+ if (!op) return undefined;
137
+ group = `effect:${op.effect}`;
138
+ }
139
+ if (!permits) return undefined;
140
+ const [scopes, principal] =
141
+ login === undefined
142
+ ? [[ANONYMOUS], `== Sanoma::Principal::"${ANONYMOUS}"`]
143
+ : [[ANONYMOUS, VISITORS], `in Sanoma::Group::"${VISITORS}"`];
144
+ if (permits(new Set(scopes), action, group)) return undefined;
145
+ return (
146
+ `${why}: no permit in policies/ names ${scopes.join(" or ")} for ${action}. ` +
147
+ `Add: permit (principal ${principal}, action == Sanoma::Action::"${action}", resource);`
148
+ );
149
+ };
83
150
 
84
151
  /** A call as written, resolved as the app's `callApi` resolves it, with why it would fail. */
85
152
  const resolve = ({ kind, name, via, ...at }: Written): { call: SiteCall; problem?: string } => {
@@ -110,12 +177,17 @@ export function readSites(
110
177
  const file = readSource(path);
111
178
  return typeof file === "string" ? [] : usesIn(file, relative(root, path).split(sep).join("/")).map(resolve);
112
179
  });
113
- const title = titleOf(join(dir, name, "site.ts"));
180
+ const siteFile = `sites/${name}/site.ts`;
181
+ const { problems: optionProblems, ...options } = siteOptionsOf(join(root, siteFile), connectors);
114
182
  const calls = resolved.map((r) => r.call);
115
- const problems = resolved.flatMap(({ call: { file, line, column }, problem }) =>
116
- problem === undefined ? [] : [{ file, line, column, message: problem }],
117
- );
118
- return { name, ...(title !== undefined && { title }), calls, problems };
183
+ const problems = [
184
+ ...resolved.flatMap(({ call, problem }) => {
185
+ const message = problem ?? (options.public ? refused(call, options.login) : undefined);
186
+ return message === undefined ? [] : [{ file: call.file, line: call.line, column: call.column, message }];
187
+ }),
188
+ ...optionProblems.map((p) => ({ file: siteFile, ...p })),
189
+ ];
190
+ return { name, ...options, calls, problems };
119
191
  });
120
192
  }
121
193
 
@@ -125,7 +197,7 @@ type Written = Omit<SiteCall, "target"> & { name: string };
125
197
  /** The uses in one file, in its order. */
126
198
  function usesIn({ program, at }: SourceFile, file: string): Written[] {
127
199
  const api = importedAs(program, SITE_SOURCES, "api");
128
- const runForm = importedAs(program, UI_SOURCES, "RunForm");
200
+ const runForm = importedAs(program, SITE_SOURCES, "RunForm");
129
201
  if (!api.size && !runForm.size) return [];
130
202
  const found: Omit<Written, "page">[] = [];
131
203
  let page: string | undefined;
@@ -156,7 +228,7 @@ function usesIn({ program, at }: SourceFile, file: string): Written[] {
156
228
  }
157
229
 
158
230
  /** What a member path on `api` names; undefined for a path on anything else, a name not written out, or no name. */
159
- function apiPath(member: Node, api: ReadonlySet<string>): Pick<Written, "kind" | "name"> | undefined {
231
+ function apiPath(member: Node, api: ReadonlySet<string>): Pick<Written, "kind" | "name" | "method"> | undefined {
160
232
  const path: string[] = [];
161
233
  let node = member;
162
234
  while (node?.type === "MemberExpression") {
@@ -166,10 +238,12 @@ function apiPath(member: Node, api: ReadonlySet<string>): Pick<Written, "kind" |
166
238
  node = unwrap(node.object);
167
239
  }
168
240
  if (node?.type !== "Identifier" || !api.has(node.name)) return undefined;
169
- const [first, second] = path as [string, ...string[]];
170
- if (first === DATA_VENDOR) return path.length === 3 ? { kind: "collection", name: second! } : undefined;
241
+ const [first, second, third] = path as [string, ...string[]];
242
+ if (first === DATA_VENDOR)
243
+ return path.length === 3 ? { kind: "collection", name: second!, method: third } : undefined;
171
244
  if (path.length === 3) return { kind: "op", name: path.join(".") };
172
- return path.length <= 2 ? { kind: "workflow", name: first } : undefined;
245
+ if (path.length > 2) return undefined;
246
+ return { kind: "workflow", name: first, ...(second !== undefined && { method: second }) };
173
247
  }
174
248
 
175
249
  /** The local names `imported` has in the file, imported as a value from one of `sources`. */
@@ -187,22 +261,80 @@ function importedAs(program: Node, sources: ReadonlySet<string>, imported: strin
187
261
  return names;
188
262
  }
189
263
 
190
- /** A site's title: the string `title` its `site.ts` exports by default through `defineSite`. */
191
- function titleOf(path: string): string | undefined {
264
+ /** The connectors a site's `login` is checked against: the config's vendors, and those with a login half. */
265
+ type SiteConnectors = { vendors: ReadonlySet<string>; logins: ReadonlySet<string> };
266
+
267
+ /** A property of an object literal that is written out: not a spread, its name not computed. */
268
+ const isWritten = (p: Node) => p.type === "Property" && !p.computed;
269
+
270
+ /** What `site.ts` that `sanoma` cannot read without running it is told. */
271
+ const unread = (what: string, as: string) => `${what} is read without running site.ts: write it as ${as}`;
272
+
273
+ /**
274
+ * What `site.ts` says of its site, read without running it, and what is wrong there: its default
275
+ * export must be `defineSite({ … })` written out, so it can be read at all; then `title` when it is
276
+ * a string, `public` and `login`, which must be literals. `login` is kept as written; one on a
277
+ * site that is not public, or naming no connector of the config with a login half, is a problem.
278
+ */
279
+ function siteOptionsOf(
280
+ path: string,
281
+ connectors: SiteConnectors,
282
+ ): Pick<SiteEntry, "title" | "public" | "login"> & { problems: LintProblem[] } {
192
283
  const file = readSource(path);
193
- if (typeof file === "string") return undefined;
194
- const defineSite = importedAs(file.program, SITE_SOURCES, "defineSite");
195
- const exported = file.program.body.find((s: Node) => s.type === "ExportDefaultDeclaration")?.declaration;
196
- const call = unwrap(exported);
284
+ if (typeof file === "string") return { problems: [] };
285
+ const problems: LintProblem[] = [...file.problems];
286
+ const problem = (at: Node, message: string) => void problems.push({ ...file.at(at.start), message });
287
+ const exported = file.program.body.find((s: Node) => s.type === "ExportDefaultDeclaration");
288
+ if (!exported) return { problems };
289
+ const call = unwrap(exported.declaration);
197
290
  const callee = unwrap(call?.callee);
198
291
  const options = unwrap(call?.arguments?.[0]);
199
- if (call?.type !== "CallExpression" || callee?.type !== "Identifier" || !defineSite.has(callee.name))
292
+ const defineSite = importedAs(file.program, SITE_SOURCES, "defineSite");
293
+ // Anything not written out (a variable, a spread, a computed key) could make the site public unseen.
294
+ const written =
295
+ call?.type === "CallExpression" &&
296
+ callee?.type === "Identifier" &&
297
+ defineSite.has(callee.name) &&
298
+ (options === undefined || options.type === "ObjectExpression");
299
+ if (!written) {
300
+ problem(
301
+ exported,
302
+ unread("its default export", '`defineSite({ … })` from "sanoma/site", its options an object literal'),
303
+ );
304
+ return { problems };
305
+ }
306
+ for (const p of options?.properties ?? []) {
307
+ if (!isWritten(p)) problem(p, unread("each of `defineSite`'s options", "a property with its name"));
308
+ }
309
+ // The last of a name, as the object it makes has.
310
+ const property = (name: string): Node =>
311
+ options?.properties.findLast((p: Node) => isWritten(p) && keyName(p.key) === name);
312
+
313
+ const title = stringValue(property("title")?.value);
314
+ const publicAt = property("public");
315
+ const flag = unwrap(publicAt?.value);
316
+ const isPublic = flag?.type === "Literal" && flag.value === true;
317
+ const publicUnread = publicAt !== undefined && !(flag?.type === "Literal" && typeof flag.value === "boolean");
318
+ if (publicUnread) problem(publicAt, unread("`public`", "`true` or `false`"));
319
+
320
+ const loginAt = property("login");
321
+ const login = stringValue(loginAt?.value);
322
+ const loginProblem = (): string | undefined => {
323
+ if (login === undefined) return unread("`login`", "a string");
324
+ // When `public` is what is wrong, it is said there.
325
+ if (!isPublic && !publicUnread) return "a login is for a public site: add `public: true`, or remove `login`";
326
+ if (!connectors.vendors.has(login)) return `login names "${login}", which is not a connector in sanoma.config.ts`;
327
+ if (!connectors.logins.has(login)) return `login names "${login}", whose connector has no login half`;
200
328
  return undefined;
201
- if (options?.type !== "ObjectExpression") return undefined;
202
- const title = options.properties.find(
203
- (p: Node) => p.type === "Property" && !p.computed && keyName(p.key) === "title",
204
- );
205
- return stringValue(title?.value);
329
+ };
330
+ const message = loginAt && loginProblem();
331
+ if (message) problem(loginAt, message);
332
+ return {
333
+ ...(title !== undefined && { title }),
334
+ ...(isPublic && { public: true as const }),
335
+ ...(login !== undefined && { login }),
336
+ problems,
337
+ };
206
338
  }
207
339
 
208
340
  /**
package/src/version.ts CHANGED
@@ -5,8 +5,10 @@ import { dirname, join } from "node:path";
5
5
  import { jsonSchemaOf, opIdsOf, type WorkflowDefinition } from "./define.ts";
6
6
  import { upFrom } from "./paths.ts";
7
7
 
8
- /** This package's version. A unit test keeps it equal to package.json. */
9
- export const RUNTIME_VERSION = "0.4.2";
8
+ /** This package's version, from its package.json: one level above `src/` and `dist/` alike. */
9
+ export const RUNTIME_VERSION = (
10
+ JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")) as { version: string }
11
+ ).version;
10
12
 
11
13
  /**
12
14
  * The sequence of DBOS calls the runtime makes around each `ctx` call: policy step, approval