@sanoma-ai/workflows 0.10.3 → 0.10.7

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 (70) hide show
  1. package/CLOUD-IDENTITY.md +128 -17
  2. package/README.md +15 -4
  3. package/dist/apply.d.ts +11 -4
  4. package/dist/apply.js +126 -35
  5. package/dist/apply.js.map +1 -1
  6. package/dist/call.js +1 -1
  7. package/dist/call.js.map +1 -1
  8. package/dist/cloud-identity.d.ts +5 -1
  9. package/dist/cloud-identity.js +13 -77
  10. package/dist/cloud-identity.js.map +1 -1
  11. package/dist/config.d.ts +3 -0
  12. package/dist/config.js +3 -2
  13. package/dist/config.js.map +1 -1
  14. package/dist/define.d.ts +9 -2
  15. package/dist/define.js +1 -0
  16. package/dist/define.js.map +1 -1
  17. package/dist/dns.d.ts +20 -0
  18. package/dist/dns.js +26 -0
  19. package/dist/dns.js.map +1 -0
  20. package/dist/drift.d.ts +4 -2
  21. package/dist/drift.js +13 -4
  22. package/dist/drift.js.map +1 -1
  23. package/dist/identity-broker.d.ts +14 -0
  24. package/dist/identity-broker.js +84 -0
  25. package/dist/identity-broker.js.map +1 -0
  26. package/dist/identity-provisioning.d.ts +29 -0
  27. package/dist/identity-provisioning.js +178 -0
  28. package/dist/identity-provisioning.js.map +1 -0
  29. package/dist/identity-service.d.ts +25 -0
  30. package/dist/identity-service.js +148 -0
  31. package/dist/identity-service.js.map +1 -0
  32. package/dist/index.d.ts +2 -1
  33. package/dist/index.js +1 -0
  34. package/dist/index.js.map +1 -1
  35. package/dist/op.d.ts +15 -8
  36. package/dist/op.js.map +1 -1
  37. package/dist/provisioning-identity.d.ts +27 -0
  38. package/dist/provisioning-identity.js +81 -0
  39. package/dist/provisioning-identity.js.map +1 -0
  40. package/dist/resource.d.ts +67 -6
  41. package/dist/resource.js +95 -9
  42. package/dist/resource.js.map +1 -1
  43. package/dist/resources.d.ts +3 -3
  44. package/dist/resources.js +6 -5
  45. package/dist/resources.js.map +1 -1
  46. package/dist/runtime-ops.d.ts +60 -1
  47. package/dist/runtime-ops.js +73 -1
  48. package/dist/runtime-ops.js.map +1 -1
  49. package/dist/scenario.d.ts +6 -3
  50. package/dist/scenario.js +13 -1
  51. package/dist/scenario.js.map +1 -1
  52. package/oxlint.json +1 -0
  53. package/package.json +5 -1
  54. package/src/apply.ts +165 -47
  55. package/src/call.ts +2 -2
  56. package/src/cloud-identity.ts +16 -76
  57. package/src/config.ts +6 -2
  58. package/src/define.ts +10 -2
  59. package/src/dns.ts +29 -0
  60. package/src/drift.ts +13 -4
  61. package/src/identity-broker.ts +94 -0
  62. package/src/identity-provisioning.ts +208 -0
  63. package/src/identity-service.ts +179 -0
  64. package/src/index.ts +2 -0
  65. package/src/op.ts +13 -3
  66. package/src/provisioning-identity.ts +106 -0
  67. package/src/resource.ts +147 -15
  68. package/src/resources.ts +6 -5
  69. package/src/runtime-ops.ts +79 -0
  70. package/src/scenario.ts +24 -6
package/src/resource.ts CHANGED
@@ -2,7 +2,7 @@ import type { ConnectionRef } from "./connection.ts";
2
2
  import { z } from "zod";
3
3
  import { checkDatabase, type DatabaseSpec } from "./database.ts";
4
4
  import { isRunControlError } from "./errors.ts";
5
- import { checkHost, type HostSpec } from "./host.ts";
5
+ import { checkHost, hasPath, type HostSpec } from "./host.ts";
6
6
  import { type Connector, type OpSpec, RESOURCE, VENDOR } from "./op.ts";
