@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.
- package/CLOUD-IDENTITY.md +128 -17
- package/README.md +56 -13
- package/dist/apply.d.ts +11 -4
- package/dist/apply.js +127 -36
- package/dist/apply.js.map +1 -1
- package/dist/call.d.ts +5 -2
- package/dist/call.js +65 -20
- package/dist/call.js.map +1 -1
- package/dist/change.d.ts +5 -6
- package/dist/change.js +6 -9
- package/dist/change.js.map +1 -1
- package/dist/client.d.ts +18 -0
- package/dist/client.js +35 -3
- package/dist/client.js.map +1 -1
- package/dist/cloud-identity.d.ts +5 -1
- package/dist/cloud-identity.js +13 -77
- package/dist/cloud-identity.js.map +1 -1
- package/dist/config.d.ts +45 -4
- package/dist/config.js +73 -11
- package/dist/config.js.map +1 -1
- package/dist/db.d.ts +2 -0
- package/dist/db.js +7 -0
- package/dist/db.js.map +1 -1
- package/dist/deadlines.d.ts +26 -13
- package/dist/deadlines.js +50 -11
- package/dist/deadlines.js.map +1 -1
- package/dist/define.d.ts +29 -0
- package/dist/define.js +3 -0
- package/dist/define.js.map +1 -1
- package/dist/deployment-key.d.ts +20 -1
- package/dist/deployment-key.js +28 -3
- package/dist/deployment-key.js.map +1 -1
- package/dist/describe.js +28 -1
- package/dist/describe.js.map +1 -1
- package/dist/description.d.ts +3 -0
- package/dist/description.js +2 -0
- package/dist/description.js.map +1 -1
- package/dist/dns.d.ts +20 -0
- package/dist/dns.js +26 -0
- package/dist/dns.js.map +1 -0
- package/dist/drift.d.ts +4 -2
- package/dist/drift.js +13 -4
- package/dist/drift.js.map +1 -1
- package/dist/host.d.ts +13 -4
- package/dist/host.js +14 -5
- package/dist/host.js.map +1 -1
- package/dist/identity-broker.d.ts +14 -0
- package/dist/identity-broker.js +84 -0
- package/dist/identity-broker.js.map +1 -0
- package/dist/identity-provisioning.d.ts +29 -0
- package/dist/identity-provisioning.js +178 -0
- package/dist/identity-provisioning.js.map +1 -0
- package/dist/identity-service.d.ts +25 -0
- package/dist/identity-service.js +148 -0
- package/dist/identity-service.js.map +1 -0
- package/dist/index.d.ts +11 -6
- package/dist/index.js +9 -5
- package/dist/index.js.map +1 -1
- package/dist/ledger.d.ts +30 -1
- package/dist/ledger.js +31 -0
- package/dist/ledger.js.map +1 -1
- package/dist/login-code.d.ts +17 -0
- package/dist/login-code.js +32 -0
- package/dist/login-code.js.map +1 -0
- package/dist/op.d.ts +85 -10
- package/dist/op.js +20 -0
- package/dist/op.js.map +1 -1
- package/dist/outline.js +3 -3
- package/dist/outline.js.map +1 -1
- package/dist/provisioning-identity.d.ts +27 -0
- package/dist/provisioning-identity.js +81 -0
- package/dist/provisioning-identity.js.map +1 -0
- package/dist/resource.d.ts +73 -10
- package/dist/resource.js +103 -14
- package/dist/resource.js.map +1 -1
- package/dist/resources.d.ts +4 -2
- package/dist/resources.js +16 -6
- package/dist/resources.js.map +1 -1
- package/dist/run.d.ts +3 -0
- package/dist/run.js.map +1 -1
- package/dist/runtime-ops.d.ts +182 -0
- package/dist/runtime-ops.js +294 -0
- package/dist/runtime-ops.js.map +1 -0
- package/dist/scenario.d.ts +6 -3
- package/dist/scenario.js +13 -1
- package/dist/scenario.js.map +1 -1
- package/dist/seal.d.ts +27 -0
- package/dist/seal.js +76 -0
- package/dist/seal.js.map +1 -0
- package/dist/secret-store.d.ts +6 -4
- package/dist/secret-store.js +11 -6
- package/dist/secret-store.js.map +1 -1
- package/dist/secret.d.ts +7 -0
- package/dist/secret.js +13 -1
- package/dist/secret.js.map +1 -1
- package/dist/shared.d.ts +15 -4
- package/dist/shared.js +27 -7
- package/dist/shared.js.map +1 -1
- package/dist/ssh-key.d.ts +11 -0
- package/dist/ssh-key.js +44 -0
- package/dist/ssh-key.js.map +1 -0
- package/dist/tokens.d.ts +35 -0
- package/dist/tokens.js +71 -0
- package/dist/tokens.js.map +1 -0
- package/dist/wakeup-service.js +2 -7
- package/dist/wakeup-service.js.map +1 -1
- package/dist/wakeup.d.ts +18 -0
- package/dist/wakeup.js +15 -0
- package/dist/wakeup.js.map +1 -1
- package/dist/worker.d.ts +2 -2
- package/dist/worker.js +17 -9
- package/dist/worker.js.map +1 -1
- package/oxlint.json +2 -1
- package/package.json +5 -1
- package/src/apply.ts +166 -48
- package/src/call.ts +88 -18
- package/src/change.ts +9 -20
- package/src/client.ts +48 -7
- package/src/cloud-identity.ts +16 -76
- package/src/config.ts +116 -18
- package/src/db.ts +8 -1
- package/src/deadlines.ts +66 -20
- package/src/define.ts +33 -0
- package/src/deployment-key.ts +34 -8
- package/src/describe.ts +39 -2
- package/src/description.ts +2 -0
- package/src/dns.ts +29 -0
- package/src/drift.ts +13 -4
- package/src/host.ts +22 -6
- package/src/identity-broker.ts +94 -0
- package/src/identity-provisioning.ts +208 -0
- package/src/identity-service.ts +179 -0
- package/src/index.ts +20 -1
- package/src/ledger.ts +52 -1
- package/src/login-code.ts +52 -0
- package/src/op.ts +94 -11
- package/src/outline.ts +3 -3
- package/src/provisioning-identity.ts +106 -0
- package/src/resource.ts +155 -20
- package/src/resources.ts +14 -4
- package/src/run.ts +3 -0
- package/src/runtime-ops.ts +313 -0
- package/src/scenario.ts +24 -6
- package/src/seal.ts +95 -0
- package/src/secret-store.ts +12 -9
- package/src/secret.ts +16 -1
- package/src/shared.ts +31 -7
- package/src/ssh-key.ts +48 -0
- package/src/tokens.ts +100 -0
- package/src/wakeup-service.ts +2 -8
- package/src/wakeup.ts +21 -0
- 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
|
|
68
|
-
|
|
69
|
-
|
|
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
|
|
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
|
|
84
|
-
*
|
|
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
|
-
|
|
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
|
-
/**
|
|
191
|
-
|
|
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
|
|
441
|
-
|
|
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(
|
|
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
|
-
* `
|
|
121
|
-
*
|
|
122
|
-
*
|
|
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
|
-
|
|
126
|
-
|
|
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.
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
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"');
|
package/src/cloud-identity.ts
CHANGED
|
@@ -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
|
|
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
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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. */
|