@sanoma-ai/workflows 0.10.2 → 0.10.6

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 (152) hide show
  1. package/CLOUD-IDENTITY.md +128 -17
  2. package/README.md +56 -13
  3. package/dist/apply.d.ts +11 -4
  4. package/dist/apply.js +127 -36
  5. package/dist/apply.js.map +1 -1
  6. package/dist/call.d.ts +5 -2
  7. package/dist/call.js +65 -20
  8. package/dist/call.js.map +1 -1
  9. package/dist/change.d.ts +5 -6
  10. package/dist/change.js +6 -9
  11. package/dist/change.js.map +1 -1
  12. package/dist/client.d.ts +18 -0
  13. package/dist/client.js +35 -3
  14. package/dist/client.js.map +1 -1
  15. package/dist/cloud-identity.d.ts +5 -1
  16. package/dist/cloud-identity.js +13 -77
  17. package/dist/cloud-identity.js.map +1 -1
  18. package/dist/config.d.ts +45 -4
  19. package/dist/config.js +73 -11
  20. package/dist/config.js.map +1 -1
  21. package/dist/db.d.ts +2 -0
  22. package/dist/db.js +7 -0
  23. package/dist/db.js.map +1 -1
  24. package/dist/deadlines.d.ts +26 -13
  25. package/dist/deadlines.js +50 -11
  26. package/dist/deadlines.js.map +1 -1
  27. package/dist/define.d.ts +29 -0
  28. package/dist/define.js +3 -0
  29. package/dist/define.js.map +1 -1
  30. package/dist/deployment-key.d.ts +20 -1
  31. package/dist/deployment-key.js +28 -3
  32. package/dist/deployment-key.js.map +1 -1
  33. package/dist/describe.js +28 -1
  34. package/dist/describe.js.map +1 -1
  35. package/dist/description.d.ts +3 -0
  36. package/dist/description.js +2 -0
  37. package/dist/description.js.map +1 -1
  38. package/dist/dns.d.ts +20 -0
  39. package/dist/dns.js +26 -0
  40. package/dist/dns.js.map +1 -0
  41. package/dist/drift.d.ts +4 -2
  42. package/dist/drift.js +13 -4
  43. package/dist/drift.js.map +1 -1
  44. package/dist/host.d.ts +13 -4
  45. package/dist/host.js +14 -5
  46. package/dist/host.js.map +1 -1
  47. package/dist/identity-broker.d.ts +14 -0
  48. package/dist/identity-broker.js +84 -0
  49. package/dist/identity-broker.js.map +1 -0
  50. package/dist/identity-provisioning.d.ts +29 -0
  51. package/dist/identity-provisioning.js +178 -0
  52. package/dist/identity-provisioning.js.map +1 -0
  53. package/dist/identity-service.d.ts +25 -0
  54. package/dist/identity-service.js +148 -0
  55. package/dist/identity-service.js.map +1 -0
  56. package/dist/index.d.ts +11 -6
  57. package/dist/index.js +9 -5
  58. package/dist/index.js.map +1 -1
  59. package/dist/ledger.d.ts +30 -1
  60. package/dist/ledger.js +31 -0
  61. package/dist/ledger.js.map +1 -1
  62. package/dist/login-code.d.ts +17 -0
  63. package/dist/login-code.js +32 -0
  64. package/dist/login-code.js.map +1 -0
  65. package/dist/op.d.ts +85 -10
  66. package/dist/op.js +20 -0
  67. package/dist/op.js.map +1 -1
  68. package/dist/outline.js +3 -3
  69. package/dist/outline.js.map +1 -1
  70. package/dist/provisioning-identity.d.ts +27 -0
  71. package/dist/provisioning-identity.js +81 -0
  72. package/dist/provisioning-identity.js.map +1 -0
  73. package/dist/resource.d.ts +73 -10
  74. package/dist/resource.js +103 -14
  75. package/dist/resource.js.map +1 -1
  76. package/dist/resources.d.ts +4 -2
  77. package/dist/resources.js +16 -6
  78. package/dist/resources.js.map +1 -1
  79. package/dist/run.d.ts +3 -0
  80. package/dist/run.js.map +1 -1
  81. package/dist/runtime-ops.d.ts +182 -0
  82. package/dist/runtime-ops.js +294 -0
  83. package/dist/runtime-ops.js.map +1 -0
  84. package/dist/scenario.d.ts +6 -3
  85. package/dist/scenario.js +13 -1
  86. package/dist/scenario.js.map +1 -1
  87. package/dist/seal.d.ts +27 -0
  88. package/dist/seal.js +76 -0
  89. package/dist/seal.js.map +1 -0
  90. package/dist/secret-store.d.ts +6 -4
  91. package/dist/secret-store.js +11 -6
  92. package/dist/secret-store.js.map +1 -1
  93. package/dist/secret.d.ts +7 -0
  94. package/dist/secret.js +13 -1
  95. package/dist/secret.js.map +1 -1
  96. package/dist/shared.d.ts +15 -4
  97. package/dist/shared.js +27 -7
  98. package/dist/shared.js.map +1 -1
  99. package/dist/ssh-key.d.ts +11 -0
  100. package/dist/ssh-key.js +44 -0
  101. package/dist/ssh-key.js.map +1 -0
  102. package/dist/tokens.d.ts +35 -0
  103. package/dist/tokens.js +71 -0
  104. package/dist/tokens.js.map +1 -0
  105. package/dist/wakeup-service.js +2 -7
  106. package/dist/wakeup-service.js.map +1 -1
  107. package/dist/wakeup.d.ts +18 -0
  108. package/dist/wakeup.js +15 -0
  109. package/dist/wakeup.js.map +1 -1
  110. package/dist/worker.d.ts +2 -2
  111. package/dist/worker.js +17 -9
  112. package/dist/worker.js.map +1 -1
  113. package/oxlint.json +2 -1
  114. package/package.json +5 -1
  115. package/src/apply.ts +166 -48
  116. package/src/call.ts +88 -18
  117. package/src/change.ts +9 -20
  118. package/src/client.ts +48 -7
  119. package/src/cloud-identity.ts +16 -76
  120. package/src/config.ts +116 -18
  121. package/src/db.ts +8 -1
  122. package/src/deadlines.ts +66 -20
  123. package/src/define.ts +33 -0
  124. package/src/deployment-key.ts +34 -8
  125. package/src/describe.ts +39 -2
  126. package/src/description.ts +2 -0
  127. package/src/dns.ts +29 -0
  128. package/src/drift.ts +13 -4
  129. package/src/host.ts +22 -6
  130. package/src/identity-broker.ts +94 -0
  131. package/src/identity-provisioning.ts +208 -0
  132. package/src/identity-service.ts +179 -0
  133. package/src/index.ts +20 -1
  134. package/src/ledger.ts +52 -1
  135. package/src/login-code.ts +52 -0
  136. package/src/op.ts +94 -11
  137. package/src/outline.ts +3 -3
  138. package/src/provisioning-identity.ts +106 -0
  139. package/src/resource.ts +155 -20
  140. package/src/resources.ts +14 -4
  141. package/src/run.ts +3 -0
  142. package/src/runtime-ops.ts +313 -0
  143. package/src/scenario.ts +24 -6
  144. package/src/seal.ts +95 -0
  145. package/src/secret-store.ts +12 -9
  146. package/src/secret.ts +16 -1
  147. package/src/shared.ts +31 -7
  148. package/src/ssh-key.ts +48 -0
  149. package/src/tokens.ts +100 -0
  150. package/src/wakeup-service.ts +2 -8
  151. package/src/wakeup.ts +21 -0
  152. package/src/worker.ts +29 -11
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>;
@@ -238,7 +261,7 @@ const record = z.record(z.string(), z.unknown());
238
261
  export const PLAN_ACTIONS = ["none", "create", "update", "replace", "delete"] as const;