7
7
  import { isSanomaSecret, isSecret, type Secret } from "./secret.ts";
8
8
  import { errorMessage } from "./shared.ts";
@@ -37,6 +37,26 @@ export interface FieldReference {
37
37
  * declared one. Without it, the field holds the resource's `name`, and is compared as it is.
38
38
  */
39
39
  readonly by?: readonly string[];
40
+ /**
41
+ * A dotted path in that resource's state: apply and drift give this field the named resource's
42
+ * state at this path, as a whole (a list or an object), read in the same run or made by an
43
+ * earlier step of it (`records: mail` takes a `resend.domain`'s `dns_records`). A data file may
44
+ * give a literal value here instead. `desired` holds the resource's `name` until then, which the
45
+ * schema does not judge. Where the same apply changes that resource at the path (creates or
46
+ * replaces it, or updates it with the path `unknown` or another value there in its plan), or,
47
+ * for a create, it has no state yet or nothing at the path, a plan is given `null` there (known
48
+ * after apply: the type's `plan` must accept it and report the path in `unknown`), so a change it
49
+ * makes is planned, and approved, up front. A plan of a bound object leaves the field out while
50
+ * its resource is absent, gone or unreadable and not about to change, as drift does, and its step
51
+ * runs without it. A step given `null` for the field whose value is still not known (its
52
+ * resource's step failed or did not run) fails before its driver is called, and drift leaves the
53
+ * field out. An empty list or object at the path counts as known: a vendor that answers `[]`
54
+ * while it is still filling the list makes the dependent write an empty one. The path must be a
55
+ * field of each type it may name (`checkFromRefs`, when a config is resolved). Not on a
56
+ * write-only field, nor one holding a write-only field (refused). Not yet on a bridge-backed type
57
+ * (`tfConnector`): an OpenTofu provider's plan reads `null` as unset, not as known after apply.
58
+ */
59
+ readonly from?: string;
40
60
  }
41
61
 
42
62
  /** A resource type's `fields.references`: by dotted path, the resource each names. */
