@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/apply.ts CHANGED
@@ -24,9 +24,13 @@ import {
24
24
  afterCreateStuck,
25
25
  fieldPathOf,
26
26
  holderOf,
27
+ type Missing,
27
28
  type PlanAction,
28
29
  type Resource,
30
+ same,
31
+ type Unfilled,
29
32
  valueAt,
33
+ withFrom,
30
34
  } from "./resource.ts";
31
35
  import type { ResourceProblem } from "./resources.ts";
32
36
  import { type APPLY_WORKFLOW, errorMessage } from "./shared.ts";
@@ -60,13 +64,14 @@ export type ApplyInput = z.infer<typeof ApplyInput>;
60
64
  /**
61
65
  * One declared resource, or orphaned binding, by what apply does with it: `create` it (as the
62
66
  * vendor plans: `planned`, the paths `unknown` until it exists), `update` or `replace` its bound
63
- * object (the declared `fields` that differ, the paths that force the replace), `bind` it to an
67
+ * object (the declared `fields` that differ, the paths that force the replace, any `unknown` until
68
+ * it is done: a `from` field whose resource changes first), `bind` it to an
64
69
  * object its identity `found` or the input `given`, ask about a `gone` object or an `orphan`ed
65
70
  * binding (unless `answer`ed), or `none`.
66
71
  */
