@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/call.ts CHANGED
@@ -11,6 +11,7 @@ import {
11
11
  SleepFor,
12
12
  type SleepRequest,
13
13
  SleepUntil,
14
+ type StartedBy,
14
15
  scriptPath,
15
16
  type Use,
16
17
  type WorkflowDefinition,
@@ -33,14 +34,18 @@ import { callAt, callName, type CallNode } from "./outline.ts";
33
34
  import { allowAll, type Decision, type Policy, type PolicyCall, policyOpOf, type RecordedDecision } from "./policy.ts";
34
35
  import type { Run, StartHold, WorkerState } from "./run.ts";
35
36
  import { reservedFor, runtimeHold } from "./change.ts";
37
+ import { isChangeOp } from "./resource.ts";
36
38
  import { readPeople } from "./resources.ts";
37
39
  import {
40
+ APPLY_WORKFLOW,
38
41
  approverLabel,
39
42
  CALL_WORKFLOW,
40
43
  CHANGE_MERGE,
41
44
  DATA_VENDOR,
42
45
  errorMessage,
43
46
  fitsOp,
47
+ LOGIN_CODE_WORKFLOW,
48
+ problemLine,
44
49
  type Saved,
45
50
  WORKFLOW_ACTION,
46
51
  } from "./shared.ts";
@@ -64,11 +69,18 @@ export function isInfrastructureError(err: unknown, state: Pick<WorkerState, "st
64
69
  return takenByDbos(err) || (state.stopped && errorCode(err) === undefined);
65
70
  }
66
71
 
67
- /** The value as the ledger records it: each of the operation's `opaque` fields as `"<name>"`. */
68
- export function recorded(op: Op, value: unknown): unknown {
69
- if (!op.opaque?.length || typeof value !== "object" || value === null || Array.isArray(value)) return value;
72
+ /** The fields of a mail a `login-code` run sends that the ledger never shows: its body, which holds the code. */
73
+ const LOGIN_CODE_BODY = ["text", "html"];
74
+
75
+ /**
76
+ * The value as the ledger records it: each of the operation's `opaque` fields as `"<name>"`, and
77
+ * in a run of `workflow` `login-code`, the mail's `text` and `html` too, so no colleague reads another's code.
78
+ */
79
+ export function recorded(op: Op, value: unknown, workflow?: string): unknown {
80
+ const hidden = [...(op.opaque ?? []), ...(workflow === LOGIN_CODE_WORKFLOW ? LOGIN_CODE_BODY : [])];
81
+ if (!hidden.length || typeof value !== "object" || value === null || Array.isArray(value)) return value;
70
82
  const out: Record<string, unknown> = { ...value };
71
- for (const name of op.opaque) if (out[name] !== undefined) out[name] = `<${name}>`;
83
+ for (const name of hidden) if (out[name] !== undefined) out[name] = `<${name}>`;
72
84
  return out;
73
85
  }
74
86
 
@@ -80,13 +92,12 @@ const isSchemaError = (err: unknown) => err instanceof Error && err.name === "Zo
80
92
 
81
93
  /**
82
94
  * Whether DBOS should try an idempotent step again: not when the reply failed the output
83
- * schema (it would fail again), and not when the driver said the vendor's answer is final
84
- * (a `DriverError` with `retryable: false`, such as a 4xx). Read by code and property, never
85
- * `instanceof`: the error may come from another copy of this package.
95
+ * schema (it would fail again), and not when the error says its answer is final (`retryable:
96
+ * false`: a `DriverError` for a 4xx, a `SecretStoreError` other than `unavailable`). Read by
97
+ * property, never `instanceof`: the error may come from another copy of this package.
86
98
  */
87
99
  function shouldRetry(err: unknown): boolean {
88
- if (isSchemaError(err)) return false;
89
- return !(errorCode(err) === "driver_failed" && isFinal(err));
100
+ return !isSchemaError(err) && !isFinal(err);
90
101
  }
91
102
 
92
103
  /*
@@ -184,11 +195,20 @@ export function buildCtx(wf: WorkflowDefinition<any, any>, run: Run): any {
184
195
  );
185
196
  }
186
197
  if (uses.includes("all")) members.all = placed("all", (node, list) => all(run, list, node.path));
187
- return guarded(members, "ctx", wf.name);
198
+ return guarded(members, "ctx", wf.name, { startedBy: startedByOf(run.actor) });
188
199
  }
189
200
 
190
- /** `members`, each object in it included, refusing any member not in it (`strict`). */
191
- function guarded(members: Record<string, unknown>, path: string, workflow: string): Record<string, unknown> {
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, github, subject }: Principal): StartedBy =>
203
+ Object.freeze({ id, ...(email && { email }), ...(github && { github }), ...(subject && { subject }) });
204
+
205
+ /** `members`, each object in it included, refusing any member not in it (`strict`), with `plain`'s as they are. */
206
+ function guarded(
207
+ members: Record<string, unknown>,
208
+ path: string,
209
+ workflow: string,
210
+ plain: Record<string, unknown> = {},
211
+ ): Record<string, unknown> {
192
212
  const out: Record<string, unknown> = {};
193
213
  for (const [key, value] of Object.entries(members)) {
194
214
  out[key] =
@@ -196,7 +216,7 @@ function guarded(members: Record<string, unknown>, path: string, workflow: strin
196
216
  ? guarded(value as Record<string, unknown>, `${path}.${key}`, workflow)
197
217
  : value;
198
218
  }
199
- return strict(out, path, workflow);
219
+ return strict({ ...out, ...plain }, path, workflow);
200
220
  }
201
221
 
202
222
  /** Where a `ctx` member was called from, as a stack frame says. */
@@ -425,6 +445,50 @@ function ask(
425
445
  return policy.version === undefined ? decision : { ...decision, policyVersion: policy.version };
426
446
  }
427
447
 
448
+ /**
449
+ * Refuses a change operation (`isChangeOp`) aimed at an object the data files declare, which only
450
+ * apply changes: an `update` or `delete` of an id the lockfile binds or a data file names, and a
451
+ * `create` or `update` whose identity (what its type's `find` makes of `desired`) a data file
452
+ * declares or the lockfile binds. Any other object is the workflow's own, its change an ordinary
453
+ * write. While the files have problems, which objects they declare is not known, so it refuses.
454
+ */
455
+ async function refuseDeclared(run: Run, op: Op, input: unknown) {
456
+ const type = `${op.vendor}.${op.resource}`;
457
+ const { id, desired } = input as { id?: unknown; desired?: Record<string, unknown> };
458
+ const targets: string[] = [];
459
+ if (op.name !== "create" && typeof id === "string") targets.push(id);
460
+ const find = run.state.types.get(type)?.find;
461
+ if (op.name !== "delete" && desired && find) {
462
+ try {
463
+ targets.push(find(desired));
464
+ } catch (err) {
465
+ throw new SanomaError(
466
+ "invalid_input",
467
+ `${op.id}: its desired names no ${type} (${errorMessage(err)}), so it cannot be told from a declared one`,
468
+ { op: op.id, workflow: run.workflow },
469
+ );
470
+ }
471
+ }
472
+ if (!targets.length) return;
473
+ const declared = await run.state.declaredObjects();
474
+ if (declared.problems.length) {
475
+ throw new SanomaError(
476
+ "invalid_input",
477
+ `${op.id} waits for the data files to be fixed, since only apply changes a declared resource: ${declared.problems.map(problemLine).join("; ")}`,
478
+ { op: op.id, workflow: run.workflow },
479
+ );
480
+ }
481
+ for (const target of targets) {
482
+ const key = declared.keys.get(`${type} ${target}`);
483
+ if (key === undefined) continue;
484
+ throw new SanomaError(
485
+ "invalid_input",
486
+ `${op.id} changes ${key}, a declared resource, which only apply does: change the data file instead`,
487
+ { op: op.id, workflow: run.workflow, declared: key },
488
+ );
489
+ }
490
+ }
491
+
428
492
  async function callOp(run: Run, id: string, input: unknown, place: Placed, ref?: ConnectionRef) {
429
493
  // The worker's declaration, never the workflow's: its effect, schemas and retry setting.
430
494
  const op = run.state.ops.get(id);
@@ -437,9 +501,8 @@ async function callOp(run: Run, id: string, input: unknown, place: Placed, ref?:
437
501
  : `No connector or fake for ${id} in this worker: no fake its connectors attach implements it`,
438
502
  );
439
503
  }
440
- // Only apply changes a declared resource, and only change takes a change's steps; `resolveConfig`
441
- // refuses any other workflow that uses such an operation.
442
- const reserved = reservedFor(op, run.state.types);
504
+ // Only change takes a change's steps; `resolveConfig` refuses any other workflow that uses one.
505
+ const reserved = reservedFor(op);
443
506
  if (reserved && run.workflow !== reserved.workflow) {
444
507
  throw new SanomaError("invalid_input", `${op.id} ${reserved.does}, which only ${reserved.workflow} does`, {
445
508
  op: op.id,
@@ -469,12 +532,15 @@ async function callOp(run: Run, id: string, input: unknown, place: Placed, ref?:
469
532
  DBOS.runStep(
470
533
  async () => {
471
534
  if (run.sandbox === undefined) await run.state.connections?.assertCurrent(connection, run.actor.id);
535
+ // Read in the step, so a replay reuses the first answer, and asked again after each
536
+ // approval, so a call made after a long wait sees the files as they are then.
537
+ if (run.workflow !== APPLY_WORKFLOW && isChangeOp(op, run.state.types)) await refuseDeclared(run, op, parsed);
472
538
  return decide(run, op, parsed, connection);
473
539
  },
474
540
  { name: `policy:${op.id}` },
475
541
  );
476
542
  let decision = await decideStep();
477
- const logged = recorded(op, parsed);
543
+ const logged = recorded(op, parsed, run.workflow);
478
544
  const call = {
479
545
  type: "op.called",
480
546
  op: op.id,
@@ -567,7 +633,11 @@ async function callOp(run: Run, id: string, input: unknown, place: Placed, ref?:
567
633
  try {
568
634
  await write(
569
635
  run,
570
- entry(run, { ...call, output: recorded(op, output), durationMs, attempt: result.attempt }, { seq, at }),
636
+ entry(
637
+ run,
638
+ { ...call, output: recorded(op, output, run.workflow), durationMs, attempt: result.attempt },
639
+ { seq, at },
640
+ ),
571
641
  );
572
642
  } catch (err) {
573
643
  // The vendor acted, and nothing records it. A workflow that caught this and called again
package/src/change.ts CHANGED
@@ -3,18 +3,9 @@ import type { ApprovalState, Approver } from "./define.ts";
3
3
  import { errorInfo } from "./errors.ts";
4
4
  import { type CallContext, defineConnector, defineDriver, type Driver, DriverError } from "./op.ts";
5
5
  import { approve, approvedFor, type Decision, deny } from "./policy.ts";
6
- import { isChangeOp, type Resource } from "./resource.ts";
7
6
  import { currentRun } from "./run.ts";
8
7
  import type { Secrets } from "./secret.ts";
9
- import {
10
- APPLY_WORKFLOW,
11
- CHANGE_MERGE,
12
- CHANGE_WORKFLOW,
13
- DRAFT_ID,
14
- errorMessage,
15
- groupNamesOf,
16
- type PeopleResource,
17
- } from "./shared.ts";
8
+ import { CHANGE_MERGE, CHANGE_WORKFLOW, DRAFT_ID, errorMessage, groupNamesOf, type PeopleResource } from "./shared.ts";
18
9
  import {
19
10
  type CheckResult,
20
11
  type RemoteContext,
@@ -117,17 +108,15 @@ export const changeOps = defineConnector("sanoma", {
117
108
 
118
109
  /**
119
110
  * The built-in workflow that alone may call `op`, what the operation does and what to do instead:
120
- * `apply` for a writable resource type's change operations (`isChangeOp`), `change` for
121
- * `sanoma.change.*`; undefined for any other. `resolveConfig` refuses a workflow that uses one, and
122
- * a run's call of one outside its workflow fails.
111
+ * `change` for `sanoma.change.*`; undefined for any other. `resolveConfig` refuses a workflow that
112
+ * uses one, and a run's call of one outside its workflow fails. A resource type's change
113
+ * operations are not reserved: any workflow may call them for an object no data file declares,
114
+ * and the call refuses a declared one (`refuseDeclared` in `call.ts`), which only apply changes.
123
115
  */
124
- export function reservedFor(
125
- op: { readonly vendor: string; readonly resource: string; readonly name: string },
126
- types: ReadonlyMap<string, Pick<Resource, "writable">>,
127
- ): { workflow: string; does: string; instead: string } | undefined {
128
- if (isChangeOp(op, types)) {
129
- return { workflow: APPLY_WORKFLOW, does: "changes a declared resource", instead: "change the data file" };
130
- }
116
+ export function reservedFor(op: {
117
+ readonly vendor: string;
118
+ readonly resource: string;
119
+ }): { workflow: string; does: string; instead: string } | undefined {
131
120
  if (op.vendor === "sanoma" && op.resource === "change") {
132
121
  return { workflow: CHANGE_WORKFLOW, does: "changes the company's repository", instead: "send a draft for review" };
133
122
  }
package/src/client.ts CHANGED
@@ -26,6 +26,7 @@ import {
26
26
  } from "./approvals.ts";
27
27
  import { beforeOf, decideCall, decideStart, recorded, startHoldOf } from "./call.ts";
28
28
  import { type Chats, chatStore } from "./chats.ts";
29
+ import { type Tokens, tokenStore } from "./tokens.ts";
29
30
  import { collectionsDriver } from "./collection.ts";
30
31
  import { type ResolvedConfig, resolveConfig, type SanomaConfig } from "./config.ts";
31
32
  import { configuredSecrets } from "./credentials.ts";
@@ -58,9 +59,9 @@ import {
58
59
  SanomaError,
59
60
  vendorCodeOf,
60
61
  } from "./errors.ts";
61
- import { insertRecord, type LedgerRecord, type OwnedLedger, ownedLedger } from "./ledger.ts";
62
+ import { insertRecord, lastSignIns, type LedgerRecord, loginRecord, type OwnedLedger, ownedLedger } from "./ledger.ts";
62
63
  import { warn } from "./log.ts";
63
- import type { CallContext, Driver, Op } from "./op.ts";
64
+ import type { CallContext, Driver, Op, SignInMethod } from "./op.ts";
64
65
  import { allowAll, type RecordedDecision } from "./policy.ts";
65
66
  import { admit, readPeople } from "./resources.ts";
66
67
  import type { RunArgs, StartHold } from "./run.ts";
@@ -78,6 +79,7 @@ import {
78
79
  errorMessage,
79
80
  HOST_CHECK_AGENT,
80
81
  isEnded,
82
+ isToken,
81
83
  isVisitor,
82
84
  refusalOf,
83
85
  type RunStatus,
@@ -268,6 +270,8 @@ export class SanomaClient {
268
270
  private readonly storedSecrets: SecretAccess;
269
271
  /** The operator agent's chats: their rows and their transcripts, in the config's database. */
270
272
  readonly chats: Chats;
273
+ /** The api tokens a public site's api takes as a bearer (`token:<id>`, a visitor): minted, revoked, resolved. */
274
+ readonly tokens: Tokens;
271
275
  private readonly options: ClientOptions;
272
276
 
273
277
  private constructor(
@@ -284,6 +288,7 @@ export class SanomaClient {
284
288
  this.pool = lazyPool(config.databaseUrl, "client");
285
289
  this.storedSecrets = configuredSecrets(config.secrets, () => this.db());
286
290
  this.chats = chatStore(() => this.db());
291
+ this.tokens = tokenStore(() => this.db());
287
292
  }
288
293
 
289
294
  /**
@@ -315,11 +320,13 @@ export class SanomaClient {
315
320
  input: z.input<S>,
316
321
  options: StartOptions,
317
322
  ): Promise<string> {
318
- const startedBy = await this.admitted(
319
- parseOrThrow(
320
- PrincipalOrId,
321
- options?.startedBy,
322
- '`startedBy` must be a principal, such as "alice" or { id: "alice" }',
323
+ const startedBy = await this.withSubject(
324
+ await this.admitted(
325
+ parseOrThrow(
326
+ PrincipalOrId,
327
+ options?.startedBy,
328
+ '`startedBy` must be a principal, such as "alice" or { id: "alice" }',
329
+ ),
323
330
  ),
324
331
  );
325
332
  if (typeof workflow === "string") {
@@ -639,6 +646,32 @@ export class SanomaClient {
639
646
  return row ? { runId: row.workflowID, output: row.output, at: row.updatedAt ?? row.createdAt } : null;
640
647
  }
641
648
 
649
+ /** Records that the declared `person` signed in to the staff login, by `method`: a ledger record outside any run. */
650
+ async recordSignIn(person: string, method: SignInMethod): Promise<void> {
651
+ const actor = await this.admitted({ id: person });
652
+ await this.audit.store.append(
653
+ loginRecord(this.config.appName, actor, { type: "signin.recorded", person: actor.id, method }),
654
+ );
655
+ }
656
+
657
+ /** Records that `by` minted an invite for the declared `person`, good until `expiresAt`: a ledger record outside any run. */
658
+ async recordInvite(person: string, options: { by: string; expiresAt: Date }): Promise<void> {
659
+ const [actor, invited] = await Promise.all([this.admitted({ id: options.by }), this.admitted({ id: person })]);
660
+ await this.audit.store.append(
661
+ loginRecord(this.config.appName, actor, {
662
+ type: "invite.minted",
663
+ person: invited.id,
664
+ by: actor.id,
665
+ expiresAt: options.expiresAt.toISOString(),
666
+ }),
667
+ );
668
+ }
669
+
670
+ /** When each of `people` last signed in to the staff login, and how, by id (`recordSignIn`); one who never did is left out. */
671
+ lastSignIns(people: readonly string[]): Promise<Record<string, { at: number; method: SignInMethod }>> {
672
+ return lastSignIns(this.audit.store, people);
673
+ }
674
+
642
675
  /** The run's audit record, in order, from the ledger store. */
643
676
  async ledger(runId: string): Promise<LedgerRecord[]> {
644
677
  await this.mustExist(runId);
@@ -1470,6 +1503,14 @@ export class SanomaClient {
1470
1503
  return admit(principal, readPeople(this.config.root));
1471
1504
  }
1472
1505
 
1506
+ /** An api token's principal (`token:<id>`) with the subject its row acts for, refused once the token is revoked; anyone else as given. */
1507
+ private async withSubject(principal: Principal): Promise<Principal> {
1508
+ if (!isToken(principal)) return principal;
1509
+ const subject = await this.tokens.subjectOf(principal.id.slice("token:".length));
1510
+ if (subject === undefined) throw new NotAdmittedError(principal.id);
1511
+ return { ...principal, subject };
1512
+ }
1513
+
1473
1514
  /** `options.by`, who sets or clears a credential, admitted (`admitted`): their id; never a visitor's. */
1474
1515
  private async admittedBy(options: { by: string }): Promise<string> {
1475
1516
  const by = parseOrThrow(z.string().min(1), options?.by, '`by` must name who is asking, such as "alice"');
@@ -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. */