239
262
  export type PlanAction = (typeof PLAN_ACTIONS)[number];
240
263
 
241
- /** The operations of a writable type that change its objects: only the apply workflow calls them (`isChangeOp`). */
264
+ /** The operations of a writable type that change its objects (`isChangeOp`): apply's for a declared object, any workflow's for another. */
242
265
  export const CHANGE_OPS = ["create", "update", "delete"] as const;
243
266
 
244
267
  const opsOf = <S extends z.ZodObject>(schema: S) => {
@@ -293,7 +316,8 @@ export type ResourceOps<S extends z.ZodObject, W extends boolean = false> = Spec
293
316
 
294
317
  /**
295
318
  * True for an operation that changes a writable type's objects (`CHANGE_OPS`), which only the
296
- * apply workflow may call: by its vendor, resource and name, so a copy of a connector is told too.
319
+ * apply workflow may call on a declared object (a workflow's call is refused one, `refuseDeclared`
320
+ * in `call.ts`): by its vendor, resource and name, so a copy of a connector is told too.
297
321
  */
298
322
  export function isChangeOp(
299
323
  op: { readonly vendor: string; readonly resource: string; readonly name: string },
@@ -476,6 +500,65 @@ export function holderOf(value: unknown, path: string): { holder: State; key: st
476
500
  return isObject(holder) || Array.isArray(holder) ? { holder: holder as State, key } : undefined;
477
501
  }
478
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
+
479
562
  /** A field's dotted path as `fields` names it, without a list item's index: `teams.0` is `teams`. */
480
563
  export const fieldPathOf = (at: string) => at.replaceAll(/\.\d+(?=\.|$)/g, "");
481
564
 
@@ -484,7 +567,7 @@ export const valueAt = (value: unknown, path: string) =>
484
567
  path.split(".").reduce<unknown>((v, key) => (isObject(v) || Array.isArray(v) ? (v as State)[key] : undefined), value);
485
568
 
486
569
  /** True when the two are the same JSON, where a missing value is `null`. */
487
- function same(a: unknown, b: unknown): boolean {
570
+ export function same(a: unknown, b: unknown): boolean {
488
571
  if (a === b || (a == null && b == null)) return true;
489
572
  if (Array.isArray(a) && Array.isArray(b)) return a.length === b.length && a.every((item, i) => same(item, b[i]));
490
573
  if (isObject(a) && isObject(b)) return Object.keys({ ...a, ...b }).every((key) => same(a[key], b[key]));
@@ -565,8 +648,9 @@ function pathsIn(value: unknown, paths: ReadonlySet<string>, prefix = ""): strin
565
648
  * returns the same: `{ gone: true }` when the object no longer exists, else its fresh
566
649
  * `state`. A driver may import first when it is given no state.
567
650
  *
568
- * A `writable` type has the change operations too, which only the apply workflow may call
569
- * (`isChangeOp`): `create`, `update` and `delete` effect `write` (`delete` idempotent), and `plan`:
651
+ * A `writable` type has the change operations too, which only the apply workflow may call on a
652
+ * declared object, and any workflow on another (`isChangeOp`): `create`, `update` and `delete`
653
+ * effect `write` (`delete` idempotent), and `plan`:
570
654
  *
571
655
  * - `plan` (`read`, idempotent) takes `{ id?, state?, handle?, desired? }`, no `desired` for a
572
656
  * delete and no `id` for a create, and returns `{ action, planned?, unknown, replace }`:
@@ -577,6 +661,12 @@ function pathsIn(value: unknown, paths: ReadonlySet<string>, prefix = ""): strin
577
661
  * - `update` takes `{ id, state?, handle?, desired, expect? }` and returns the same; given
578
662
  * `expect` (a plan's `action` and `replace`), it refuses a fresh plan that differs
579
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.
580
670
  * - `delete` takes `{ id, state?, handle? }` and returns `{ id, gone: true }`.
581
671
  *
582
672
  * `desired` may hold secret markers at write-only fields, which the driver resolves; no output
@@ -589,8 +679,10 @@ function pathsIn(value: unknown, paths: ReadonlySet<string>, prefix = ""): strin
589
679
  * schema does not have, fields the vendor owns (unless the identity names them), values the
590
680
  * schema rejects, and an empty identity. Where `fields.references` allows it, a field may name
591
681
  * another declared resource of that type (`repository_id: site`): `desired` holds, and the
592
- * schema and `find` read, that resource's `name`, and `refs` says where it was named. A type
593
- * 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.
594
686
  */
595
687
  export function defineResource<
596
688
  S extends z.ZodObject,
@@ -670,6 +762,20 @@ export function defineResource<
670
762
  const owned = new Set(fields.vendorOwned.filter((path) => !named.has(path)));
671
763
  const references: References = fields.references ?? {};
672
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
+ }
673
779
  const declare = (
674
780
  given: Declaring<z.input<S>, R>,
675
781
  options?: { connection?: ConnectionRef },
@@ -682,8 +788,9 @@ export function defineResource<
682
788
  throw new Error(`${what}: select a declared connection of this connector`);
683
789
  }
684
790
  const refs: Record<string, string> = {};
685
- // Where a secret is given, with list items' indexes: the schema does not judge its marker.
686
- 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>();
687
794
  // Each resource `given` names, where `references` allows one of its type, replaced by its name.
688
795
  // `path` is the field's, as `references` names it; `at` has list items' indexes too.
689
796
  const resolve = (value: unknown, path: string, at: string): unknown => {
@@ -691,10 +798,11 @@ export function defineResource<
691
798
  if (!underAny(path, writeOnly)) {
692
799
  throw new Error(`${what}: ${at} is not write-only, so it takes a value, not a secret (${value.$secret})`);
693
800
  }
694
- if (isSanomaSecret(value.$secret) && !(host && underAny(path, new Set([host.inputs.secrets])))) {
801
+ const hostSecrets = host?.inputs.secrets;
802
+ if (isSanomaSecret(value.$secret) && !(hostSecrets !== undefined && underAny(path, new Set([hostSecrets])))) {
695
803
  throw new Error(`${what}: ${at} names ${value.$secret}, one of sanoma's own: only a host's secrets may`);
696
804
  }
697
- secrets.add(at);
805
+ unjudged.add(at);
698
806
  return value;
699
807
  }
700
808
  if (isDeclared(value)) {
@@ -706,10 +814,16 @@ export function defineResource<
706
814
  throw new Error(`${what}: ${at} names a resource of type ${types.join(" or ")}, not ${id} (${value.name})`);
707
815
  if (value.name === undefined)
708
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);
709
818
  refs[at] = `${id}:${value.name}`;
710
819
  return value.name;
711
820
  }
712
- 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
+ }
713
827
  if (!isObject(value)) return value;
714
828
  return Object.fromEntries(
715
829
  Object.entries(value).map(([field, v]) => [field, resolve(v, joinPath(path, field), joinPath(at, field))]),
@@ -729,7 +843,7 @@ export function defineResource<
729
843
  );
730
844
  }
731
845
  const parsed = schema.safeParse(desired);
732
- 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(".")));
733
847
  if (issues?.length) throw new Error(`${what}: ${z.prettifyError(new z.ZodError(issues))}`);
