@sanoma-ai/workflows 0.4.1 → 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.
- package/README.md +19 -10
- package/dist/cedar.d.ts +23 -6
- package/dist/cedar.js +151 -44
- package/dist/cedar.js.map +1 -1
- package/dist/client.d.ts +3 -2
- package/dist/client.js +15 -6
- package/dist/client.js.map +1 -1
- package/dist/config.d.ts +6 -1
- package/dist/config.js +3 -1
- package/dist/config.js.map +1 -1
- package/dist/describe.d.ts +2 -0
- package/dist/describe.js +39 -4
- package/dist/describe.js.map +1 -1
- package/dist/host.d.ts +2 -0
- package/dist/host.js.map +1 -1
- package/dist/index.d.ts +4 -3
- package/dist/index.js +4 -3
- package/dist/index.js.map +1 -1
- package/dist/op.d.ts +57 -3
- package/dist/op.js +10 -2
- package/dist/op.js.map +1 -1
- package/dist/policy.d.ts +9 -1
- package/dist/policy.js +18 -7
- package/dist/policy.js.map +1 -1
- package/dist/resources.d.ts +4 -2
- package/dist/resources.js +23 -11
- package/dist/resources.js.map +1 -1
- package/dist/scenario.d.ts +1 -1
- package/dist/scenario.js +4 -2
- package/dist/scenario.js.map +1 -1
- package/dist/schema.js +1 -1
- package/dist/shared.d.ts +27 -2
- package/dist/shared.js +52 -3
- package/dist/shared.js.map +1 -1
- package/dist/sites.d.ts +35 -6
- package/dist/sites.js +129 -25
- package/dist/sites.js.map +1 -1
- package/dist/vendor-info.d.ts +19 -0
- package/dist/vendor-info.js +49 -0
- package/dist/vendor-info.js.map +1 -0
- package/dist/version.d.ts +2 -2
- package/dist/version.js +2 -2
- package/dist/version.js.map +1 -1
- package/package.json +1 -1
- package/src/cedar.ts +152 -41
- package/src/client.ts +17 -4
- package/src/config.ts +8 -2
- package/src/describe.ts +48 -4
- package/src/host.ts +2 -0
- package/src/index.ts +11 -0
- package/src/op.ts +61 -5
- package/src/policy.ts +29 -10
- package/src/resources.ts +26 -10
- package/src/scenario.ts +5 -2
- package/src/schema.ts +1 -1
- package/src/shared.ts +61 -3
- package/src/sites.ts +162 -30
- package/src/vendor-info.ts +53 -0
- package/src/version.ts +4 -2
package/src/op.ts
CHANGED
|
@@ -139,9 +139,12 @@ export interface VendorInfo {
|
|
|
139
139
|
homepage?: string;
|
|
140
140
|
}
|
|
141
141
|
|
|
142
|
-
/** One `<svg>` element, with nothing but whitespace around it. */
|
|
142
|
+
/** One `<svg>` element, with nothing but whitespace around it: what a logo must be. */
|
|
143
143
|
const SVG_ELEMENT = /^\s*<svg[\s>][\s\S]*<\/svg>\s*$/;
|
|
144
144
|
|
|
145
|
+
/** Whether `svg` is a logo as `VendorInfo` takes it: one `<svg>…</svg>` element. */
|
|
146
|
+
export const isSvgElement = (svg: unknown): svg is string => typeof svg === "string" && SVG_ELEMENT.test(svg);
|
|
147
|
+
|
|
145
148
|
const isResourceGroup = (group: unknown): group is ResourceGroup =>
|
|
146
149
|
(typeof group === "function" || typeof group === "object") &&
|
|
147
150
|
group !== null &&
|
|
@@ -165,7 +168,7 @@ export function defineConnector<const V extends string, const S extends Specs>(
|
|
|
165
168
|
const logo =
|
|
166
169
|
info?.logo && Object.freeze(Object.fromEntries(Object.entries(info.logo).filter(([, v]) => v !== undefined)));
|
|
167
170
|
for (const [variant, svg] of Object.entries(logo ?? {})) {
|
|
168
|
-
if (
|
|
171
|
+
if (!isSvgElement(svg)) {
|
|
169
172
|
throw new Error(
|
|
170
173
|
`defineConnector("${vendor}"): logo.${variant} must be inline SVG markup, one <svg>…</svg> element`,
|
|
171
174
|
);
|
|
@@ -301,9 +304,10 @@ export interface Driver {
|
|
|
301
304
|
|
|
302
305
|
/**
|
|
303
306
|
* What a connector package attaches to its connector (`withSetup`), for the config's resolver
|
|
304
|
-
* to call: the config then names the connector alone, and never a driver, a fake
|
|
305
|
-
* be missing: a workflow using an operation of a connector with no driver is
|
|
306
|
-
* 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.
|
|
307
311
|
*/
|
|
308
312
|
export interface ConnectorSetup {
|
|
309
313
|
/** Makes the live driver, which reads its credentials from the environment when called: once per resolved config. */
|
|
@@ -312,6 +316,58 @@ export interface ConnectorSetup {
|
|
|
312
316
|
fake?: () => Fake<any, any>;
|
|
313
317
|
/** The connector's steps for Gherkin tests (`defineSteps`): a config's scenarios and the connector's own features can use them. */
|
|
314
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>;
|
|
315
371
|
}
|
|
316
372
|
|
|
317
373
|
/**
|
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
|
-
/**
|
|
112
|
-
const
|
|
113
|
-
|
|
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
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
-
:
|
|
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.
|
|
173
|
-
*
|
|
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 `
|
|
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 `
|
|
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
|
}
|