@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/cedar.ts CHANGED
@@ -18,25 +18,30 @@ import {
18
18
  type PolicyCall,
19
19
  type PreviewAnswer,
20
20
  type PreviewRequest,
21
+ VISITOR_DENIED,
21
22
  } from "./policy.ts";
22
- import { readPeople } from "./resources.ts";
23
+ import { admit, readPeople } from "./resources.ts";
23
24
  import { cedarValue, cedarWasm, contextTypeOf, messageOf, NAMESPACE, schemaOf } from "./schema.ts";
24
25
  import {
26
+ ANONYMOUS,
25
27
  approverLabel,
26
28
  approverOf,
29
+ CALL_WORKFLOW,
27
30
  DATA_VENDOR,
28
31
  groupNamesOf,
32
+ isVisitor,
29
33
  type PeopleResource,
30
34
  principalIdsOf,
31
35
  principalOf,
32
36
  problemLine,
37
+ VISITORS,
33
38
  WORKFLOW_ACTION,
34
39
  } from "./shared.ts";
35
40
 
36
41
  // A company's policies in Cedar (`policies/**/*.cedar`), decided with `@cedar-policy/cedar-wasm`
37
42
  // against the schema of its resolved config (`schemaOf`). Allow is the default: the runtime adds
38
- // the one `permit`, so a company's policies are `forbid`s, each a hold when it carries
39
- // `@hold(...)` and a deny otherwise.
43
+ // the one `permit` for people and agents, so a company's policies are `forbid`s, each a hold when
44
+ // it carries `@hold(...)` and a deny otherwise, and the `permit`s that let visitors in.
40
45
 
41
46
  /** Where a company's policies are, under its root. */
42
47
  export const POLICIES_DIR = "policies";
@@ -58,6 +63,8 @@ export interface CedarPolicySource {
58
63
  title?: string;
59
64
  /** `@reason(...)`: the message of its deny. */
60
65
  reason?: string;
66
+ /** Whom a `permit` is for, when its principal is exactly `in Sanoma::Group::"visitors"` or `== Sanoma::Principal::"anonymous"`. */
67
+ scope?: typeof VISITORS | typeof ANONYMOUS;
61
68
  }
62
69
 
63
70
  /** What is wrong in the policies, at a file, line and column when Cedar says where. */
@@ -70,7 +77,10 @@ export interface PolicyProblem {
70
77
  message: string;
71
78
  }
72
79
 
73
- /** The runtime's own policy: allow whatever no `forbid` refuses. */
80
+ /**
81
+ * The runtime's own policy: allow whatever no `forbid` refuses. A visitor's start or direct call
82
+ * is asked of the company's permits first (`cedarPolicy`): the runtime's permit leaves visitors out there.
83
+ */
74
84
  const ALLOW_ID = "allow-by-default";
75
85
  const ALLOW = "permit (principal, action, resource);";
76
86
 
@@ -132,7 +142,7 @@ export function loadPolicies(files: readonly { file: string; text: string }[]):
132
142
  problems.push(...answer.errors.map((e) => problemOf(e, file, part, start)));
133
143
  return;
134
144
  }
135
- const { effect, annotations = {} } = answer.json;
145
+ const { effect, principal, annotations = {} } = answer.json;
136
146
  const id = annotations.id ?? `${file}#${n + 1}`;
137
147
  if (ids.has(id)) {
138
148
  const whose = id === ALLOW_ID ? "the runtime's own policy's" : "another policy's";
@@ -149,16 +159,28 @@ export function loadPolicies(files: readonly { file: string; text: string }[]):
149
159
  ...(annotations.hold !== undefined && { hold: annotations.hold }),
150
160
  ...(annotations.title !== undefined && { title: annotations.title }),
151
161
  ...(annotations.reason !== undefined && { reason: annotations.reason }),
162
+ ...scopeOf(principal),
152
163
  });
153
164
  });
154
165
  }
155
166
  return { policies, problems };
156
167
  }
157
168
 
169
+ /** A permit's `scope`: whom its principal names, when that is exactly the visitors or `anonymous`. */
170
+ function scopeOf(principal: PolicyJson["principal"]): Pick<CedarPolicySource, "scope"> {
171
+ if (!("entity" in principal) || !("id" in principal.entity)) return {};
172
+ const { op, entity } = principal;
173
+ const is = ({ type, id }: { type: string; id: string }) => entity.type === type && entity.id === id;
174
+ if (op === "in" && is(uid("Group", VISITORS))) return { scope: VISITORS };
175
+ if (op === "==" && is(uid("Principal", ANONYMOUS))) return { scope: ANONYMOUS };
176
+ return {};
177
+ }
178
+
158
179
  /**
159
180
  * The policies checked against the schema of the resolved config (`validate`, strict), and what
160
- * Cedar cannot check: a `permit` (the runtime's is the only one), and a `@hold` naming neither a
161
- * declared group, nor a declared person, nor `on-behalf-of`. In the order written, each at its
181
+ * Cedar cannot check: a `permit` for anyone but the visitors or `anonymous` (the runtime's is the
182
+ * only one for people and agents), and a `@hold` naming neither a declared group, nor a declared
183
+ * person, nor `on-behalf-of`. In the order written, each at its
162
184
  * file, line and column, once per place: a policy scoped to every action fails the same way for each.
163
185
  */