@@ -196,9 +216,10 @@ export interface Declared<Id extends string = string, T = State> {
196
216
 
197
217
  /**
198
218
  * What a resource type's constructor takes: its fields, where `R` (its `fields.references`)
199
- * names a path, a declared resource of that type instead of a value (`repository_id: site`), and
200
- * where a string goes, a secret (`secret("NAME")`), which the constructor allows at a write-only
201
- * field only.
219
+ * names a path, a declared resource of that type instead of a value (`repository_id: site`), in
220
+ * place of the whole field for a `from` reference (`records: mail`) and of each item for a list
221
+ * otherwise, and where a string goes, a secret (`secret("NAME")`), which the constructor allows
222
+ * at a write-only field only.
202
223
  */
203
224
  export type Declaring<T, R extends References = {}> = [keyof R] extends [never]
204
225
  ? WithSecrets<T>
@@ -219,9 +240,11 @@ type DeclaringAt<T, R extends References, P extends string> = T extends readonly
219
240
  : WithSecrets<T>;
220
241
 
221
242
  type DeclaringField<V, R extends References, P extends string> = P extends keyof R
222
- ? V extends readonly (infer I)[]
223
- ? (WithSecrets<I> | DeclaredOf<R[P]["type"]>)[]
224
- : WithSecrets<V> | DeclaredOf<R[P]["type"]>
243
+ ? R[P] extends { readonly from: string }
244
+ ? WithSecrets<V> | DeclaredOf<R[P]["type"]>
245
+ : V extends readonly (infer I)[]
246
+ ? (WithSecrets<I> | DeclaredOf<R[P]["type"]>)[]
247
+ : WithSecrets<V> | DeclaredOf<R[P]["type"]>
225
248
  : [Extract<keyof R, `${P}.${string}`>] extends [never]
226
249
  ? WithSecrets<V>
227
250
  : DeclaringAt<V, R, P>;
@@ -477,6 +500,65 @@ export function holderOf(value: unknown, path: string): { holder: State; key: st
477
500
  return isObject(holder) || Array.isArray(holder) ? { holder: holder as State, key } : undefined;
478
501
  }
479
502
 
503
+ /**
504
+ * Each place `refs` (a declaration's, by dotted path to the resource named there) holds a `from`
505
+ * reference (`FieldReference.from`): its path, the resource it names (`named`), and the path in
506
+ * that resource's state its value comes from.
507
+ */
508
+ const fromRefs = (references: References | undefined, refs: Readonly<Record<string, string>>) =>
509
+ Object.entries(refs).flatMap(([at, named]) => {
510
+ const from = references?.[fieldPathOf(at)]?.from;
511
+ return from === undefined ? [] : [{ at, named, from }];
512
+ });
513
+
514
+ /** What `withFrom` makes of a `from` field it cannot fill: `null` (known after apply), or nothing. */
515
+ export type Missing = "unknown" | "drop";
516
+
517
+ /** A `from` reference `withFrom` could not fill: the resource it names has no state, or nothing at its path. */
518
+ export interface Unfilled {
519
+ at: string;
520
+ named: string;
521
+ from: string;
522
+ /** True when the resource has a state, with nothing at the path. */
523
+ stated: boolean;
524
+ }
525
+
526
+ /**
527
+ * `desired` with each field a `from` reference names given the state of the resource it names
528
+ * at that path (a copy), `stateOf` that resource (by what `refs` holds there, and the path read);
529
+ * and the references it could not fill, where that resource has no state or nothing there, each
530
+ * such field `null` (`unknown`, a value known after apply) or left out (`drop`), as `missing` says
531
+ * (for each reference, given a function). A reference with no place in `desired` to fill is
532
+ * unfilled too. `desired` itself when there is no such field.
533
+ */
534
+ export function withFrom(
535
+ references: References | undefined,
536
+ desired: State,
537
+ refs: Readonly<Record<string, string>>,
538
+ stateOf: (named: string, from: string) => State | undefined,
539
+ missing: Missing | ((named: string, from: string) => Missing),
540
+ ): { desired: State; unfilled: Unfilled[] } {
541
+ const taken = fromRefs(references, refs);
542
+ if (!taken.length) return { desired, unfilled: [] };
543
+ const out = structuredClone(desired);
544
+ const unfilled: Unfilled[] = [];
545
+ for (const { at, named, from } of taken) {
546
+ const place = holderOf(out, at);
547
+ const state = stateOf(named, from);
548
+ const value = state === undefined ? undefined : valueAt(state, from);
549
+ if (place && value != null) {
550
+ // A copy: what a driver is given never shares an object with the resource's state.
551
+ place.holder[place.key] = structuredClone(value);
552
+ continue;
553
+ }
554
+ unfilled.push({ at, named, from, stated: state !== undefined });
555
+ if (!place) continue;
556
+ if ((typeof missing === "function" ? missing(named, from) : missing) === "drop") delete place.holder[place.key];
557
+ else place.holder[place.key] = null;
558
+ }
559
+ return { desired: out, unfilled };
560
+ }
561
+
480
562
  /** A field's dotted path as `fields` names it, without a list item's index: `teams.0` is `teams`. */
481
563
  export const fieldPathOf = (at: string) => at.replaceAll(/\.\d+(?=\.|$)/g, "");
482
564
 
@@ -485,7 +567,7 @@ export const valueAt = (value: unknown, path: string) =>
485
567
  path.split(".").reduce<unknown>((v, key) => (isObject(v) || Array.isArray(v) ? (v as State)[key] : undefined), value);
486
568
 
487
569
  /** True when the two are the same JSON, where a missing value is `null`. */
488
- function same(a: unknown, b: unknown): boolean {
570
+ export function same(a: unknown, b: unknown): boolean {
489
571
  if (a === b || (a == null && b == null)) return true;
490
572
  if (Array.isArray(a) && Array.isArray(b)) return a.length === b.length && a.every((item, i) => same(item, b[i]));
491
573
  if (isObject(a) && isObject(b)) return Object.keys({ ...a, ...b }).every((key) => same(a[key], b[key]));
@@ -579,6 +661,12 @@ function pathsIn(value: unknown, paths: ReadonlySet<string>, prefix = ""): strin
579
661
  * - `update` takes `{ id, state?, handle?, desired, expect? }` and returns the same; given
580
662
  * `expect` (a plan's `action` and `replace`), it refuses a fresh plan that differs
581
663
  * (`DriverError`, `vendorCode` `plan_changed`).
664
+ *
665
+ * A type whose fields take a `from` reference (`FieldReference.from`) is given `null` there by
666
+ * apply's `plan` while the value is known only after apply, so its `plan` must accept `null` and
667
+ * report the path in `unknown`; and its `update` must treat a field left out of `desired` as
668
+ * keeping what the vendor holds, since a bound object whose `from` resource is gone is updated
669
+ * with that field left out.
582
670
  * - `delete` takes `{ id, state?, handle? }` and returns `{ id, gone: true }`.
583
671
  *
584
672
  * `desired` may hold secret markers at write-only fields, which the driver resolves; no output
@@ -591,8 +679,10 @@ function pathsIn(value: unknown, paths: ReadonlySet<string>, prefix = ""): strin
591
679
  * schema does not have, fields the vendor owns (unless the identity names them), values the
592
680
  * schema rejects, and an empty identity. Where `fields.references` allows it, a field may name
593
681
  * another declared resource of that type (`repository_id: site`): `desired` holds, and the
594
- * schema and `find` read, that resource's `name`, and `refs` says where it was named. A type
595
- * without `find` declares resources without a `name`, found only by their bindings.
682
+ * schema and `find` read, that resource's `name`, and `refs` says where it was named. A `from`
683
+ * reference names one in place of the whole field, which apply and drift fill from its state, so
684
+ * the schema does not judge the name there. A type without `find` declares resources without a
685
+ * `name`, found only by their bindings.
596
686
  */
597
687
  export function defineResource<
598
688
  S extends z.ZodObject,
@@ -672,6 +762,20 @@ export function defineResource<
672
762
  const owned = new Set(fields.vendorOwned.filter((path) => !named.has(path)));
673
763
  const references: References = fields.references ?? {};
674
764
  const writeOnly = new Set(fields.writeOnly);
765
+ for (const [path, ref] of Object.entries(references)) {
766
+ if (ref.from !== undefined && ref.by !== undefined)
767
+ throw new Error(
768
+ `${what}: the reference at ${path} takes its value from its resource's state or names it by a field, not both`,
769
+ );
770
+ // A write-only field holds secret markers only, never a value from another resource's state.
771
+ if (ref.from !== undefined && underAny(path, writeOnly))
772
+ throw new Error(`${what}: ${path} is write-only, so it cannot take its value from another resource's state`);
773
+ const inside = ref.from === undefined ? undefined : fields.writeOnly.find((w) => w.startsWith(`${path}.`));
774
+ if (inside !== undefined)
775
+ throw new Error(
776
+ `${what}: ${inside} is write-only, so ${path}, which holds it, cannot take its value from another resource's state`,
777
+ );
778
+ }
675
779
  const declare = (
676
780
  given: Declaring<z.input<S>, R>,
677
781
  options?: { connection?: ConnectionRef },
@@ -684,8 +788,9 @@ export function defineResource<
684
788
  throw new Error(`${what}: select a declared connection of this connector`);
685
789
  }
686
790
  const refs: Record<string, string> = {};
687
- // Where a secret is given, with list items' indexes: the schema does not judge its marker.
688
- const secrets = new Set<string>();
791
+ // Where a secret, or a resource a `from` reference takes, is given, with list items' indexes:
792
+ // the schema does not judge the marker, nor the name that holds the place of the state.
793
+ const unjudged = new Set<string>();
689
794
  // Each resource `given` names, where `references` allows one of its type, replaced by its name.
690
795
  // `path` is the field's, as `references` names it; `at` has list items' indexes too.
691
796
  const resolve = (value: unknown, path: string, at: string): unknown => {
@@ -697,7 +802,7 @@ export function defineResource<
697
802
  if (isSanomaSecret(value.$secret) && !(hostSecrets !== undefined && underAny(path, new Set([hostSecrets])))) {
698
803
  throw new Error(`${what}: ${at} names ${value.$secret}, one of sanoma's own: only a host's secrets may`);
699
804
  }
700
- secrets.add(at);
805
+ unjudged.add(at);
701
806
  return value;
702
807
  }
703
808
  if (isDeclared(value)) {
@@ -709,10 +814,16 @@ export function defineResource<
709
814
  throw new Error(`${what}: ${at} names a resource of type ${types.join(" or ")}, not ${id} (${value.name})`);
710
815
  if (value.name === undefined)
711
816
  throw new Error(`${what}: ${at} names a ${id}, which has no identity to name it by`);
817
+ if (wants.from !== undefined) unjudged.add(at);
712
818
  refs[at] = `${id}:${value.name}`;
713
819
  return value.name;
714
820
  }
715
- if (Array.isArray(value)) return value.map((item, i) => resolve(item, path, `${at}.${i}`));
821
+ if (Array.isArray(value)) {
822
+ const from = references[path]?.from;
823
+ const listed = from === undefined ? undefined : value.find(isDeclared);
824
+ if (listed) throw new Error(`${what}: ${path} takes a ${listed[DECLARED]}'s ${from} as a whole, not in a list`);
825
+ return value.map((item, i) => resolve(item, path, `${at}.${i}`));
826
+ }
716
827
  if (!isObject(value)) return value;
717
828
  return Object.fromEntries(
718
829
  Object.entries(value).map(([field, v]) => [field, resolve(v, joinPath(path, field), joinPath(at, field))]),
@@ -732,7 +843,7 @@ export function defineResource<
732
843
  );
733
844
  }
734
845
  const parsed = schema.safeParse(desired);
735
- const issues = parsed.error?.issues.filter((issue) => !secrets.has(issue.path.join(".")));
846
+ const issues = parsed.error?.issues.filter((issue) => !unjudged.has(issue.path.join(".")));
736
847
  if (issues?.length) throw new Error(`${what}: ${z.prettifyError(new z.ZodError(issues))}`);
737
848
  const name = find?.(desired);
738
849
  if (find && (typeof name !== "string" || name === "")) throw new Error(`${what}: its ${identity} is missing`);
@@ -770,6 +881,27 @@ export function defineResource<
770
881
  return Object.freeze(resource);
771
882
  }
772
883
 
884
+ /**
885
+ * Throws where a `from` reference (`FieldReference.from`) names a path that a type it may name
886
+ * has no field at, among `types` (a config's): `dns_record` for `dns_records` would otherwise
887
+ * fail every create. A type not among them is left to the data files' check.
888
+ */
889
+ export function checkFromRefs(types: ReadonlyMap<string, Resource>): void {
890
+ for (const type of types.values()) {
891
+ for (const [path, ref] of Object.entries(type.fields.references ?? {})) {
892
+ const named = ref.from === undefined ? [] : [ref.type].flat();
893
+ for (const held of named.map((id) => types.get(id)).filter((t) => t !== undefined)) {
894
+ if (!hasPath(held.schema, ref.from!)) {
895
+ const shape = (held.schema as Partial<z.ZodObject>).shape ?? {};
896
+ throw new Error(
897
+ `${type.vendor}.${type.type}: the reference at ${path} takes ${ref.from} from a ${held.vendor}.${held.type}'s state, which has no such field (its fields: ${Object.keys(shape).join(", ")})`,
898
+ );
899
+ }
900
+ }
901
+ }
902
+ }
903
+ }
904
+
773
905
  /** Every resource type the connectors declare, by `<vendor>.<type>`. */
774
906
  export function resourceTypesOf(connectors: readonly Connector<any, any>[]): Map<string, Resource> {
775
907
  const types = new Map<string, Resource>();
package/src/resources.ts CHANGED
@@ -140,9 +140,9 @@ export function readPeople(root: string | undefined): DeclaredPeople {
140
140
  * While no person is declared, whoever names an id is taken as given. A visitor's id (`isVisitor`)
141
141
  * is taken whatever the files say, with the groups stamped here (`visitors`, or none for
142
142
  * `anonymous`), never through a sign-in: the staff logins admit no visitor. A visitor keeps the
143
- * `email` its login verified; a `subject` is never taken from the caller (a token's is its row's,
144
- * which `SanomaClient.start` reads). Throws `NotAdmittedError` for anyone else, and for
145
- * everyone while the people's files have problems.
143
+ * `email` and `github` its login verified; a `subject` is never taken from the caller (a token's
144
+ * is its row's, which `SanomaClient.start` reads). Throws `NotAdmittedError` for anyone else, and
145
+ * for everyone while the people's files have problems.
146
146
  */
147
147
  export function admit(
148
148
  asking: { id?: string; email?: string; github?: string; groups?: string[]; onBehalfOf?: string },
@@ -155,7 +155,8 @@ export function admit(
155
155
  if (asking.id === ANONYMOUS) return ANONYMOUS_PRINCIPAL;
156
156
  // A token's groups are its row's (`SanomaClient.tokens`), which only the public front reads; a visitor's are stamped.
157
157
  if (isToken({ id: asking.id })) return { id: asking.id, groups: [...(asking.groups ?? [])] };
158
- return { id: asking.id, groups: [VISITORS], ...(asking.email && { email: asking.email }) };
158
+ const { email, github } = asking;
159
+ return { id: asking.id, groups: [VISITORS], ...(email && { email }), ...(github && { github }) };
159
160
  }
160
161
  if (problems.length) {
161
162
  // The runtime's own agents are declared by no file, so a broken one does not stop them (`check`'s scenarios).
@@ -167,7 +168,7 @@ export function admit(
167
168
  if (!declaresPeople(people)) {
168
169
  const given = Principal.safeParse(asking).data;
169
170
  if (given) {
170
- const { email: _email, subject: _subject, ...principal } = given;
171
+ const { email: _email, github: _github, subject: _subject, ...principal } = given;
171
172
  return principal;
172
173
  }
173
174
  } else {
@@ -7,6 +7,11 @@ import { sealInvite } from "./seal.ts";
7
7
  import { errorMessage } from "./shared.ts";
8
8
  import { sourceKeyPair, sshPublicKey } from "./ssh-key.ts";
9
9
  import { memoryTokens, type Tokens } from "./tokens.ts";
10
+ import {
11
+ identityProvisioningCode,
12
+ memoryIdentityProvisioning,
13
+ type IdentityProvisioning,
14
+ } from "./identity-provisioning.ts";
10
15
 
11
16
  // What a workflow cannot do itself and the runtime does for it, as operations of the vendor
12
17
  // `sanoma` (`sanoma.secret.*`, `sanoma.invite.mint`, `sanoma.token.*`): named in `uses`, decided by
@@ -23,11 +28,60 @@ const SecretName = z
23
28
  /** How long an instance gets to answer `POST /auth/invite`. */
24
29
  const INVITE_TIMEOUT_MS = 10_000;
25
30
 
31
+ const DeploymentIdentity = z.object({
32
+ deployment: z.string().min(1).max(256),
33
+ company: z.string().min(1).max(256),
34
+ environment: z.string().min(1).max(256),
35
+ serviceAccount: z.string().regex(/^[a-z][a-z0-9-]+@[a-z][a-z0-9-]+\.iam\.gserviceaccount\.com$/),
36
+ });
37
+ const Generation = z.number().int().positive().max(Number.MAX_SAFE_INTEGER);
38
+ const IdentityTarget = ({ deployment }: { deployment: string }) => deployment;
39
+
40
+ // Enrollment implementations can see credential hashes and database credentials. Neither their
41
+ // errors nor causes may be captured in durable driver history, including on an uncertain commit.
42
+ async function identityWrite(write: () => Promise<void>): Promise<void> {
43
+ try {
44
+ await write();
45
+ } catch (error) {
46
+ const code = identityProvisioningCode(error) ?? "unavailable";
47
+ throw new DriverError(`Identity provisioning: ${code}`, { retryable: code === "unavailable" });
48
+ }
49
+ }
50
+
26
51
  /**
27
52
  * The runtime's own operations a workflow calls: `import { sanoma } from "sanoma"`, then
28
53
  * `uses: [sanoma.secret.generate]` and `ctx.sanoma.secret.generate({ name, kind })`.
29
54
  */
30
55
  export const sanoma = defineConnector("sanoma", {
56
+ identity: {
57
+ enroll: {
58
+ effect: "access",
59
+ idempotent: true,
60
+ description:
61
+ "Enroll a deployment identity, keeping its bootstrap credential in the secret store. A higher generation rotates it; mappings are immutable",
62
+ input: DeploymentIdentity.extend({ generation: Generation, validUntil: z.iso.datetime(), store: SecretName }),
63
+ output: z.object({ deployment: z.string(), generation: Generation, store: z.string() }),
64
+ target: IdentityTarget,
65
+ createsSecrets: ({ store }: { store: string }) => [store],
66
+ },
67
+ retire: {
68
+ effect: "access",
69
+ idempotent: true,
70
+ description:
71
+ "Shorten an old identity generation's validity after delivering its replacement; never extend access",
72
+ input: DeploymentIdentity.extend({ generation: Generation, validUntil: z.iso.datetime() }),
73
+ output: z.object({}),
74
+ target: IdentityTarget,
75
+ },
76
+ revoke: {
77
+ effect: "access",
78
+ idempotent: true,
79
+ description: "Disable all token renewal for a deployment. This deployment id cannot be re-enrolled",
80
+ input: DeploymentIdentity,
81
+ output: z.object({}),
82
+ target: IdentityTarget,
83
+ },
84
+ },
31
85
  secret: {
32
86
  generate: {
33
87
  effect: "write",
@@ -156,12 +210,36 @@ export async function postInvite(host: string, request: string): Promise<{ url:
156
210
  */
157
211
  export function runtimeDriver({
158
212
  tokens,
213
+ identities,
159
214
  invite = postInvite,
160
215
  }: {
161
216
  tokens: Tokens;
217
+ identities?: IdentityProvisioning;
162
218
  invite?: (host: string, request: string, call: CallContext) => Promise<{ url: string }>;
163
219
  }) {
164
220
  return defineDriver(sanoma, {
221
+ identity: {
222
+ async enroll({ store, ...input }, call) {
223
+ if (!identities) throw refuse("identity provisioning is not configured");
224
+ // Store before enrolling: a lost DB response retries with the same credential, never a new bearer.
225
+ const credential = await call.secrets.ensure(store, generateSecret);
226
+ if (!/^[A-Za-z0-9_-]{43}$/.test(credential)) throw refuse("invalid identity bootstrap credential");
227
+ await identityWrite(() =>
228
+ identities.enroll({ ...input, hash: createHash("sha256").update(credential).digest("hex") }),
229
+ );
230
+ return { deployment: input.deployment, generation: input.generation, store };
231
+ },
232
+ async retire(input) {
233
+ if (!identities) throw refuse("identity provisioning is not configured");
234
+ await identityWrite(() => identities.retire(input));
235
+ return {};
236
+ },
237
+ async revoke(input) {
238
+ if (!identities) throw refuse("identity provisioning is not configured");
239
+ await identityWrite(() => identities.revoke(input));
240
+ return {};
241
+ },
242
+ },
165
243
  secret: {
166
244
  async generate({ name, kind }, call) {
167
245
  if (kind === "random") {
@@ -230,5 +308,6 @@ export function runtimeDriver({
230
308
  export const sandboxRuntimeDriver = () =>
231
309
  runtimeDriver({
232
310
  tokens: memoryTokens(),
311
+ identities: memoryIdentityProvisioning(),
233
312
  invite: async (host, _request, call) => ({ url: new URL(`/auth/invite/sandbox-${tokenIdOf(call)}`, host).href }),
234
313
  });
package/src/scenario.ts CHANGED
@@ -13,7 +13,7 @@ import {
13
13
  } from "@cucumber/messages";
14
14
  import type { z } from "zod";
15
15
  import type { RunSummary, SanomaClient } from "./client.ts";
16
- import type { Approver } from "./define.ts";
16
+ import type { Approver, Principal } from "./define.ts";
17
17
  import type { ResolvedConfig } from "./config.ts";
18
18
  import {
19
19
  buildLibrary,
@@ -162,8 +162,11 @@ export interface Scenario {
162
162
  text: string;
163
163
  /** Every step in order, for a list and for marking graph nodes. */
164
164
  steps: ScenarioStep[];
165
- /** Who starts the run, by id, when a `Given <who> is acting` says: a declared person or agent, `anonymous` or a visitor. */
166
- startedBy?: string;
165
+ /**
166
+ * Who starts the run when a `Given … is acting` says: a declared person or agent or `anonymous`
167
+ * by id, or a visitor, by id or, with the email and GitHub login its login verified, as a principal.
168
+ */
169
+ startedBy?: Principal | string;
167
170
  given: Given[];
168
171
  input: unknown;
169
172
  decisions: Decision[];
@@ -208,11 +211,11 @@ interface Then {
208
211
 
209
212
  /**
210
213
  * What a step of each kind reads into; `any` is a decision, which a step of any keyword may make,
211
- * and `actor` a `Given` saying who starts the run (`{who} is acting`), by id.
214
+ * and `actor` a `Given` saying who starts the run (`{who} is acting`), by id or as a principal.
212
215
  */
213
216
  interface Items {
214
217
  given: Unstepped<Given>[];
215
- actor: string;
218
+ actor: Principal | string;
216
219
  when: When;
217
220
  then: Then;
218
221
  any: Unstepped<Decision>;
@@ -398,6 +401,12 @@ function builtFor(scope: Scope): Built {
398
401
  /** A `Then` step of the generic steps: its expectation, checked against the run's records. */
399
402
  const expecting = (expect: CoreExpectation): Then => ({ expect, verify: (run) => detailOf(expect, run.records) });
400
403
 
404
+ /** The scenario's visitor (`a visitor is acting`) with what its login verified, as `publicFront` passes it on. */
405
+ const visitorWith = (verified: { email: unknown; github?: unknown }): Principal => ({
406
+ ...visitorPrincipal("scenario", "visitor"),
407
+ ...(verified as Pick<Principal, "email" | "github">),
408
+ });
409
+
401
410
  /** The rules a scope has, and the parameter types their expressions use. */
402
411
  function rulesOf(scope: Scope): { rules: Rule[]; parameterTypes: ParameterTypeSpec[] } {
403
412
  const { types, collections } = scope;
@@ -455,6 +464,13 @@ function rulesOf(scope: Scope): { rules: Rule[]; parameterTypes: ParameterTypeSp
455
464
  { kind: "actor", source: "{who} is acting", read: ([who]) => actorNamed(who as string) },
456
465
  // A visitor of a public site, as a login would vouch for one; `anonymous` is a `{who}`.
457
466
  { kind: "actor", source: "a visitor is acting", read: () => visitorPrincipal("scenario", "visitor").id },
467
+ // A visitor with the email its login verified, and a GitHub login it linked.
468
+ { kind: "actor", source: "a visitor {string} is acting", read: ([email]) => visitorWith({ email }) },
469
+ {
470
+ kind: "actor",
471
+ source: "a visitor {string} with GitHub {string} is acting",
472
+ read: ([email, github]) => visitorWith({ email, github }),
473
+ },
458
474
  {
459
475
  kind: "given",
460
476
  source: "{op} was called with",
@@ -985,7 +1001,9 @@ function compileWorld(built: Built, specs: Map<string, Spec>) {
985
1001
  switch (rule.kind) {
986
1002
  case "actor":
987
1003
  if (spec.startedBy !== undefined)
988
- throw new Error(`${spec.startedBy} is acting already: a run has one actor`);
1004
+ throw new Error(
1005
+ `${typeof spec.startedBy === "string" ? spec.startedBy : spec.startedBy.id} is acting already: a run has one actor`,
1006
+ );
989
1007
  spec.startedBy = rule.read(args, pickle);
990
1008
  break;
991
1009
  case "given": {