734
848
  const name = find?.(desired);
735
849
  if (find && (typeof name !== "string" || name === "")) throw new Error(`${what}: its ${identity} is missing`);
@@ -767,6 +881,27 @@ export function defineResource<
767
881
  return Object.freeze(resource);
768
882
  }
769
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
+
770
905
  /** Every resource type the connectors declare, by `<vendor>.<type>`. */
771
906
  export function resourceTypesOf(connectors: readonly Connector<any, any>[]): Map<string, Resource> {
772
907
  const types = new Map<string, Resource>();
package/src/resources.ts CHANGED
@@ -17,6 +17,7 @@ import {
17
17
  ANONYMOUS_PRINCIPAL,
18
18
  type At,
19
19
  declaresPeople,
20
+ isToken,
20
21
  isVisitor,
21
22
  type PeopleResource,
22
23
  personOf,
@@ -138,8 +139,10 @@ export function readPeople(root: string | undefined): DeclaredPeople {
138
139
  * (`signIn`), of the person it matches (`personOf`: email, GitHub login, then id), never an agent.
139
140
  * While no person is declared, whoever names an id is taken as given. A visitor's id (`isVisitor`)
140
141
  * is taken whatever the files say, with the groups stamped here (`visitors`, or none for
141
- * `anonymous`), never through a sign-in: the staff logins admit no visitor. Throws
142
- * `NotAdmittedError` for anyone else, and for everyone while the people's files have problems.
142
+ * `anonymous`), never through a sign-in: the staff logins admit no visitor. A visitor keeps the
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.
143
146
  */
144
147
  export function admit(
145
148
  asking: { id?: string; email?: string; github?: string; groups?: string[]; onBehalfOf?: string },
@@ -149,7 +152,11 @@ export function admit(
149
152
  const who = asking.github ?? asking.id ?? asking.email ?? "This sign-in";
150
153
  if (asking.id !== undefined && isVisitor({ id: asking.id })) {
151
154
  if (signIn) throw new NotAdmittedError(who);
152
- return asking.id === ANONYMOUS ? ANONYMOUS_PRINCIPAL : { id: asking.id, groups: [VISITORS] };
155
+ if (asking.id === ANONYMOUS) return ANONYMOUS_PRINCIPAL;
156
+ // A token's groups are its row's (`SanomaClient.tokens`), which only the public front reads; a visitor's are stamped.
157
+ if (isToken({ id: asking.id })) return { id: asking.id, groups: [...(asking.groups ?? [])] };
158
+ const { email, github } = asking;
159
+ return { id: asking.id, groups: [VISITORS], ...(email && { email }), ...(github && { github }) };
153
160
  }
154
161
  if (problems.length) {
155
162
  // The runtime's own agents are declared by no file, so a broken one does not stop them (`check`'s scenarios).
@@ -160,7 +167,10 @@ export function admit(
160
167
  }
161
168
  if (!declaresPeople(people)) {
162
169
  const given = Principal.safeParse(asking).data;
163
- if (given) return given;
170
+ if (given) {
171
+ const { email: _email, github: _github, subject: _subject, ...principal } = given;
172
+ return principal;
173
+ }
164
174
  } else {
165
175
  const id = signIn ? personOf(asking, people) : asking.id;
166
176
  const admitted = id === undefined ? undefined : principalOf(id, people);
package/src/run.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { ConnectionRef, ConnectionRegistry, ConnectionSnapshot } from "./connection.ts";
2
2
  import { AsyncLocalStorage } from "node:async_hooks";
3
3
  import type { Collection } from "./collection.ts";
4
+ import type { DeclaredObjects } from "./config.ts";
4
5
  import type { DeadlineRegistry } from "./deadlines.ts";
5
6
  import type { ApprovalState, Approver, Principal, WorkflowDefinition } from "./define.ts";
6
7
  import type { Fake } from "./fake.ts";
@@ -53,6 +54,8 @@ export interface WorkerState {
53
54
  /** The worker's `source`, when it was given one. */
54
55
  source?: Source;
55
56
  types: ReadonlyMap<string, Resource>;
57
+ /** The objects the data files declare, read at each call: a workflow's change operation may not aim at one (`refuseDeclared`). */
58
+ declaredObjects(): Promise<DeclaredObjects>;
56
59
  /** The collections, by name, which scenarios' steps may name (`data.<name>`). */
57
60
  collections: ReadonlyMap<string, Collection>;
58
61
  steps: readonly Steps[];