164
186
  export function validatePolicies(
@@ -187,7 +209,16 @@ export function validatePolicies(
187
209
  .toSorted((a, b) => a.line! - b.line! || (a.column ?? 0) - (b.column ?? 0));
188
210
  const ours: PolicyProblem[] = [];
189
211
  if (p.effect === "permit") {
190
- ours.push({ ...place, message: "a permit changes nothing: every call no forbid refuses is allowed already" });
212
+ if (p.scope === undefined) {
213
+ ours.push({
214
+ ...place,
215
+ message:
216
+ 'a permit is allowed only for visitors: its principal must be `in Sanoma::Group::"visitors"` or ' +
217
+ '`== Sanoma::Principal::"anonymous"`; for people and agents every call no forbid refuses is allowed already',
218
+ });
219
+ } else if (p.hold !== undefined) {
220
+ ours.push({ ...place, message: "a permit holds nothing: write the @hold on a forbid" });
221
+ }
191
222
  } else if (p.hold !== undefined && p.hold !== ON_BEHALF_OF && approverOf(p.hold, people) === undefined) {
192
223
  ours.push({ ...place, message: `@hold("${p.hold}") names no declared group or person, nor "${ON_BEHALF_OF}"` });
193
224
  }
@@ -197,9 +228,14 @@ export function validatePolicies(
197
228
 
198
229
  /**
199
230
  * The policy a repo's Cedar files make, the runtime's `permit` added; its `version` a hash of the
200
- * policies' ids and text. It decides each call with `statefulIsAuthorized`
201
- * over the policy set and the schema of `resolved` (`schemaOf`), each parsed once:
231
+ * policies' ids and text. The runtime's permit leaves visitors out (`isVisitor`) of a run's start,
232
+ * a direct call (a call of the `call` workflow) and a preview not `inRun`: those are asked of the
233
+ * company's permits alone first. Any other call in a run is decided as anyone's, so a visitor let
234
+ * start a run makes its calls, forbids holding still. It decides each call with
235
+ * `statefulIsAuthorized` over the policy set and the schema of `resolved` (`schemaOf`), each parsed once:
202
236
  *
237
+ * - a visitor's start or direct call no company `permit` allows: `deny`, `VISITOR_DENIED`, never
238
+ * held, so a visitor cannot ask anyone for what no policy lets it do;
203
239
  * - allow: `allow`, with the policies that allowed as `reasons`;
204
240
  * - deny, a determining `forbid` without `@hold`: `deny` with its `@reason`, or "Refused by <id>";
205
241
  * - deny by `@hold` forbids only: each lifted once an approval addressed to its approver and
@@ -221,14 +257,22 @@ export function cedarPolicy(
221
257
  const cedar = cedarWasm();
222
258
  const schema = schemaOf(resolved, people);
223
259
  const staticPolicies = { ...Object.fromEntries(policies.map((p) => [p.id, p.text])), [ALLOW_ID]: ALLOW };
260
+ // What lets a visitor in at a start or a direct call: the company's permits alone, the forbids aside.
261
+ const permits = Object.fromEntries(policies.filter((p) => p.effect === "permit").map((p) => [p.id, p.text]));
224
262
  const version = hash(staticPolicies);
225
- // Cedar caches both per thread, by name: one for these policies over this schema.
263
+ // Cedar caches both per thread, by name: one for these policies over this schema, and its permits.
226
264
  const set = `sanoma-${hash({ staticPolicies, schema })}`;
227
- for (const answer of [cedar.preparsePolicySet(set, { staticPolicies }), cedar.preparseSchema(set, schema)]) {
265
+ const permitsSet = `${set}-permits`;
266
+ for (const answer of [
267
+ cedar.preparsePolicySet(set, { staticPolicies }),
268
+ cedar.preparsePolicySet(permitsSet, { staticPolicies: permits }),
269
+ cedar.preparseSchema(set, schema),
270
+ ]) {
228
271
  if (answer.type === "failure") {
229
272
  throw new Error(`The Cedar policies do not parse: ${answer.errors.map(messageOf).join("; ")}`);
230
273
  }
231
274
  }
275
+ const hasPermits = Object.keys(permits).length > 0;
232
276
  const directory = directoryOf(people);
233
277
 
234
278
  /**
@@ -270,33 +314,50 @@ export function cedarPolicy(
270
314
  };
271
315
  };
272
316
 
273
- /** May `actor` take `action` on `target` with `context`, given the run's `approvals` so far. */
317
+ /**
318
+ * May `actor` take `action` on `target` with `context`, given the run's `approvals` so far; at a
319
+ * start or a direct call (`atStart`), a visitor only where a company permit lets it in.
320
+ */
274
321
  const authorize = (
275
322
  actor: Principal,
276
323
  action: string,
277
324
  target: string | undefined,
278
325
  context: Record<string, unknown>,
279
326
  approvals: PolicyCall["run"]["approvals"],
327
+ atStart: boolean,
280
328
  ): Decision => {
281
329
  const principal = directory.principal(actor);
282
- const check = (asked: Record<string, unknown>) =>
283
- cedar.statefulIsAuthorized({
284
- ...requestOf(principal, action, target, () => asked),
285
- preparsedSchemaName: set,
286
- validateRequest: true,
287
- preparsedPolicySetId: set,
288
- });
289
- let answer = check(context);
290
- // A record saved under fields since changed does not fit the schema as `before`. When the rest
291
- // of the call does, it is asked again without the schema (the context is plain values), the
292
- // action's groups given as entities: a policy reading a field the record lacks fails, so denies.
293
- const { before, ...rest } = context;
294
- if (answer.type === "failure" && before !== undefined && check(rest).type === "success") {
330
+ /** The answer of the policy set `id`. */
331
+ const ask = (id: string) => {
332
+ const check = (asked: Record<string, unknown>) =>
333
+ cedar.statefulIsAuthorized({
334
+ ...requestOf(principal, action, target, () => asked),
335
+ preparsedSchemaName: set,
336
+ validateRequest: true,
337
+ preparsedPolicySetId: id,
338
+ });
339
+ const answer = check(context);
340
+ // A record saved under fields since changed does not fit the schema as `before`. When the rest
341
+ // of the call does, it is asked again without the schema (the context is plain values), the
342
+ // action's groups given as entities: a policy reading a field the record lacks fails, so denies.
343
+ const { before, ...rest } = context;
344
+ if (answer.type === "success" || before === undefined || check(rest).type === "failure") return answer;
295
345
  const request = requestOf(principal, action, target, () => context);
296
346
  const groups = schema[NAMESPACE]?.actions[action]?.memberOf ?? [];
297
347
  request.entities.push({ uid: request.action, attrs: {}, parents: groups.map((g) => uid("Action", g.id)) });
298
- answer = cedar.statefulIsAuthorized({ ...request, preparsedPolicySetId: set });
348
+ return cedar.statefulIsAuthorized({ ...request, preparsedPolicySetId: id });
349
+ };
350
+ // Refused, never held: a visitor cannot ask anyone for what no policy lets it do.
351
+ if (atStart && isVisitor(principal)) {
352
+ const permitted = hasPermits ? ask(permitsSet) : undefined;
353
+ // A request the schema refuses is told below, as anyone's; a permit that fails, by its id.
354
+ if (permitted?.type !== "failure" && permitted?.response.decision !== "allow") {
355
+ const failed = permitted?.response.diagnostics.errors[0];
356
+ if (failed) return deny(`${failed.policyId} failed: ${messageOf(failed.error)}`, [failed.policyId]);
357
+ return deny(VISITOR_DENIED);
358
+ }
299
359
  }
360
+ const answer = ask(set);
300
361
  // A request the schema refuses (an input Cedar cannot hold) is denied, not an error of the call.
301
362
  if (answer.type === "failure")
302
363
  return deny(`The call does not fit the Cedar schema: ${answer.errors.map(messageOf).join("; ")}`);
@@ -326,22 +387,34 @@ export function cedarPolicy(
326
387
  call.target,
327
388
  { input: call.input, ...(call.before !== undefined && { before: call.before }) },
328
389
  call.run.approvals,
390
+ call.run.workflow === CALL_WORKFLOW,
329
391
  );
330
392
 
331
393
  const start = (actor: Principal, workflow: string, input: unknown, approvals: readonly ApprovalState[] = []) =>
332
- authorize(actor, `${WORKFLOW_ACTION}${workflow}`, undefined, { input }, approvals);
394
+ authorize(actor, `${WORKFLOW_ACTION}${workflow}`, undefined, { input }, approvals, true);
333
395
 
334
396
  const preview = (requests: readonly PreviewRequest[]): PreviewAnswer[] =>
335
- requests.map(({ principal: actor, action, resource }) => {
397
+ requests.map(({ principal: actor, action, resource, inRun }) => {
336
398
  const principal = directory.principal(actor);
337
- const answer = cedar.isAuthorizedPartial({
338
- ...requestOf(principal, action.id, resource, unknown),
339
- schema,
340
- validateRequest: false,
341
- policies: { staticPolicies },
342
- });
343
- if (answer.type === "failure") throw new Error(answer.errors.map(messageOf).join("; "));
344
- const { decision, mustBeDetermining, nontrivialResiduals, residuals } = answer.response;
399
+ const partial = (asked: Record<string, string>) => {
400
+ const answer = cedar.isAuthorizedPartial({
401
+ ...requestOf(principal, action.id, resource, unknown),
402
+ schema,
403
+ validateRequest: false,
404
+ policies: { staticPolicies: asked },
405
+ });
406
+ if (answer.type === "failure") throw new Error(answer.errors.map(messageOf).join("; "));
407
+ return answer.response;
408
+ };
409
+ if (!inRun && isVisitor(principal)) {
410
+ const gate = hasPermits ? partial(permits) : undefined;
411
+ if (gate?.decision !== "allow") {
412
+ // A permit the input decides: the answer depends on it, whatever the rest says.
413
+ const open = policies.find((p) => gate?.nontrivialResiduals.includes(p.id));
414
+ return open ? { answer: "depends", rule: ruleOf(open) } : { answer: "deny" };
415
+ }
416
+ }
417
+ const { decision, mustBeDetermining, nontrivialResiduals, residuals } = partial(staticPolicies);
345
418
  if (decision === "allow") return { answer: "allow" };
346
419
  if (decision === "deny") {
347
420
  const { refusing, holds } = forbidsOf(mustBeDetermining, principal);
@@ -357,23 +430,59 @@ export function cedarPolicy(
357
430
  return Object.freeze(Object.assign(decide, { version, preview, start }));
358
431
  }
359
432
 
433
+ /**
434
+ * Whether a company `permit` could let a visitor take `action`, in its action group `group`
435
+ * (`start`, or its operation's `effect:<effect>`): asked of the permits whose `scope` is in
436
+ * `scopes`, each with its principal taken as its scope says and its `when` and `unless` left out,
437
+ * on the workflow a start names, or on any target. False means none can apply, so a visitor's
438
+ * start or direct call of it is refused whatever its input (`VISITOR_DENIED`).
439
+ */
440
+ export function visitorPermits(
441
+ policies: readonly CedarPolicySource[],
442
+ ): (scopes: ReadonlySet<string>, action: string, group: string) => boolean {
443
+ const cedar = cedarWasm();
444
+ const permits = policies.flatMap(({ id, effect, scope, text }) => {
445
+ if (effect !== "permit" || scope === undefined) return [];
446
+ const answer = cedar.policyToJson(text);
447
+ if (answer.type !== "success") return [];
448
+ const json: PolicyJson = { ...answer.json, principal: { op: "All" }, conditions: [] };
449
+ return [{ id, scope, json }];
450
+ });
451
+ return (scopes, action, group) => {
452
+ const asked = permits.filter((p) => scopes.has(p.scope));
453
+ if (!asked.length) return false;
454
+ const uidOf = uid("Action", action);
455
+ const answer = cedar.isAuthorizedPartial({
456
+ principal: uid("Principal", ANONYMOUS),
457
+ action: uidOf,
458
+ // An operation's target is the call's: unknown, so a permit naming one may apply.
459
+ resource: action.startsWith(WORKFLOW_ACTION) ? uid("Workflow", action.slice(WORKFLOW_ACTION.length)) : null,
460
+ context: {},
461
+ entities: [{ uid: uidOf, attrs: {}, parents: [uid("Action", group)] }],
462
+ policies: { staticPolicies: Object.fromEntries(asked.map((p) => [p.id, p.json])) },
463
+ });
464
+ // A request Cedar cannot evaluate is not a refusal this can be sure of.
465
+ return answer.type === "failure" || answer.response.decision !== "deny";
466
+ };
467
+ }
468
+
360
469
  /**
361
470
  * The policy of the repo at `root`: its `policies/**\/*.cedar` over `resolved` and the people its
362
471
  * data files declare (`readPeople`, `cedarPolicy`), or `allowAll` when it has none. With problems
363
472
  * (`loadPolicies`, `validatePolicies`), each a printable line (`file:line:col: <policy id>:
364
473
  * <message>`), a policy that refuses every call, start and preview naming the first: a repo never
365
- * runs on part of its rules.
474
+ * runs on part of its rules. `policies` are what it is made from: undefined while they have problems.
366
475
  */
367
476
  export function policyAt(
368
477
  root: string | undefined,
369
478
  resolved: Pick<ResolvedConfig, "ops" | "workflows" | "collections">,
370
- ): { policy: Policy; problems: string[] } {
479
+ ): { policy: Policy; policies?: CedarPolicySource[]; problems: string[] } {
371
480
  const files = root === undefined ? [] : readPolicyFiles(root);
372
- if (!files.length) return { policy: allowAll, problems: [] };
481
+ if (!files.length) return { policy: allowAll, policies: [], problems: [] };
373
482
  const { people } = readPeople(root);
374
483
  const { policies, problems } = loadPolicies(files);
375
484
  problems.push(...validatePolicies(resolved, policies, people));
376
- if (!problems.length) return { policy: cedarPolicy(resolved, policies, people), problems: [] };
485
+ if (!problems.length) return { policy: cedarPolicy(resolved, policies, people), policies, problems: [] };
377
486
  const lines = problems.map(({ policy, ...p }) =>
378
487
  problemLine({ ...p, message: policy ? `${policy}: ${p.message}` : p.message }),
379
488
  );
@@ -411,6 +520,8 @@ interface Directory {
411
520
  function directoryOf(people: readonly PeopleResource[]): Directory {
412
521
  const groups = new Set(groupNamesOf(people));
413
522
  const principal = (actor: Principal): Principal => {
523
+ // A visitor is told by its id, as `admit` takes it: never in a declared group.
524
+ if (isVisitor(actor)) return admit(actor, { people, problems: [] });
414
525
  // An admitted principal (`admit`) carries its groups as the files say now: those are taken as
415
526
  // they are. One without groups is given those declared when the policy was made.
416
527
  const declared = actor.groups === undefined ? principalOf(actor.id, people) : undefined;
package/src/client.ts CHANGED
@@ -19,6 +19,7 @@ import {
19
19
  type ErrorInfo,
20
20
  errorCode,
21
21
  errorInfo,
22
+ NotAdmittedError,
22
23
  parseOrThrow,
23
24
  PolicyDeniedError,
24
25
  SanomaError,
@@ -35,6 +36,7 @@ import {
35
36
  DATA_VENDOR,
36
37
  errorMessage,
37
38
  isEnded,
39
+ isVisitor,
38
40
  refusalOf,
39
41
  type RunStatus,
40
42
  WORKFLOW_ACTION,
@@ -263,7 +265,8 @@ export class SanomaClient {
263
265
  /**
264
266
  * The policy's answer to starting a run of `workflow` as `startedBy` (`decideStart`), for a
265
267
  * workflow of the config's own: a deny throws `policy_denied`; a hold is what the run waits on
266
- * before its body. A run `runId` names already is returned as it is, whatever the policy says now.
268
+ * before its body. A run `runId` names already is returned as it is, whatever the policy says now, but to a visitor.
269
+ * A visitor (`isVisitor`) starts no built-in but `call`, whose calls the policy decides as direct ones.
267
270
  */
268
271
  private async startHold(
269
272
  workflow: string,
@@ -272,8 +275,17 @@ export class SanomaClient {
272
275
  runId: string | undefined,
273
276
  ): Promise<StartHold | undefined> {
274
277
  const { policy } = this.config;
275
- if (BUILT_IN_WORKFLOWS.has(workflow) || policy === allowAll) return undefined;
276
- if (runId !== undefined && (await this.row(runId))) return undefined;
278
+ const visitor = isVisitor(startedBy);
279
+ const builtIn = BUILT_IN_WORKFLOWS.has(workflow);
280
+ if (visitor && builtIn && workflow !== CALL_WORKFLOW) {
281
+ throw new PolicyDeniedError(
282
+ `${WORKFLOW_ACTION}${workflow}`,
283
+ "A visitor may not start the runtime's own workflows",
284
+ );
285
+ }
286
+ if (builtIn || (policy === allowAll && !visitor)) return undefined;
287
+ // A visitor's start is decided whatever run its id names: a visitor shares an id (`anonymous`) with others.
288
+ if (!visitor && runId !== undefined && (await this.row(runId))) return undefined;
277
289
  const decision = decideStart(policy, workflow, input, startedBy);
278
290
  if (decision.kind === "allow") return undefined;
279
291
  if (decision.kind === "deny") throw new PolicyDeniedError(`${WORKFLOW_ACTION}${workflow}`, decision.reason);
@@ -805,9 +817,10 @@ export class SanomaClient {
805
817
  return admit(principal, readPeople(this.config.root));
806
818
  }
807
819
 
808
- /** `options.by`, who sets or clears a credential, admitted (`admitted`): their id. */
820
+ /** `options.by`, who sets or clears a credential, admitted (`admitted`): their id; never a visitor's. */
809
821
  private async admittedBy(options: { by: string }): Promise<string> {
810
822
  const by = parseOrThrow(z.string().min(1), options?.by, '`by` must name who is asking, such as "alice"');
823
+ if (isVisitor({ id: by })) throw new NotAdmittedError(by);
811
824
  return (await this.admitted({ id: by })).id;
812
825
  }
813
826
 
package/src/config.ts CHANGED
@@ -32,7 +32,7 @@ import {
32
32
  setupOf,
33
33
  VENDOR,
34
34
  } from "./op.ts";
35
- import { policyAt } from "./cedar.ts";
35
+ import { type CedarPolicySource, policyAt } from "./cedar.ts";
36
36
  import type { Policy } from "./policy.ts";
37
37
  import { isChangeOp, type Resource, resourceTypesOf, withoutWriteOnly } from "./resource.ts";
38
38
  import { APPLY_WORKFLOW, BUILT_IN_WORKFLOWS, CALL_WORKFLOW, DATA_VENDOR } from "./shared.ts";
@@ -120,6 +120,8 @@ export interface ResolvedConfig {
120
120
  driversByVendor: Map<string, Driver>;
121
121
  /** The makers of the fakes the connectors attach, by vendor id in `connectors` order: each sandbox run makes its own. */
122
122
  fakes: Map<string, () => Fake<any, any>>;
123
+ /** The makers of the login halves the connectors attach, by vendor id in `connectors` order: the app makes one per public site that names it. */
124
+ logins: ReadonlyMap<string, NonNullable<ConnectorSetup["login"]>>;
123
125
  /** `scenarios/` in `root`, as a `file:` URL, when that directory exists. */
124
126
  scenarios?: URL;
125
127
  /** The resource types the connectors declare, by `<vendor>.<type>`: what the drift workflow checks and scenarios seed. */
@@ -145,6 +147,8 @@ export interface ResolvedConfig {
145
147
  * (`policyAt`), `allowAll` without any, and one that refuses everything while they have problems.
146
148
  */
147
149
  policy: Policy;
150
+ /** The policies `policy` is made from (`loadPolicies`), none without any; undefined while they have problems. */
151
+ policies?: readonly CedarPolicySource[];
148
152
  /** What is wrong in the policies, one printable line each (`file:line:col: <policy id>: <message>`). */
149
153
  policyProblems: string[];
150
154
  /** The config's `ledger`, when it gives one; the worker and the client otherwise make `postgresLedger` on `databaseUrl` (`ownedLedger`). */
@@ -278,7 +282,7 @@ export function resolveConfig(config: SanomaConfig): ResolvedConfig {
278
282
  // Built-in: a direct call the policy holds for an approval, made a run, when any operation may be called directly.
279
283
  const call = callWorkflow(ops, drivers, types);
280
284
  if (call.uses.length) names.set(CALL_WORKFLOW, call);
281
- const { policy, problems: policyProblems } = policyAt(root, { ops, workflows: names, collections });
285
+ const { policy, policies, problems: policyProblems } = policyAt(root, { ops, workflows: names, collections });
282
286
  return {
283
287
  appName,
284
288
  databaseUrl,
@@ -289,6 +293,7 @@ export function resolveConfig(config: SanomaConfig): ResolvedConfig {
289
293
  drivers,
290
294
  driversByVendor,
291
295
  fakes,
296
+ logins: makersOf(config.connectors, "login"),
292
297
  ...(scenarios && { scenarios }),
293
298
  types,
294
299
  steps: [...new Set([...config.connectors.flatMap((c) => setupOf(c).steps ?? []), ...(config.steps ?? [])])],
@@ -296,6 +301,7 @@ export function resolveConfig(config: SanomaConfig): ResolvedConfig {
296
301
  ...(dataStore && { dataStore }),
297
302
  workflows: names,
298
303
  policy,
304
+ ...(policies && { policies }),
299
305
  policyProblems,
300
306
  ...(ledger !== undefined && { ledger }),
301
307
  ...(root !== undefined && { root }),
package/src/describe.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { join } from "node:path";
1
2
  import type { z } from "zod";
2
3
  import { type LintProblem, readSource, type SourceFile } from "./ast.ts";
3
4
  import { type Field, recordSchemaOf } from "./collection.ts";
@@ -8,7 +9,7 @@ import { lintWorkflow } from "./lint.ts";
8
9
  import { type Effect, VENDOR, type VendorInfo } from "./op.ts";
9
10
  import { type Outline, outlineWithSource } from "./outline.ts";
10
11
  import { allowAll, policyOpOf } from "./policy.ts";
11
- import { type ResourceFields, resourceTypesOf } from "./resource.ts";
12
+ import { type Resource, type ResourceFields, resourceTypesOf, valueAt } from "./resource.ts";
12
13
  import {
13
14
  type DataFiles,
14
15
  type DeclaredCollection,
@@ -303,6 +304,12 @@ export function describeConfig(config: SanomaConfig): ConfigDescription {
303
304
  });
304
305
  workflows.sort((a, b) => a.name.localeCompare(b.name));
305
306
  addApiNameProblems(workflows, new Set(ops.map((op) => op.vendor)));
307
+ const sites = readSites(
308
+ resolved.root,
309
+ { workflows, collections, ops },
310
+ { vendors: new Set(Object.keys(vendors)), logins: new Set(resolved.logins.keys()) },
311
+ resolved.policies,
312
+ );
306
313
 
307
314
  return {
308
315
  appName: resolved.appName,
@@ -314,16 +321,52 @@ export function describeConfig(config: SanomaConfig): ConfigDescription {
314
321
  collections,
315
322
  resources: dataFiles.resources,
316
323
  orphans: dataFiles.orphans,
317
- problems: dataFiles.problems,
324
+ problems: [...dataFiles.problems, ...domainProblems(resolved.root, dataFiles.resources, resources, sites, read)],
318
325
  policy: {
319
326
  defined: resolved.policy !== allowAll,
320
327
  ...(resolved.policy.version === undefined ? {} : { version: resolved.policy.version }),
321
328
  ...(resolved.policyProblems.length ? { problems: resolved.policyProblems } : {}),
322
329
  },
323
- sites: readSites(resolved.root, { workflows, collections, ops }),
330
+ sites,
324
331
  };
325
332
  }
326
333
 
334
+ /**
335
+ * Each site a host's `domains` (its type's `host.inputs.domains`) names that is not a public
336
+ * site, and each domain it gives more than one site, at the host's declaration: the app serves
337
+ * only a public site at a domain, and one site at each.
338
+ */
339
+ function domainProblems(
340
+ root: string | undefined,
341
+ declared: readonly DeclaredResource[],
342
+ types: ReadonlyMap<string, Resource>,
343
+ sites: readonly SiteEntry[],
344
+ read: (path: string) => SourceFile | string,
345
+ ): ResourceProblem[] {
346
+ const isPublic = new Set(sites.filter((site) => site.public).map((site) => site.name));
347
+ const problems: ResourceProblem[] = [];
348
+ for (const resource of declared) {
349
+ const path = types.get(`${resource.vendor}.${resource.type}`)?.host?.inputs.domains;
350
+ const domains = (path === undefined ? {} : (valueAt(resource.desired, path) ?? {})) as Record<string, unknown>;
351
+ const messages = Object.keys(domains)
352
+ .filter((name) => !isPublic.has(name))
353
+ .map((name) => `domains names "${name}", which is not a public site (defineSite({ public: true }))`);
354
+ const named = Object.entries(domains).filter(([, value]) => typeof value === "string");
355
+ // A host name's case is not its own: `publicHosts` compares them lowercased.
356
+ for (const [domain, entries] of Map.groupBy(named, ([, value]) => (value as string).toLowerCase())) {
357
+ const names = entries.map(([name]) => name);
358
+ if (names.length < 2) continue;
359
+ const others = `${names.length === 2 ? "both " : ""}${names.slice(0, -1).join(", ")}`;
360
+ messages.push(`${domain} is the domain of ${others} and ${names.at(-1)}`);
361
+ }
362
+ if (!messages.length || root === undefined) continue;
363
+ const file = read(join(root, resource.file));
364
+ const at = typeof file === "string" ? {} : file.at(resource.span[0]);
365
+ for (const message of messages) problems.push({ file: resource.file, ...at, message });
366
+ }
367
+ return problems;
368
+ }
369
+
327
370
  /**
328
371
  * A site's `api` names each workflow `apiNameOf(name)` beside the vendors' operations and the
329
372
  * collections (`api.data`), so a workflow whose name comes out as another's, a vendor's id or
package/src/host.ts CHANGED
@@ -42,6 +42,8 @@ export interface HostSpec {
42
42
  secrets: string;
43
43
  /** The port the app listens on. */
44
44
  port: string;
45
+ /** A public site's domain, by site name: the app serves each site at the root of its domain (`SANOMA_DOMAINS`). */
46
+ domains?: string;
45
47
  };
46
48
  /**
47
49
  * The field apply fills with the commit `ref` resolves to, before it plans: hidden, as a data
package/src/index.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  export {
2
2
  type CallContext,
3
+ CLIENT_IP_HEADER,
3
4
  type Connector,
4
5
  type ConnectorSetup,
5
6
  type ConnectorVendor,
@@ -11,6 +12,8 @@ export {
11
12
  type Effect,
12
13
  isOp,
13
14
  isSvgElement,
15
+ type LoginContext,
16
+ type LoginHalf,
14
17
  type Op,
15
18
  type OpSpec,
16
19
  type ResourceGroup,
@@ -88,6 +91,7 @@ export {
88
91
  type PreviewAnswer,
89
92
  type PreviewRequest,
90
93
  type RecordedDecision,
94
+ VISITOR_DENIED,
91
95
  } from "./policy.ts";
92
96
  export {
93
97
  cedarPolicy,
@@ -148,6 +152,8 @@ export { dbosClient } from "./db.ts";
148
152
  // Also at `@sanoma-ai/workflows/shared`, which a browser bundle can import.
149
153
  export {
150
154
  AGENTS,
155
+ ANONYMOUS,
156
+ ANONYMOUS_PRINCIPAL,
151
157
  approverLabel,
152
158
  approverOf,
153
159
  declaresPeople,
@@ -161,11 +167,14 @@ export {
161
167
  errorMessage,
162
168
  isEnded,
163
169
  isLoopback,
170
+ isVisitor,
164
171
  mayDecide,
165
172
  problemAt,
166
173
  problemLine,
167
174
  RUN_STATUSES,
168
175
  type RunStatus,
176
+ VISITORS,
177
+ visitorPrincipal,
169
178
  } from "./shared.ts";
170
179
  // describeConfig, with the outline and every problem lintWorkflow finds, lives at
171
180
  // `@sanoma-ai/workflows/describe`: what a UI renders from, apart from what runs. The worker reads
package/src/op.ts CHANGED
@@ -304,9 +304,10 @@ export interface Driver {
304
304
 
305
305
  /**
306
306
  * What a connector package attaches to its connector (`withSetup`), for the config's resolver
307
- * to call: the config then names the connector alone, and never a driver, a fake or steps. Any may
308
- * be missing: a workflow using an operation of a connector with no driver is refused, and a
309
- * connector with no fake has none for sandbox runs.
307
+ * to call: the config then names the connector alone, and never a driver, a fake, steps or a
308
+ * login. Any may be missing: a workflow using an operation of a connector with no driver is
309
+ * refused, a connector with no fake has none for sandbox runs, and one with no login half signs
310
+ * no visitor in.
310
311
  */
311
312
  export interface ConnectorSetup {
312
313
  /** Makes the live driver, which reads its credentials from the environment when called: once per resolved config. */
@@ -315,6 +316,58 @@ export interface ConnectorSetup {
315
316
  fake?: () => Fake<any, any>;
316
317
  /** The connector's steps for Gherkin tests (`defineSteps`): a config's scenarios and the connector's own features can use them. */
317
318
  steps?: Steps;
319
+ /**
320
+ * Makes the connector's login half for one public site: once per site, by the app, when it
321
+ * starts; a login that needs tables migrates them here (serialized, idempotent).
322
+ */
323
+ login?: (context: LoginContext) => LoginHalf | Promise<LoginHalf>;
324
+ }
325
+
326
+ /** What the app gives a connector's login half (`ConnectorSetup.login`) when it makes it for a public site. */
327
+ export interface LoginContext {
328
+ /** The company's database (`resolved.databaseUrl`), where a login like Better Auth keeps its users. */
329
+ databaseUrl: string;
330
+ /**
331
+ * A 32-byte key derived from the deployment's `SANOMA_SECRETS_KEY` for this login (HKDF-SHA256,
332
+ * purpose `login <connector>`), hex; a login signs its cookies with it and never asks a person for a secret.
333
+ */
334
+ key: string;
335
+ /**
336
+ * Secrets' values by name, for a login that holds a vendor's credentials (an Auth0 client
337
+ * secret): the environment's first, then the stored credentials (the values `SanomaClient.secrets` reads).
338
+ */
339
+ secrets(names: readonly string[]): Promise<Map<string, string>>;
340
+ /**
341
+ * The origins the site answers on, scheme included and `:*` for any port:
342
+ * `["http://www.localhost:*", "https://sanoma.example"]`; a login needs the protocol for its
343
+ * cookies and allowed hosts.
344
+ */
345
+ origins: readonly string[];
346
+ }
347
+
348
+ /**
349
+ * The app sets this header on every request to a public site from the socket's address,
350
+ * overwriting any copy a client sent; a login reads a visitor's address from it (for rate
351
+ * limits), never from `X-Forwarded-For`.
352
+ */
353
+ export const CLIENT_IP_HEADER = "x-sanoma-client-ip";
354
+
355
+ /**
356
+ * A connector's login half: how a public site tells who a visitor is. The app makes one with
357
+ * `login(context)` once per public site, when it starts; a login that keeps tables in the
358
+ * database migrates them itself there, serialized and idempotent. It answers the provider's
359
+ * subject only; the app makes the principal from it (`visitor:<connector>:<subject>`, in
360
+ * `visitors`), so a connector never sets a visitor's groups. A visitor's address, for rate
361
+ * limits, is the `CLIENT_IP_HEADER` header.
362
+ */
363
+ export interface LoginHalf {
364
+ /** Who signed in, as the provider's stable subject, or null; never throws for a missing or bad cookie. */
365
+ verify(request: Request): Promise<{ subject: string } | null>;
366
+ /** Answers its own routes, `/auth/<vendor>/*`; undefined for any other request. */
367
+ handle(request: Request): Promise<Response | undefined>;
368
+ /** The path a page POSTs to sign out, which `handle` serves; the app's own Sign out POSTs it with no body. */
369
+ signOut?: string;
370
+ close?(): Promise<void>;
318
371
  }
319
372
 
320
373
  /**