67
72
  export type PlanStep = { id: string; vendor: string; type: string; connection?: ConnectionRef } & (
68
73
  | { action: "create"; planned: Record<string, unknown>; unknown: string[] }
69
- | { action: "update" | "replace"; binding: string; fields: DriftField[]; replace: string[] }
74
+ | { action: "update" | "replace"; binding: string; fields: DriftField[]; replace: string[]; unknown?: string[] }
70
75
  | { action: "bind"; binding: string; source: "found" | "given" }
71
76
  | { action: "gone"; binding: string; answer?: PlanAnswer }
72
77
  | { action: "orphan"; key: string; binding: string; answer?: PlanAnswer }
@@ -183,22 +188,40 @@ function inDependencyOrder<T extends { id: string; refs: Record<string, string>
183
188
  return out;
184
189
  }
185
190
 
191
+ /** What a run knows of the declared resources as it goes, by declared id, which `desiredOf` reads. */
192
+ interface Known {
193
+ /** The lockfile's bindings, as the run has changed them so far. */
194
+ bindings: Bindings;
195
+ /** The clone URL and commit a host wanted runs. */
196
+ hosts: ReadonlyMap<string, { source: string; revision: string }>;
197
+ /** Where each host answers, as its state says. */
198
+ urls: ReadonlyMap<string, string>;
199
+ /**
200
+ * A resource's state as found or as its step left it, to read a `from` reference's path in;
201
+ * undefined while it has none known there.
202
+ */
203
+ stateOf: (named: string, from: string) => State | undefined;
204
+ }
205
+
186
206
  /**
187
207
  * What a change operation is given of a declaration: its fields, where it names a resource the
188
208
  * vendor may hold by another field (`by`, as a repository's node id), that resource's binding id
189
- * when it has one, made in this run or before; a wakeup's `host`, the URL its host answers at
190
- * (`urls`, by the host's declared id) once its state says.
209
+ * when it has one, made in this run or before; where a `from` reference names one, that
210
+ * resource's state at its path, or while it has none there, `null` (`unknown`) or nothing
211
+ * (`drop`), as `missing` says, with those references as `unfilled`; a wakeup's `host`,
212
+ * the URL its host answers at (`urls`, by the host's declared id) once its state says.
191
213
  */
192
214
  function desiredOf(
193
215
  r: Declaration,
194
216
  types: ReadonlyMap<string, Resource>,
195
- bindings: Bindings,
196
- hosts: ReadonlyMap<string, { source: string; revision: string }>,
197
- urls: ReadonlyMap<string, string>,
198
- ): State {
217
+ known: Known,
218
+ missing: Missing | ((named: string, from: string) => Missing),
219
+ ): { desired: State; unfilled: Unfilled[] } {
220
+ const { bindings, hosts, urls, stateOf } = known;
199
221
  const { fields, host, wakeup } = types.get(`${r.vendor}.${r.type}`)!;
200
222
  const { references = {} } = fields;
201
- const out = structuredClone(r.desired);
223
+ const filled = withFrom(references, r.desired, r.refs, stateOf, missing);
224
+ const out = filled.desired === r.desired ? structuredClone(r.desired) : filled.desired;
202
225
  for (const [at, target] of Object.entries(r.refs)) {
203
226
  const bound = bindings[target]?.id;
204
227
  const place = holderOf(out, at);
@@ -220,9 +243,16 @@ function desiredOf(
220
243
  if (place) place.holder[place.key] = value;
221
244
  }
222
245
  }
223
- return out;
246
+ return { desired: out, unfilled: filled.unfilled };
224
247
  }
225
248
 
249
+ /** Whether two dotted paths name the same field, or one a field inside the other. */
250
+ const overlaps = (a: string, b: string) => a === b || a.startsWith(`${b}.`) || b.startsWith(`${a}.`);
251
+
252
+ /** Why a step cannot write a field a `from` reference takes: its resource has no state, or nothing at the path. */
253
+ const waitingFor = ({ at, named, from, stated }: Unfilled) =>
254
+ `its ${at} come from ${named}, ${stated ? `whose state has no ${from} yet` : "which has no state yet"}`;
255
+
226
256
  /** Why apply cannot `what` (create, update, delete) an object of a type. */
227
257
  const cannotChange = (type: Pick<Resource, "vendor" | "type" | "writable"> | undefined, what: string) =>
228
258
  type?.writable
@@ -292,6 +322,9 @@ function decide(
292
322
  return result.status === "drifted" ? { step, problem: cannot("update") } : { step };
293
323
  }
294
324
 
325
+ /** Whether apply makes a declared resource: one unbound (`absent`) or `gone`. */
326
+ const creates = (result: DriftResult) => result.status === "absent" || result.status === "gone";
327
+
295
328
  /** The step a `plan` answer makes of a declared resource: a create for one unbound or gone, else an update, a replace or none. */
296
329
  function fromPlan(
297
330
  r: Declaration,
@@ -299,12 +332,21 @@ function fromPlan(
299
332
  p: { action: PlanAction; planned?: State; unknown: string[]; replace: string[] },
300
333
  ): { step?: PlanStep; problem?: ResourceProblem } {
301
334
  const head = { id: r.id, vendor: r.vendor, type: r.type };
302
- const creating = result.status === "absent" || result.status === "gone";
335
+ const creating = creates(result);
303
336
  if (creating && p.action === "create") {
304
337
  return { step: { ...head, action: "create", planned: p.planned ?? {}, unknown: p.unknown } };
305
338
  }
306
339
  if (!creating && (p.action === "update" || p.action === "replace")) {
307
- return { step: { ...head, action: p.action, binding: result.binding!, fields: result.fields, replace: p.replace } };
340
+ return {
341
+ step: {
342
+ ...head,
343
+ action: p.action,
344
+ binding: result.binding!,
345
+ fields: result.fields,
346
+ replace: p.replace,
347
+ ...(p.unknown.length && { unknown: p.unknown }),
348
+ },
349
+ };
308
350
  }
309
351
  if (!creating && p.action === "none") return { step: { ...head, action: "none", binding: result.binding! } };
310
352
  return { problem: problemOf(r.id, `the vendor plans to ${p.action} it, which apply does not do here`) };
@@ -432,9 +474,14 @@ function messageOf(step: PlanStep, before: Binding | undefined, after: Binding |
432
474
  * and right after one that changes a binding, the lockfile written and committed
433
475
  * (`steps.write`), so an object made is never left unbound. An update is given the plan's action
434
476
  * and replace (`expect`), and refuses (`plan_changed`) when the vendor now plans otherwise; a
435
- * reference to a resource made in this run is given its new binding, and a wakeup's `host` the
436
- * URL its host's state gives (`outputs(state).url`), found or made in this run. A failed step is reported
437
- * and the run goes on; a failure that ends the run (`isRunControlError`) ends it.
477
+ * reference to a resource made in this run is given its new binding, a `from` reference that
478
+ * resource's state at its path as an earlier step left it (planned as `null` while this apply
479
+ * changes it there, or a create's while it has none there, so the change is planned and approved
480
+ * up front, and the step planned again once the value is known: done without a write when it
481
+ * plans `none`, else expecting the fresh plan; a step whose resource still has none there, its
482
+ * step failed or not done, fails before its driver is called), and a wakeup's `host` the URL its host's state gives
483
+ * (`outputs(state).url`), found or made in this run. A failed step is reported and the run
484
+ * goes on; a failure that ends the run (`isRunControlError`) ends it.
438
485
  */
439
486
  export function applyWorkflow(
440
487
  types: ReadonlyMap<string, Resource>,
@@ -499,15 +546,21 @@ export function applyWorkflow(
499
546
  }
500
547
  }
501
548
  }
502
- // Where each declared host answers, as its state says, found now or made below: a wakeup is given it.
549
+ // Each declared resource's state, found now or made below, for a `from` reference to it, and
550
+ // where each declared host answers, as its state says: a wakeup is given it.
551
+ const states = new Map<string, State>();
503
552
  const urls = new Map<string, string>();
504
- const answersAt = (r: Declaration, state: State) => {
553
+ // The resources whose step to change them has started and not left a state yet.
554
+ const unsettled = new Set<string>();
555
+ const keep = (r: Declaration, state: State) => {
556
+ states.set(r.id, state);
557
+ unsettled.delete(r.id);
505
558
  const url = types.get(`${r.vendor}.${r.type}`)?.host?.outputs(state).url;
506
559
  if (url) urls.set(r.id, url);
507
560
  };
508
561
  for (const resource of resources) {
509
562
  const now = found.get(resource.id);
510
- if (now && "state" in now) answersAt(resource, now.state);
563
+ if (now && "state" in now) keep(resource, now.state);
511
564
  }
512
565
  // A host wanted runs the commit its ref names now, resolved before planning: a merge is a change
513
566
  // to it. One bound by the input needs none.
@@ -534,32 +587,69 @@ export function applyWorkflow(
534
587
  }
535
588
  }
536
589
  const drift = new Map(compare(types, resources, found, revisions).map((result) => [result.id, result]));
537
- const desired = new Map(resources.map((r) => [r.id, desiredOf(r, types, locked, hosts, urls)]));
538
-
590
+ // Each declaration's fields as planned, each after the ones it names. A `from` field whose
591
+ // value this apply changes is planned unknown (`null`), so the change it makes is in the plan,
592
+ // and the approval: its resource created or replaced, or updated where the update's plan
593
+ // leaves the path unknown or plans another value there. One whose resource has no state
594
+ // there is `null` in a create's plan, and left out of a bound object's, as drift does, and
595
+ // run so.
596
+ const desired = new Map<string, State>();
597
+ // For each resource a step changes: whether the change touches a path of its state.
598
+ const touches = new Map<string, (from: string) => boolean>();
599
+ // For each step, the `from` fields planned left out: run so, not waited for.
600
+ const dropped = new Map<string, Set<string>>();
601
+ // The steps planned with a `from` field `null`: planned again at their step, once known.
602
+ const awaited = new Set<string>();
539
603
  const planned: PlanStep[] = [];
604
+ const add = (step: PlanStep, after?: State) => {
605
+ planned.push(step);
606
+ const before = states.get(step.id);
607
+ if (runOf(step) === "create" || step.action === "replace") touches.set(step.id, () => true);
608
+ else if (step.action === "update")
609
+ touches.set(
610
+ step.id,
611
+ (from) =>
612
+ (step.unknown ?? []).some((path) => overlaps(path, from)) ||
613
+ after === undefined ||
614
+ !same(valueAt(after, from), valueAt(before, from)),
615
+ );
616
+ };
540
617
  for (const resource of inDependencyOrder(resources).filter((r) => wanted(r.id))) {
541
618
  const result = drift.get(resource.id)!;
619
+ const changesAt = (named: string, from: string) => touches.get(named)?.(from) ?? false;
620
+ const missing = (named: string, from: string): Missing =>
621
+ creates(result) || changesAt(named, from) ? "unknown" : "drop";
622
+ const stateOf = (named: string, from: string) => (changesAt(named, from) ? undefined : states.get(named));
623
+ const { desired: fields, unfilled } = desiredOf(
624
+ resource,
625
+ types,
626
+ { bindings: locked, hosts, urls, stateOf },
627
+ missing,
628
+ );
629
+ desired.set(resource.id, fields);
630
+ const left = unfilled.filter((u) => missing(u.named, u.from) === "drop").map((u) => u.at);
631
+ if (left.length) dropped.set(resource.id, new Set(left));
632
+ if (left.length < unfilled.length) awaited.add(resource.id);
542
633
  const unresolved = revisions.get(resource.id);
543
634
  // A host whose ref could not be resolved has no commit to run (compare says so only for one it found).
544
635
  const next =
545
636
  unresolved && "error" in unresolved && result.status !== "error"
546
637
  ? { problem: problemOf(resource.id, unresolved.error) }
547
- : decide(resource, result, found.get(resource.id)!, input, desired.get(resource.id)!, types, usable);
638
+ : decide(resource, result, found.get(resource.id)!, input, fields, types, usable);
548
639
  if (next.ask !== undefined) {
549
640
  try {
550
- const answer = fromPlan(
551
- resource,
552
- result,
553
- await ctx[resource.vendor]![resource.type]!.plan(next.ask, { connection: resource.connection }),
554
- );
555
- if (answer.step) planned.push(answer.step);
641
+ const answered = await ctx[resource.vendor]![resource.type]!.plan(next.ask, {
642
+ connection: resource.connection,
643
+ });
644
+ const answer = fromPlan(resource, result, answered);
645
+ if (answer.step) add(answer.step, answered.planned);
556
646
  if (answer.problem) problems.push(answer.problem);
557
647
  } catch (err) {
558
648
  if (isRunControlError(err)) throw err;
559
649
  problems.push(problemOf(resource.id, `could not be planned: ${errorMessage(err)}`));
560
650
  }
561
651
  }
562
- if (next.step) planned.push(next.step);
652
+ if (next.step) add(next.step);
563
653
  if (next.problem) problems.push(next.problem);
564
654
  }
565
655
  const orphaned = orphansOf(orphans, input, wanted, usable, types);
@@ -579,47 +669,74 @@ export function applyWorkflow(
579
669
  }
580
670
 
581
671
  const bindings: Bindings = { ...locked };
672
+ // A resource whose step failed or waited has no state known where its change touches it: a
673
+ // `from` reference there fails, never given the state before; elsewhere the state stands.
674
+ const settled = (named: string, from: string) =>
675
+ unsettled.has(named) && touches.get(named)?.(from) ? undefined : states.get(named);
676
+ const known: Known = { bindings, hosts, urls, stateOf: settled };
582
677
  const results: ApplyResult[] = [];
583
678
  let written = false;
584
679
  let revision: string | undefined;
585
680
  for (const step of plan.steps) {
586
- const todo = runOf(step);
681
+ const declared = declarations.get(step.id);
682
+ const binding = "binding" in step ? step.binding : "";
683
+ const run = runOf(step);
684
+ const writing = run === "create" || run === "update";
685
+ // For a step that writes them, its fields as the run knows them now, a `from` field from
686
+ // its resource's state as an earlier step left it. One not known yet is left out, so a
687
+ // driver is never given `null`: planned unknown, the step fails, its driver not called;
688
+ // planned left out (its resource had no state there and no change), it runs without.
689
+ const filled = declared && writing ? desiredOf(declared, types, known, "drop") : undefined;
690
+ const unfilled = filled?.unfilled.find((u) => !dropped.get(step.id)?.has(u.at));
691
+ const waiting = unfilled && waitingFor(unfilled);
692
+ // Unsettled until the step leaves its new state (`keep`). Its URL stays: a host answers
693
+ // where it did until a change moves it.
694
+ if (writing) unsettled.add(step.id);
695
+ const todo = waiting === undefined ? run : "none";
587
696
  const key = keyOf(step);
588
697
  const type = `${step.vendor}.${step.type}`;
589
698
  const before = bindings[key];
590
- const binding = "binding" in step ? step.binding : "";
591
- const declared = declarations.get(step.id);
592
699
  const spec = types.get(type);
593
700
  let state: State | undefined;
594
701
  try {
595
702
  if (todo === "create") {
596
703
  const made = await ctx[step.vendor]![step.type]!.create(
597
- {
598
- desired: desiredOf(declared!, types, bindings, hosts, urls),
599
- },
704
+ { desired: filled!.desired },
600
705
  { connection: step.connection },
601
706
  );
602
707
  bindings[key] = { type, id: made.id, ...(step.connection && { connection: step.connection }) };
603
708
  state = made.state;
604
709
  } else if (todo === "update" && (step.action === "update" || step.action === "replace")) {
605
- const expect = { action: step.action, replace: step.replace };
606
- const changed = await ctx[step.vendor]![step.type]!.update(
607
- {
608
- id: binding,
609
- desired: desiredOf(declared!, types, bindings, hosts, urls),
610
- expect,
611
- },
612
- { connection: step.connection },
613
- );
614
- bindings[key] = { type, id: changed.id, ...(step.connection && { connection: step.connection }) };
615
- state = changed.state;
710
+ // A step planned with a `from` field unknown (its resource changed first) is planned again with its fields as now known: the approval covered the change in kind,
711
+ // and the vendor now says which. Nothing to write, it is done; else the update expects
712
+ // the fresh plan, an action other than an update or replace refused as changed.
713
+ let expect: Expect = { action: step.action, replace: step.replace };
714
+ let unchanged = false;
715
+ if (awaited.has(step.id)) {
716
+ const fresh = await ctx[step.vendor]![step.type]!.plan(
717
+ { id: binding, desired: filled!.desired },
718
+ { connection: step.connection },
719
+ );
720
+ unchanged = fresh.action === "none";
721
+ if (fresh.action === "update" || fresh.action === "replace")
722
+ expect = { action: fresh.action, replace: fresh.replace };
723
+ }
724
+ if (unchanged) unsettled.delete(step.id);
725
+ else {
726
+ const changed = await ctx[step.vendor]![step.type]!.update(
727
+ { id: binding, desired: filled!.desired, expect },
728
+ { connection: step.connection },
729
+ );
730
+ bindings[key] = { type, id: changed.id, ...(step.connection && { connection: step.connection }) };
731
+ state = changed.state;
732
+ }
616
733
  } else if (todo === "delete") {
617
734
  await ctx[step.vendor]![step.type]!.delete({ id: binding }, { connection: step.connection });
618
735
  delete bindings[key];
619
736
  } else if (todo === "bind")
620
737
  bindings[key] = { type, id: binding, ...(step.connection && { connection: step.connection }) };
621
738
  else if (todo === "forget") delete bindings[key];
622
- if (state && declared) answersAt(declared, state);
739
+ if (state && declared) keep(declared, state);
623
740
  const after = bindings[key];
624
741
  if (after?.id !== before?.id) {
625
742
  const wrote = await steps.write(key, after, messageOf(step, before, after));
@@ -630,7 +747,8 @@ export function applyWorkflow(
630
747
  // id, again every few seconds until its output passes `until`; one that never does fails
631
748
  // the step, and the rest are not called. The loop `callAfterCreate` has, inline: a
632
749
  // function defined here may not close over `ctx`.
633
- let stuck: string | undefined;
750
+ // A step waiting on a `from` reference ran as `none`, and fails here with why.
751
+ let stuck = waiting;
634
752
  for (const hook of todo === "create" ? (spec?.afterCreate ?? []) : []) {
635
753
  if (stuck === undefined) {
636
754
  const [vendor = "", group = "", name = ""] = hook.op.split(".");
package/src/call.ts CHANGED
@@ -199,8 +199,8 @@ export function buildCtx(wf: WorkflowDefinition<any, any>, run: Run): any {
199
199
  }
200
200
 
201
201
  /** Who started the run, as `ctx.startedBy` shows it: a plain frozen record, whose absent fields read as undefined. */
202
- const startedByOf = ({ id, email, subject }: Principal): StartedBy =>
203
- Object.freeze({ id, ...(email && { email }), ...(subject && { subject }) });
202
+ const startedByOf = ({ id, email, github, subject }: Principal): StartedBy =>
203
+ Object.freeze({ id, ...(email && { email }), ...(github && { github }), ...(subject && { subject }) });
204
204
 
205
205
  /** `members`, each object in it included, refusing any member not in it (`strict`), with `plain`'s as they are. */
206
206
  function guarded(
@@ -1,6 +1,12 @@
1
1
  import { createHash, randomBytes } from "node:crypto";
2
2
  import { Compute, OAuth2Client } from "google-auth-library";
3
3
  import type { Db } from "./db.ts";
4
+ import { enrollmentBroker } from "./identity-broker.ts";
5
+ export { provisioningIdentityBroker, attachedGoogleIdTokenIssuer } from "./provisioning-identity.ts";
6
+ export type { ProvisioningIdentity, ProvisioningIdToken } from "./provisioning-identity.ts";
7
+
8
+ export { postgresIdentityProvisioning, IdentityProvisioningError } from "./identity-provisioning.ts";
9
+ export type { IdentityProvisioning, DeploymentIdentity, EnrollIdentity } from "./identity-provisioning.ts";
4
10
 
5
11
  const LIFETIME = 600_000;
6
12
  const SCOPE = "https://www.googleapis.com/auth/cloud-platform";
@@ -15,7 +21,7 @@ export class CloudIdentityError extends Error {
15
21
  }
16
22
  }
17
23
 
18
- /** Provisioning only. Deliver the value once through deployment secrets; persist only its hash. */
24
+ /** Provisioning only. Deliver through deployment secrets; persist only its hash in the enrollment database. */
19
25
  export function createDeploymentCredential(): { credential: string; hash: string } {
20
26
  const credential = randomBytes(32).toString("base64url");
21
27
  return { credential, hash: credentialHash(credential) };
@@ -103,12 +109,6 @@ export interface IdentityBrokerOptions {
103
109
  /** Must durably record the decision without token values. A failure prevents delivery. */
104
110
  audit(event: IdentityAudit): Promise<void>;
105
111
  }
106
- const validEnrollment = (e: IdentityEnrollment | undefined): e is IdentityEnrollment =>
107
- !!e &&
108
- [e.deployment, e.company, e.environment].every((v) => typeof v === "string" && v.length > 0 && v.length <= 256) &&
109
- ACCOUNT.test(e.serviceAccount) &&
110
- Number.isSafeInteger(e.generation) &&
111
- e.generation > 0;
112
112
  const validToken = (token: CloudAccessToken, now: number) =>
113
113
  typeof token?.accessToken === "string" &&
114
114
  token.accessToken.length > 0 &&
@@ -117,77 +117,17 @@ const validToken = (token: CloudAccessToken, now: number) =>
117
117
  Number.isFinite(token.expiresAt) &&
118
118
  token.expiresAt > now &&
119
119
  token.expiresAt <= now + LIFETIME + 30_000;
120
- const reply = (status: number, body: unknown) =>
121
- Response.json(body, {
122
- status,
123
- headers: { "Cache-Control": "no-store", Pragma: "no-cache" },
124
- });
125
-
126
- // Node/edge adapters can represent an empty POST as a stream. Reject actual bytes,
127
- // without buffering attacker-controlled input or waiting indefinitely for its end.
128
- async function hasBody(request: Request): Promise<boolean> {
129
- if (!request.body) return false;
130
- const reader = request.body.getReader();
131
- let timeout: ReturnType<typeof setTimeout> | undefined;
132
- try {
133
- const first = await Promise.race([
134
- reader.read(),
135
- new Promise<never>((_, reject) => {
136
- timeout = setTimeout(() => reject(new CloudIdentityError()), 5_000);
137
- }),
138
- ]);
139
- return !first.done;
140
- } finally {
141
- clearTimeout(timeout);
142
- void reader.cancel().catch(() => {});
143
- }
144
- }
145
-
146
120
  /** POST /token with a deployment bearer credential and no query or body. No enrollment endpoint. */
147
121
  export function identityBroker(options: IdentityBrokerOptions): (request: Request) => Promise<Response> {
148
- return async (request) => {
149
- let enrollment: IdentityEnrollment | undefined;
150
- try {
151
- const url = new URL(request.url);
152
- if (url.pathname !== "/token" || request.method !== "POST") return reply(404, { error: "not_found" });
153
- if (url.search || (await hasBody(request))) return reply(400, { error: "invalid_request" });
154
- const authorization = request.headers.get("authorization") ?? "";
155
- const credential = authorization.startsWith("Bearer ") ? authorization.slice(7) : "";
156
- const hash = CREDENTIAL.test(credential) ? credentialHash(credential) : undefined;
157
- enrollment = hash ? await options.enrollments.active(hash) : undefined;
158
- if (!validEnrollment(enrollment)) {
159
- await options.audit({ result: "denied" });
160
- return reply(401, { error: "unauthorized" });
161
- }
162
- // Copy authoritative values before any awaits; caller fields never select a target or lifetime.
163
- enrollment = {
164
- deployment: enrollment.deployment,
165
- company: enrollment.company,
166
- environment: enrollment.environment,
167
- serviceAccount: enrollment.serviceAccount,
168
- generation: enrollment.generation,
169
- };
170
- const token = await options.issue(enrollment.serviceAccount);
171
- if (!validToken(token, Date.now())) throw new CloudIdentityError();
172
- const current = await options.enrollments.active(hash!);
173
- if (
174
- !current ||
175
- (Object.keys(enrollment) as (keyof IdentityEnrollment)[]).some((key) => current[key] !== enrollment![key])
176
- ) {
177
- await options.audit({ result: "denied", enrollment });
178
- return reply(401, { error: "unauthorized" });
179
- }
180
- await options.audit({ result: "issued", enrollment, expiresAt: token.expiresAt });
181
- return reply(200, { accessToken: token.accessToken, expiresAt: token.expiresAt });
182
- } catch {
183
- try {
184
- await options.audit({ result: "unavailable", ...(enrollment && { enrollment }) });
185
- } catch {
186
- /* fail closed */
187
- }
188
- return reply(503, { error: "unavailable" });
189
- }
190
- };
122
+ return enrollmentBroker({
123
+ ...options,
124
+ path: "/token",
125
+ method: "POST",
126
+ accept: () => true,
127
+ issue: (enrollment) => options.issue(enrollment.serviceAccount),
128
+ valid: validToken,
129
+ body: (token) => ({ accessToken: token.accessToken, expiresAt: token.expiresAt }),
130
+ });
191
131
  }
192
132
 
193
133
  /** Cloud Run attached identity only: no ADC environment file or service-account private key fallback. */
package/src/config.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { configuredSecrets } from "./credentials.ts";
2
+ import type { IdentityProvisioning } from "./identity-provisioning.ts";
2
3
  import { secretsForInput } from "./secret.ts";
3
4
  import { isRunControlError } from "./errors.ts";
4
5
  import { currentRun } from "./run.ts";
@@ -52,7 +53,7 @@ import {
52
53
  } from "./op.ts";
53
54
  import { type CedarPolicySource, policyAt } from "./cedar.ts";
54
55
  import type { Policy } from "./policy.ts";
55
- import { type Resource, resourceTypesOf, withoutWriteOnly } from "./resource.ts";
56
+ import { checkFromRefs, type Resource, resourceTypesOf, withoutWriteOnly } from "./resource.ts";
56
57
  import { CODE_MAIL, loginCodeWorkflow } from "./login-code.ts";
57
58
  import {
58
59
  APPLY_WORKFLOW,
@@ -107,6 +108,8 @@ export interface SanomaConfig {
107
108
  ledger?: LedgerStore;
108
109
  /** Credential storage and explicit bindings. Omitted: encrypted Postgres, no environment fallback. */
109
110
  secrets?: SecretConfiguration;
111
+ /** Trusted control plane only: separate broker enrollment writer. Absent in company runtimes. */
112
+ identityProvisioning?: IdentityProvisioning;
110
113
  connections?: readonly ConnectionDefinition[];
111
114
  connectionRules?: readonly ConnectionRule[];
112
115
  /** Postgres for the runtime. Defaults to `SANOMA_DATABASE_URL`, then `DEFAULT_DATABASE_URL`. */
@@ -349,6 +352,7 @@ export function resolveConfig(config: SanomaConfig): ResolvedConfig {
349
352
  ]);
350
353
  checkWakeupOps(types, ops);
351
354
  checkDatabaseOps(types, ops);
355
+ checkFromRefs(types);
352
356
  const driversByVendor = new Map([...makersOf(running, "driver")].map(([vendor, make]) => [vendor, make()]));
353
357
  const remotes = new Map([...makersOf(config.connectors, "remote")].map(([vendor, make]) => [vendor, make()]));
354
358
  const drivers = indexDrivers(
@@ -357,7 +361,7 @@ export function resolveConfig(config: SanomaConfig): ResolvedConfig {
357
361
  changeDriver(remotes),
358
362
  connectionDriver(connections),
359
363
  appsListDriver,
360
- runtimeDriver({ tokens: tokenStore(() => connectionPool.db()) }),
364
+ runtimeDriver({ tokens: tokenStore(() => connectionPool.db()), identities: config.identityProvisioning }),
361
365
  ...(hostTyped ? [sourceDriver] : []),
362
366
  ...(dataStore ? [collectionsDriver(declared, dataStore)] : []),
363
367
  ],
package/src/define.ts CHANGED
@@ -23,6 +23,11 @@ export interface Principal {
23
23
  * passes on. Admission keeps it for a visitor alone; a run's `ctx.startedBy.email`.
24
24
  */
25
25
  email?: string;
26
+ /**
27
+ * For a visitor: the GitHub login their site's login verified (`LoginHalf.verify`: signed in
28
+ * with GitHub, or linked it), kept as `email` is; a run's `ctx.startedBy.github`.
29
+ */
30
+ github?: string;
26
31
  /**
27
32
  * For an api token (`token:<id>`): the subject its row acts for, which `SanomaClient.start`
28
33
  * reads from the row, never from the caller; a run's `ctx.startedBy.subject`.
@@ -35,17 +40,20 @@ export const Principal: z.ZodType<Principal> = z.object({
35
40
  groups: z.array(z.string()).optional(),
36
41
  onBehalfOf: z.string().regex(/\S/, "onBehalfOf must not be blank").optional(),
37
42
  email: z.string().optional(),
43
+ github: z.string().optional(),
38
44
  subject: z.string().optional(),
39
45
  });
40
46
 
41
47
  /**
42
48
  * Who started a run, as its workflow sees it (`ctx.startedBy`): the principal's id (`alice`,
43
- * `visitor:<login>:<subject>`, `token:<id>`), with the email a visitor's login verified, and for
44
- * an api token the subject its row acts for, so a workflow can tell whose a record is.
49
+ * `visitor:<login>:<subject>`, `token:<id>`), with the email and GitHub login a visitor's login
50
+ * verified, and for an api token the subject its row acts for, so a workflow can tell whose a
51
+ * record is.
45
52
  */
46
53
  export interface StartedBy {
47
54
  readonly id: string;
48
55
  readonly email?: string;
56
+ readonly github?: string;
49
57
  readonly subject?: string;
50
58
  }
51
59
 
package/src/dns.ts ADDED
@@ -0,0 +1,29 @@
1
+ import { z } from "zod";
2
+
3
+ /**
4
+ * A DNS record as one resource's state gives it and another's input takes it, through a `from`
5
+ * reference (`FieldReference.from`): the records a sending domain asks for (`resend.domain`'s
6
+ * `dns_records`), which a zone's record set writes (`cloudflare.records`' `records`).
7
+ */
8
+ export const dnsRecord = z
9
+ .object({
10
+ type: z.enum(["TXT", "MX", "CNAME", "CAA"]),
11
+ /** The full name, lower case, with no trailing dot: `resend._domainkey.example.com`. */
12
+ name: z
13
+ .string()
14
+ .refine((name) => name === name.toLowerCase(), "a record's name is lower case")
15
+ .refine((name) => !name.endsWith("."), "a record's name has no trailing dot"),
16
+ /** As the zone holds it: a TXT record's text unquoted. */
17
+ content: z.string(),
18
+ /** An MX record's priority. */
19
+ priority: z.number().int().optional(),
20
+ /** In seconds; 1 is automatic, the zone's default. */
21
+ ttl: z.number().int().optional(),
22
+ })
23
+ .refine(({ type, content }) => type !== "TXT" || !/^".*"$/s.test(content), {
24
+ message: "a TXT record's content is its text, not wrapped in quotes",
25
+ path: ["content"],
26
+ });
27
+
28
+ /** One DNS record: see `dnsRecord`. */
29
+ export type DnsRecord = z.infer<typeof dnsRecord>;
package/src/drift.ts CHANGED
@@ -2,7 +2,7 @@ import { z } from "zod";
2
2
  import { defineWorkflow, type WorkflowDefinition } from "./define.ts";
3
3
  import { isRunControlError } from "./errors.ts";
4
4
  import type { Op } from "./op.ts";
5
- import { type DriftField, diffDeclared, fieldPathOf, type Resource, valueAt } from "./resource.ts";
5
+ import { type DriftField, diffDeclared, fieldPathOf, type Resource, valueAt, withFrom } from "./resource.ts";
6
6
  import type { DeclaredResource, ResourceProblem } from "./resources.ts";
7
7
  import { type DRIFT_WORKFLOW, errorMessage } from "./shared.ts";
8
8
  import { SOURCE_RESOLVE, type sourceOps } from "./source.ts";
@@ -128,8 +128,10 @@ export const observed = (out: { gone: boolean; state?: State }): Found =>
128
128
  * Each declared resource against what its import `found`: `error`, `absent` (unbound, with the
129
129
  * binding id of what its identity found as `found`), `gone`, else the state and the declared
130
130
  * fields compared by `diffDeclared`, a reference against the state of the resource it names
131
- * (so GitHub's node id for a repository counts as its name). A host with a `revisions` entry
132
- * also compares its revision against the commit its ref names now (or is that entry's `error`).
131
+ * (so GitHub's node id for a repository counts as its name), and a `from` reference's field
132
+ * against that resource's state at its path, left out while that resource is absent, gone or
133
+ * unreadable (its own row says so). A host with a `revisions` entry also compares its revision
134
+ * against the commit its ref names now (or is that entry's `error`).
133
135
  */
134
136
  export function compare(
135
137
  types: ReadonlyMap<string, Resource>,
@@ -137,6 +139,12 @@ export function compare(
137
139
  found: ReadonlyMap<string, Found>,
138
140
  revisions: ReadonlyMap<string, Revision> = new Map(),
139
141
  ): DriftResult[] {
142
+ const bound = new Set(resources.filter((r) => r.binding !== undefined).map((r) => r.id));
143
+ // A resource's state as a `from` reference takes it: only a bound one's, read in this run.
144
+ const stateOf = (target: string) => {
145
+ const now = found.get(target);
146
+ return bound.has(target) && now && "state" in now ? now.state : undefined;
147
+ };
140
148
  return resources.map(({ id, vendor, type, name, binding, desired, refs }): DriftResult => {
141
149
  const row = { id, type, vendor, name, ...(binding !== undefined && { binding }) };
142
150
  const now = found.get(id)!;
@@ -158,7 +166,8 @@ export function compare(
158
166
  const named = found.get(target);
159
167
  if (named && "state" in named) accept[at] = by.map((field) => named.state[field]).filter((v) => v != null);
160
168
  }
161
- const fields = diffDeclared(resource, now.state, desired, accept);
169
+ const declared = withFrom(resource.fields.references, desired, refs, stateOf, "drop").desired;
170
+ const fields = diffDeclared(resource, now.state, declared, accept);
162
171
  const resolved = revisions.get(id);
163
172
  if (resolved && "error" in resolved) return { ...row, status: "error", fields: [], error: resolved.error };
164
173
  // A host whose state does not say which commit it runs is not compared.