@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/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,15 +53,26 @@ 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 {
|
|
56
|
+
import { checkFromRefs, type Resource, resourceTypesOf, withoutWriteOnly } from "./resource.ts";
|
|
57
|
+
import { CODE_MAIL, loginCodeWorkflow } from "./login-code.ts";
|
|
58
|
+
import {
|
|
59
|
+
APPLY_WORKFLOW,
|
|
60
|
+
BUILT_IN_WORKFLOWS,
|
|
61
|
+
CALL_WORKFLOW,
|
|
62
|
+
CHANGE_WORKFLOW,
|
|
63
|
+
DATA_VENDOR,
|
|
64
|
+
LOGIN_CODE_WORKFLOW,
|
|
65
|
+
} from "./shared.ts";
|
|
57
66
|
import { fakeSourceDriver, type RemoteHalf, SOURCE_RESOLVE, sourceDriver, sourceOps } from "./source.ts";
|
|
58
67
|
import { appsListDriver, appsListOps } from "./apps-list.ts";
|
|
68
|
+
import { runtimeDriver, sandboxRuntimeDriver, sanoma } from "./runtime-ops.ts";
|
|
69
|
+
import { tokenStore } from "./tokens.ts";
|
|
59
70
|
import type { Steps } from "./steps.ts";
|
|
60
71
|
import { checkSchedule } from "./schedules.ts";
|
|
61
72
|
import { changeCodeOf, computeVersion } from "./version.ts";
|
|
62
|
-
import type { DeclaredResource } from "./resources.ts";
|
|
63
|
-
import { checkWakeupOps, type DeclaredWakeup } from "./wakeup.ts";
|
|
73
|
+
import type { DeclaredResource, ResourceProblem } from "./resources.ts";
|
|
74
|
+
import { checkWakeupOps, type DeclaredWakeup, type EnvWakeup, HOST_URL } from "./wakeup.ts";
|
|
75
|
+
import { instanceUrl, WAKEUP_TOKEN } from "./wakeup-service.ts";
|
|
64
76
|
import { sanomaWakeupDriver, wakeupSteps } from "./wakeup-driver.ts";
|
|
65
77
|
import { wakeupOps } from "./wakeup-type.ts";
|
|
66
78
|
import { checkDatabaseOps } from "./database.ts";
|
|
@@ -96,6 +108,8 @@ export interface SanomaConfig {
|
|
|
96
108
|
ledger?: LedgerStore;
|
|
97
109
|
/** Credential storage and explicit bindings. Omitted: encrypted Postgres, no environment fallback. */
|
|
98
110
|
secrets?: SecretConfiguration;
|
|
111
|
+
/** Trusted control plane only: separate broker enrollment writer. Absent in company runtimes. */
|
|
112
|
+
identityProvisioning?: IdentityProvisioning;
|
|
99
113
|
connections?: readonly ConnectionDefinition[];
|
|
100
114
|
connectionRules?: readonly ConnectionRule[];
|
|
101
115
|
/** Postgres for the runtime. Defaults to `SANOMA_DATABASE_URL`, then `DEFAULT_DATABASE_URL`. */
|
|
@@ -131,6 +145,13 @@ export interface SanomaConfig {
|
|
|
131
145
|
* that sets it and declares a wakeup of its own.
|
|
132
146
|
*/
|
|
133
147
|
wakeup?: true;
|
|
148
|
+
/**
|
|
149
|
+
* How the staff and visitor logins send a sign-in code: `sender`, an operation of a connector in
|
|
150
|
+
* `connectors` with the effect `send` that takes `{ to, subject, text }`, such as
|
|
151
|
+
* `resend.emails.send`. With it, the runtime registers the built-in `login-code` workflow,
|
|
152
|
+
* which the runtime's agent `login` runs; without it, nobody signs in by code.
|
|
153
|
+
*/
|
|
154
|
+
login?: { sender: Op };
|
|
134
155
|
}
|
|
135
156
|
|
|
136
157
|
/** A config, checked, with everything a worker, client or app derives from it. */
|
|
@@ -150,9 +171,12 @@ export interface ResolvedConfig {
|
|
|
150
171
|
changeCode: string;
|
|
151
172
|
/** The DBOS queue runs are started on: `sanoma:<appName>`. */
|
|
152
173
|
queueName: string;
|
|
174
|
+
/** The config's `login`, checked (`checkSender`); the built-in `login-code` workflow sends through its `sender`. */
|
|
175
|
+
login?: { sender: Op };
|
|
153
176
|
/**
|
|
154
177
|
* The operations the connectors declare, by id, the built-in `sanoma.change.*` (`changeOps`, in
|
|
155
|
-
* every config)
|
|
178
|
+
* every config) and the runtime's own a workflow may use (`sanoma`, runtime-ops.ts, in every
|
|
179
|
+
* config), `sanoma.source.resolve` and `sanoma.wakeup`'s when one declares a host type,
|
|
156
180
|
* and the collections' (`data.<name>.<op>`).
|
|
157
181
|
*/
|
|
158
182
|
ops: Map<string, Op>;
|
|
@@ -177,6 +201,11 @@ export interface ResolvedConfig {
|
|
|
177
201
|
* declares a host type.
|
|
178
202
|
*/
|
|
179
203
|
hosts(): Promise<string[]>;
|
|
204
|
+
/**
|
|
205
|
+
* The objects the data files under `root` declare, read at each call: what a workflow's change
|
|
206
|
+
* operation may not aim at, since only apply changes a declared object.
|
|
207
|
+
*/
|
|
208
|
+
declaredObjects(): Promise<DeclaredObjects>;
|
|
180
209
|
/**
|
|
181
210
|
* `root`, as a `file:` URL, when `scenarios/` or an app's `apps/<app>/scenarios/` in it is a
|
|
182
211
|
* directory: the feature files under those, each named by its path from the root.
|
|
@@ -310,17 +339,20 @@ export function resolveConfig(config: SanomaConfig): ResolvedConfig {
|
|
|
310
339
|
const declared = [...collections.values()];
|
|
311
340
|
const dataStore = declared.length ? lazyPool(databaseUrl, "data") : undefined;
|
|
312
341
|
// Built-in, in every config: a change's operations, which the template's policy names
|
|
313
|
-
// (`sanoma.change.merge`),
|
|
342
|
+
// (`sanoma.change.merge`), the listing of apps a page may call (`sanoma.apps.list`), and what a
|
|
343
|
+
// workflow asks the runtime to do for it (`sanoma`, runtime-ops.ts).
|
|
314
344
|
const ops = indexConnectors([
|
|
315
345
|
...running,
|
|
316
346
|
changeOps,
|
|
317
347
|
connectionOps,
|
|
318
348
|
appsListOps,
|
|
349
|
+
sanoma,
|
|
319
350
|
...(hostTyped ? [sourceOps] : []),
|
|
320
351
|
...(dataStore ? [collectionsConnector(declared)] : []),
|
|
321
352
|
]);
|
|
322
353
|
checkWakeupOps(types, ops);
|
|
323
354
|
checkDatabaseOps(types, ops);
|
|
355
|
+
checkFromRefs(types);
|
|
324
356
|
const driversByVendor = new Map([...makersOf(running, "driver")].map(([vendor, make]) => [vendor, make()]));
|
|
325
357
|
const remotes = new Map([...makersOf(config.connectors, "remote")].map(([vendor, make]) => [vendor, make()]));
|
|
326
358
|
const drivers = indexDrivers(
|
|
@@ -329,6 +361,7 @@ export function resolveConfig(config: SanomaConfig): ResolvedConfig {
|
|
|
329
361
|
changeDriver(remotes),
|
|
330
362
|
connectionDriver(connections),
|
|
331
363
|
appsListDriver,
|
|
364
|
+
runtimeDriver({ tokens: tokenStore(() => connectionPool.db()), identities: config.identityProvisioning }),
|
|
332
365
|
...(hostTyped ? [sourceDriver] : []),
|
|
333
366
|
...(dataStore ? [collectionsDriver(declared, dataStore)] : []),
|
|
334
367
|
],
|
|
@@ -342,7 +375,7 @@ export function resolveConfig(config: SanomaConfig): ResolvedConfig {
|
|
|
342
375
|
if (BUILT_IN_WORKFLOWS.has(wf.name)) {
|
|
343
376
|
throw new Error(`A workflow is named "${wf.name}", the name of a built-in workflow; name it otherwise`);
|
|
344
377
|
}
|
|
345
|
-
checkUses(wf, ops, drivers
|
|
378
|
+
checkUses(wf, ops, drivers);
|
|
346
379
|
checkSchedule(wf);
|
|
347
380
|
const other = names.get(wf.name);
|
|
348
381
|
if (other && other !== wf) {
|
|
@@ -368,9 +401,13 @@ export function resolveConfig(config: SanomaConfig): ResolvedConfig {
|
|
|
368
401
|
const hosts = async () =>
|
|
369
402
|
(await read()).resources.filter((r) => types.get(`${r.vendor}.${r.type}`)?.host).map((r) => r.id);
|
|
370
403
|
names.set(CHANGE_WORKFLOW, changeWorkflow(changeOps, changeSteps({ remotes, hosts })));
|
|
404
|
+
const declaredObjects = () => readDeclaredObjects(root, connectors, declarable);
|
|
371
405
|
// Built-in: a direct call the policy holds for an approval, made a run, when any operation may be called directly.
|
|
372
406
|
const call = callWorkflow(ops, drivers, types);
|
|
373
407
|
if (call.uses.length) names.set(CALL_WORKFLOW, call);
|
|
408
|
+
// Built-in: sending a sign-in code, when the config names how.
|
|
409
|
+
const login = config.login && { sender: checkSender(config.login, ops, drivers) };
|
|
410
|
+
if (login) names.set(LOGIN_CODE_WORKFLOW, loginCodeWorkflow(login.sender, appName));
|
|
374
411
|
const { policy, policies, problems: policyProblems } = policyAt(root, { ops, workflows: names, collections });
|
|
375
412
|
return {
|
|
376
413
|
secrets: config.secrets ?? {},
|
|
@@ -382,6 +419,7 @@ export function resolveConfig(config: SanomaConfig): ResolvedConfig {
|
|
|
382
419
|
version: computeVersion({ appName, workflows: [...names.values()] }),
|
|
383
420
|
changeCode: changeCodeOf(names.get(CHANGE_WORKFLOW)!),
|
|
384
421
|
queueName: `sanoma:${appName}`,
|
|
422
|
+
...(login && { login }),
|
|
385
423
|
ops,
|
|
386
424
|
drivers,
|
|
387
425
|
driversByVendor,
|
|
@@ -390,6 +428,7 @@ export function resolveConfig(config: SanomaConfig): ResolvedConfig {
|
|
|
390
428
|
models: makersOf(config.connectors, "model"),
|
|
391
429
|
remotes,
|
|
392
430
|
hosts,
|
|
431
|
+
declaredObjects,
|
|
393
432
|
...(scenarios && { scenarios }),
|
|
394
433
|
types,
|
|
395
434
|
steps: [...new Set([...running.flatMap((c) => setupOf(c).steps ?? []), ...(config.steps ?? [])])],
|
|
@@ -404,6 +443,26 @@ export function resolveConfig(config: SanomaConfig): ResolvedConfig {
|
|
|
404
443
|
};
|
|
405
444
|
}
|
|
406
445
|
|
|
446
|
+
/**
|
|
447
|
+
* The config's `login.sender`, as the config's operations have it: refused unless it is an
|
|
448
|
+
* operation of a connector in `connectors`, with the effect `send`, a driver, and an input that
|
|
449
|
+
* takes `{ to, subject, text }`.
|
|
450
|
+
*/
|
|
451
|
+
function checkSender(login: unknown, ops: ReadonlyMap<string, Op>, drivers: ReadonlyMap<string, unknown>): Op {
|
|
452
|
+
const sender: unknown = (login as { sender?: unknown } | null)?.sender;
|
|
453
|
+
const fix = "name a connector's send operation, such as `login: { sender: resend.emails.send }`";
|
|
454
|
+
if (!isOp(sender)) throw new Error(`The config's \`login.sender\` is not an operation: ${fix}`);
|
|
455
|
+
const op = ops.get(sender.id);
|
|
456
|
+
if (!op) throw new Error(`The config's \`login.sender\`, ${sender.id}, is of no connector in \`connectors\`: add it`);
|
|
457
|
+
if (op.effect !== "send")
|
|
458
|
+
throw new Error(`The config's \`login.sender\`, ${op.id}, does not send (its effect is ${op.effect}): ${fix}`);
|
|
459
|
+
if (!drivers.has(op.id))
|
|
460
|
+
throw new Error(`The config's \`login.sender\`, ${op.id}, has no driver: the connector package must attach one`);
|
|
461
|
+
if (!op.input.safeParse(CODE_MAIL).success)
|
|
462
|
+
throw new Error(`The config's \`login.sender\`, ${op.id}, does not take an email as { to, subject, text }: ${fix}`);
|
|
463
|
+
return op;
|
|
464
|
+
}
|
|
465
|
+
|
|
407
466
|
/**
|
|
408
467
|
* The config's collections by name, checked: each one `collection()` made, no two with one name,
|
|
409
468
|
* and no connector of their vendor, `DATA_VENDOR`.
|
|
@@ -431,11 +490,13 @@ function collectionsOf(config: SanomaConfig): Map<string, Collection> {
|
|
|
431
490
|
/**
|
|
432
491
|
* The built-in drivers a sandbox run calls beside its fakes (`seedSandbox`), for the built-in
|
|
433
492
|
* operations among `ops` that no fake implements: `sanoma.apps.list`'s own, which needs no
|
|
434
|
-
* credentials and changes nothing (GitHub's public search),
|
|
435
|
-
* (`
|
|
493
|
+
* credentials and changes nothing (GitHub's public search), the runtime's own operations over the
|
|
494
|
+
* run's secrets and tokens of its own (`sandboxRuntimeDriver`, made for each run), and
|
|
495
|
+
* `sanoma.source.resolve`'s fake (`fakeSourceDriver`), for a config with a host type.
|
|
436
496
|
*/
|
|
437
497
|
export const sandboxDrivers = (ops: ReadonlyMap<string, unknown>): Driver[] => [
|
|
438
498
|
appsListDriver,
|
|
499
|
+
sandboxRuntimeDriver(),
|
|
439
500
|
...(ops.has(SOURCE_RESOLVE) ? [fakeSourceDriver] : []),
|
|
440
501
|
];
|
|
441
502
|
|
|
@@ -545,6 +606,47 @@ export async function declaredWakeup(config: SanomaConfig): Promise<DeclaredWake
|
|
|
545
606
|
};
|
|
546
607
|
}
|
|
547
608
|
|
|
609
|
+
/**
|
|
610
|
+
* The wakeup `env` names (`EnvWakeup`): its host's URL, `SANOMA_HOST_URL`, as the wakeup service
|
|
611
|
+
* keeps one (`instanceUrl`), and its token, `SANOMA_WAKEUP_TOKEN`, when both are set and the URL is
|
|
612
|
+
* one; undefined otherwise. A host Sanoma Cloud made gives both to an instance that declares no
|
|
613
|
+
* wakeup of its own.
|
|
614
|
+
*/
|
|
615
|
+
export function envWakeup(env: NodeJS.ProcessEnv): EnvWakeup | undefined {
|
|
616
|
+
const host = env[HOST_URL]?.trim();
|
|
617
|
+
const token = env[WAKEUP_TOKEN]?.trim();
|
|
618
|
+
const checked = host ? instanceUrl(host) : undefined;
|
|
619
|
+
return checked && "url" in checked && token ? { url: checked.url, token } : undefined;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
/**
|
|
623
|
+
* The objects a company's data files declare, which only apply changes (`refuseDeclared` in
|
|
624
|
+
* `call.ts`), by `<vendor>.<type> <id or name>` to their key: each binding in the lockfile, an
|
|
625
|
+
* orphan's too, by its id, and each declaration by its identity (what its type's `find` made);
|
|
626
|
+
* and the files' problems, while which the declared objects are not known.
|
|
627
|
+
*/
|
|
628
|
+
export interface DeclaredObjects {
|
|
629
|
+
keys: ReadonlyMap<string, string>;
|
|
630
|
+
problems: readonly ResourceProblem[];
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
/** The objects the data files under `root` and its lockfile declare (`DeclaredObjects`): none without a root. */
|
|
634
|
+
async function readDeclaredObjects(
|
|
635
|
+
root: string | undefined,
|
|
636
|
+
connectors: readonly Connector<any, any>[],
|
|
637
|
+
declarable: ReadonlyMap<string, Resource>,
|
|
638
|
+
): Promise<DeclaredObjects> {
|
|
639
|
+
if (root === undefined) return { keys: new Map(), problems: [] };
|
|
640
|
+
// Loaded when a call asks, not with the worker.
|
|
641
|
+
const { loadDataFiles } = await import("./resources.ts");
|
|
642
|
+
const { resources, bindings, problems } = loadDataFiles(root, connectors, declarable);
|
|
643
|
+
const named = resources.flatMap((r) =>
|
|
644
|
+
r.identity === undefined ? [] : [[`${r.vendor}.${r.type} ${r.identity}`, r.id] as const],
|
|
645
|
+
);
|
|
646
|
+
const bound = Object.entries(bindings).map(([key, { type, id }]) => [`${type} ${id}`, key] as const);
|
|
647
|
+
return { keys: new Map([...named, ...bound]), problems };
|
|
648
|
+
}
|
|
649
|
+
|
|
548
650
|
/**
|
|
549
651
|
* The data files under `root` and the lockfile beside them, in one read, as a drift or apply run
|
|
550
652
|
* records them: each resource with its binding, without its write-only values (a secret marker
|
|
@@ -693,19 +795,15 @@ function guarded(vendor: string, env: DriverEnv, fn: DriverFn): DriverFn {
|
|
|
693
795
|
* Every operation a workflow uses must be one the config's connectors declare, with a driver.
|
|
694
796
|
* The workflow normally imports the same connector object; a copy is accepted only if it
|
|
695
797
|
* declares the same effect and retry setting, and the worker's declaration is used either way.
|
|
696
|
-
* None may use an operation a built-in workflow alone calls (`reservedFor`): a
|
|
697
|
-
*
|
|
798
|
+
* None may use an operation a built-in workflow alone calls (`reservedFor`): a change's step, which
|
|
799
|
+
* only the change workflow takes. A resource type's change operations are allowed: the call
|
|
800
|
+
* refuses one aimed at a declared object (`DeclaredObjects`), which only apply changes.
|
|
698
801
|
*/
|
|
699
|
-
function checkUses(
|
|
700
|
-
wf: WorkflowDefinition<any, any>,
|
|
701
|
-
ops: Map<string, Op>,
|
|
702
|
-
impls: Map<string, unknown>,
|
|
703
|
-
types: ReadonlyMap<string, Resource>,
|
|
704
|
-
) {
|
|
802
|
+
function checkUses(wf: WorkflowDefinition<any, any>, ops: Map<string, Op>, impls: Map<string, unknown>) {
|
|
705
803
|
const problems: string[] = [];
|
|
706
804
|
for (const op of (wf.uses as readonly Use[]).filter(isOp)) {
|
|
707
805
|
const known = ops.get(op.id);
|
|
708
|
-
const reserved = known && reservedFor(known
|
|
806
|
+
const reserved = known && reservedFor(known);
|
|
709
807
|
if (!known) problems.push(`${op.id} is not declared by any connector in \`connectors\``);
|
|
710
808
|
else if (reserved) {
|
|
711
809
|
problems.push(`${op.id} ${reserved.does}, which only ${reserved.workflow} does: ${reserved.instead} instead`);
|
package/src/db.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { warn } from "./log.ts";
|
|
|
5
5
|
import { errorMessage } from "./shared.ts";
|
|
6
6
|
|
|
7
7
|
// The runtime's own tables live in the config's database, beside DBOS's: the credentials, the
|
|
8
|
-
// ledger, the collections' records, the wakeup's row
|
|
8
|
+
// ledger, the collections' records, the wakeup's row, the operator agent's chats and the api tokens. What they
|
|
9
9
|
// share is here: the one preparation of a database, a pool that opens once it is prepared and
|
|
10
10
|
// survives a dropped connection, and a table creation two processes may race.
|
|
11
11
|
|
|
@@ -28,6 +28,8 @@ export const WAKEUP_TABLE = "sanoma_wakeup";
|
|
|
28
28
|
export const CHATS_TABLE = "sanoma_chats";
|
|
29
29
|
/** Each chat's messages, the AI SDK's UI messages as JSON, in `seq` order. */
|
|
30
30
|
export const CHAT_MESSAGES_TABLE = "sanoma_chat_messages";
|
|
31
|
+
/** The api tokens (`tokens.ts`): each one's SHA-256, never the token, whom it acts for, its groups, and when it was revoked. */
|
|
32
|
+
export const API_TOKENS_TABLE = "sanoma_api_tokens";
|
|
31
33
|
|
|
32
34
|
/** The runtime's tables, each with the word the messages name it by. */
|
|
33
35
|
const TABLES: readonly [what: string, ddl: string][] = [
|
|
@@ -97,6 +99,11 @@ const TABLES: readonly [what: string, ddl: string][] = [
|
|
|
97
99
|
`CREATE TABLE IF NOT EXISTS ${CHAT_MESSAGES_TABLE} (chat_id text NOT NULL REFERENCES ${CHATS_TABLE} (id) ` +
|
|
98
100
|
"ON DELETE CASCADE, id text NOT NULL, seq integer NOT NULL, message jsonb NOT NULL, PRIMARY KEY (chat_id, id))",
|
|
99
101
|
],
|
|
102
|
+
[
|
|
103
|
+
"api tokens",
|
|
104
|
+
`CREATE TABLE IF NOT EXISTS ${API_TOKENS_TABLE} (id text PRIMARY KEY, hash text NOT NULL UNIQUE, ` +
|
|
105
|
+
"subject text NOT NULL, groups jsonb NOT NULL, created_at timestamptz NOT NULL DEFAULT now(), revoked_at timestamptz)",
|
|
106
|
+
],
|
|
100
107
|
];
|
|
101
108
|
|
|
102
109
|
/** The Postgres error code of `err`, or an empty string when it has none. */
|
package/src/deadlines.ts
CHANGED
|
@@ -8,7 +8,7 @@ import { warn } from "./log.ts";
|
|
|
8
8
|
import type { CallContext, DriverFn, Op } from "./op.ts";
|
|
9
9
|
import type { Secrets } from "./secret.ts";
|
|
10
10
|
import { errorMessage, WAKEUP_AGENT } from "./shared.ts";
|
|
11
|
-
import type
|
|
11
|
+
import { type EnvWakeup, WAKE_PATH, type WakeupRegistration } from "./wakeup.ts";
|
|
12
12
|
|
|
13
13
|
// What a worker knows of its own runs that DBOS cannot tell it: which are executing and which are
|
|
14
14
|
// blocked in a `ctx` wait, and until when. DBOS sees both as PENDING. A worker that registers a
|
|
@@ -133,15 +133,15 @@ export interface SelfStop {
|
|
|
133
133
|
at: string | null;
|
|
134
134
|
}
|
|
135
135
|
|
|
136
|
-
/**
|
|
136
|
+
/** Registers a deadline with the instance's wakeup, replacing what it held; throws when it could not. */
|
|
137
|
+
export type RegisterWakeup = (registration: WakeupRegistration) => Promise<void>;
|
|
138
|
+
|
|
139
|
+
/** What the stop rule acts on: the wakeup row, how a deadline is registered, and the worker. */
|
|
137
140
|
export interface StopRule {
|
|
138
141
|
/** The wakeup row's database. */
|
|
139
142
|
db: () => Promise<Db>;
|
|
140
|
-
/** The declared wakeup's
|
|
141
|
-
|
|
142
|
-
/** The wakeup type's register operation, and its driver (none when the connector attaches none). */
|
|
143
|
-
register: { op: Op; fn: DriverFn | undefined };
|
|
144
|
-
secrets: Secrets;
|
|
143
|
+
/** The declared wakeup's register operation (`declaredRegister`), or the host's own route (`envRegister`). */
|
|
144
|
+
register: RegisterWakeup;
|
|
145
145
|
deadlines: Deadlines;
|
|
146
146
|
idle: () => Promise<boolean>;
|
|
147
147
|
everyMs: number;
|
|
@@ -174,7 +174,7 @@ export function stopWhenIdle(rule: StopRule): { close(): void } {
|
|
|
174
174
|
const db = await rule.db();
|
|
175
175
|
if (at !== null) {
|
|
176
176
|
try {
|
|
177
|
-
await
|
|
177
|
+
await rule.register({ at });
|
|
178
178
|
} catch (err) {
|
|
179
179
|
const message = errorMessage(err);
|
|
180
180
|
await recordWakeupError(db, message);
|
|
@@ -214,18 +214,64 @@ export function stopWhenIdle(rule: StopRule): { close(): void } {
|
|
|
214
214
|
};
|
|
215
215
|
}
|
|
216
216
|
|
|
217
|
+
/** A declared wakeup's register operation, and what its call needs. */
|
|
218
|
+
export interface DeclaredRegister {
|
|
219
|
+
/** The wakeup type's register operation. */
|
|
220
|
+
op: Op;
|
|
221
|
+
/** Its driver: none when the connector attaches none. */
|
|
222
|
+
fn: DriverFn | undefined;
|
|
223
|
+
/** The instance's secrets, which the driver reads the wakeup's token from. */
|
|
224
|
+
secrets: Secrets;
|
|
225
|
+
/** The declared wakeup's binding, else its declaration's id: what scopes a registration's idempotency key to this instance. */
|
|
226
|
+
wakeup: string;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Registers through the declared wakeup's register operation, as `SanomaClient.call` calls a
|
|
231
|
+
* driver: its input parsed, a `CallContext` of the runtime's, and no ledger record, which is a run's.
|
|
232
|
+
*/
|
|
233
|
+
export function declaredRegister({ op, fn, secrets, wakeup }: DeclaredRegister): RegisterWakeup {
|
|
234
|
+
return async (registration) => {
|
|
235
|
+
if (!fn) throw new Error(`${op.id} has no driver: the connector package must attach one`);
|
|
236
|
+
// Fresh each time: a repeat of an earlier `at` after another registration must re-arm it, so no
|
|
237
|
+
// two registrations share a key (real drivers never dedupe on it anyway).
|
|
238
|
+
const key = `wakeup:${wakeup}:${randomUUID()}`;
|
|
239
|
+
const context: CallContext = {
|
|
240
|
+
idempotencyKey: key,
|
|
241
|
+
runId: key,
|
|
242
|
+
opId: op.id,
|
|
243
|
+
attempt: 1,
|
|
244
|
+
secrets,
|
|
245
|
+
by: WAKEUP_AGENT,
|
|
246
|
+
};
|
|
247
|
+
await fn(op.input.parse(registration), context);
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
|
|
217
251
|
/**
|
|
218
|
-
*
|
|
219
|
-
*
|
|
252
|
+
* Registers at the host's own wake route, as the environment names it (`EnvWakeup`): `PUT
|
|
253
|
+
* {url}/__sanoma/wake` with the token as a bearer and the registration as JSON, no driver and no
|
|
254
|
+
* secret store. 204 is taken; anything else fails with its status and the route's own `error`.
|
|
220
255
|
*/
|
|
221
|
-
export
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
256
|
+
export function envRegister({ url, token }: EnvWakeup): RegisterWakeup {
|
|
257
|
+
return async (registration) => {
|
|
258
|
+
const where = `PUT ${url}${WAKE_PATH}`;
|
|
259
|
+
let res: Response;
|
|
260
|
+
try {
|
|
261
|
+
res = await fetch(`${url}${WAKE_PATH}`, {
|
|
262
|
+
method: "PUT",
|
|
263
|
+
headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
|
|
264
|
+
body: JSON.stringify(registration),
|
|
265
|
+
// A redirect is no answer of the host's, and would carry the token elsewhere.
|
|
266
|
+
redirect: "manual",
|
|
267
|
+
signal: AbortSignal.timeout(30_000),
|
|
268
|
+
});
|
|
269
|
+
} catch (err) {
|
|
270
|
+
throw new Error(`${where}: no answer (${errorMessage(err)})`, { cause: err });
|
|
271
|
+
}
|
|
272
|
+
if (res.status === 204) return;
|
|
273
|
+
const body: unknown = await res.json().catch(() => undefined);
|
|
274
|
+
const error = (body as { error?: unknown } | undefined)?.error;
|
|
275
|
+
throw new Error(`${where} answered ${res.status}${typeof error === "string" ? `: ${error}` : ""}`);
|
|
276
|
+
};
|
|
231
277
|
}
|
package/src/define.ts
CHANGED
|
@@ -18,14 +18,45 @@ export interface Principal {
|
|
|
18
18
|
groups?: string[];
|
|
19
19
|
/** For an agent: the id of the person whose instruction it carries. */
|
|
20
20
|
onBehalfOf?: string;
|
|
21
|
+
/**
|
|
22
|
+
* For a visitor: the email their site's login verified (`LoginHalf.verify`), which the app
|
|
23
|
+
* passes on. Admission keeps it for a visitor alone; a run's `ctx.startedBy.email`.
|
|
24
|
+
*/
|
|
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;
|
|
31
|
+
/**
|
|
32
|
+
* For an api token (`token:<id>`): the subject its row acts for, which `SanomaClient.start`
|
|
33
|
+
* reads from the row, never from the caller; a run's `ctx.startedBy.subject`.
|
|
34
|
+
*/
|
|
35
|
+
subject?: string;
|
|
21
36
|
}
|
|
22
37
|
|
|
23
38
|
export const Principal: z.ZodType<Principal> = z.object({
|
|
24
39
|
id: z.string().regex(/\S/, "id must not be blank"),
|
|
25
40
|
groups: z.array(z.string()).optional(),
|
|
26
41
|
onBehalfOf: z.string().regex(/\S/, "onBehalfOf must not be blank").optional(),
|
|
42
|
+
email: z.string().optional(),
|
|
43
|
+
github: z.string().optional(),
|
|
44
|
+
subject: z.string().optional(),
|
|
27
45
|
});
|
|
28
46
|
|
|
47
|
+
/**
|
|
48
|
+
* Who started a run, as its workflow sees it (`ctx.startedBy`): the principal's id (`alice`,
|
|
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.
|
|
52
|
+
*/
|
|
53
|
+
export interface StartedBy {
|
|
54
|
+
readonly id: string;
|
|
55
|
+
readonly email?: string;
|
|
56
|
+
readonly github?: string;
|
|
57
|
+
readonly subject?: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
29
60
|
/** A built-in a workflow may call, as its description lists them. */
|
|
30
61
|
export type Builtin = WorkflowEntry["builtins"][number];
|
|
31
62
|
export type Use = Op | Builtin;
|
|
@@ -227,6 +258,8 @@ interface BaseCtx {
|
|
|
227
258
|
*/
|
|
228
259
|
now(): Promise<number>;
|
|
229
260
|
readonly runId: string;
|
|
261
|
+
/** Who started the run: read, never called (`StartedBy`). */
|
|
262
|
+
readonly startedBy: StartedBy;
|
|
230
263
|
}
|
|
231
264
|
|
|
232
265
|
export type Ctx<U extends readonly Use[]> = BaseCtx &
|
package/src/deployment-key.ts
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
|
+
import { hkdfSync } from "node:crypto";
|
|
1
2
|
import { readFileSync } from "node:fs";
|
|
2
3
|
|
|
4
|
+
/** The deployment's own keys: bound only to the environment or bootstrap, never written by the runtime. */
|
|
5
|
+
export const DEPLOYMENT_KEYS: readonly string[] = ["SANOMA_SIGNING_KEY", "SANOMA_SECRETS_KEY"];
|
|
6
|
+
export type DeploymentKeyName = "SANOMA_SIGNING_KEY" | "SANOMA_SECRETS_KEY";
|
|
7
|
+
|
|
3
8
|
/** Bootstrap only: never resolves through the secret store that the key unlocks. */
|
|
4
|
-
export function deploymentKey(
|
|
5
|
-
name: "SANOMA_SECRETS_KEY" | "SANOMA_SIGNING_KEY",
|
|
6
|
-
env: NodeJS.ProcessEnv = process.env,
|
|
7
|
-
): Buffer {
|
|
9
|
+
export function deploymentKey(name: DeploymentKeyName, env: NodeJS.ProcessEnv = process.env): Buffer {
|
|
8
10
|
const value = env[name];
|
|
9
11
|
const file = env[`${name}_FILE`];
|
|
10
12
|
if (value && file) throw new Error(`Set only one of ${name} and ${name}_FILE`);
|
|
@@ -16,8 +18,32 @@ export function deploymentKey(
|
|
|
16
18
|
throw new Error(`${name}_FILE could not be read`);
|
|
17
19
|
}
|
|
18
20
|
}
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
21
|
+
const key = encoded ? keyBytes(encoded) : undefined;
|
|
22
|
+
if (!key) throw new Error(`${name} must be 32 random bytes in base64; supply ${name} or ${name}_FILE`);
|
|
23
|
+
return key;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The 32 bytes of a key written as text: padded base64 (`sanoma key`), or base64url without
|
|
28
|
+
* padding (a `secret(..., { generate: true })` marker's value). Anything else is `undefined`.
|
|
29
|
+
*/
|
|
30
|
+
export function keyBytes(text: string): Buffer | undefined {
|
|
31
|
+
if (/^[A-Za-z0-9+/]{43}=$/.test(text)) return Buffer.from(text, "base64");
|
|
32
|
+
if (/^[A-Za-z0-9_-]{43}$/.test(text)) return Buffer.from(text, "base64url");
|
|
33
|
+
return undefined;
|
|
23
34
|
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* A 32-byte key of its own for `purpose`, derived from a deployment's signing key (HKDF-SHA256
|
|
38
|
+
* over its base64 text), as that deployment derives it from its `SANOMA_SIGNING_KEY`.
|
|
39
|
+
*/
|
|
40
|
+
export const deriveSigningKey = (signingKey: Buffer, purpose: string): Buffer =>
|
|
41
|
+
Buffer.from(hkdfSync("sha256", signingKey.toString("base64"), "", purpose, 32));
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* A 32-byte key of its own for `purpose`, derived from `SANOMA_SIGNING_KEY` (`deriveSigningKey`):
|
|
45
|
+
* each use of the signing key (a login's cookies, the setup's state, sealed values) gets a key no
|
|
46
|
+
* other use shares.
|
|
47
|
+
*/
|
|
48
|
+
export const signingKeyFor = (purpose: string): Buffer =>
|
|
49
|
+
deriveSigningKey(deploymentKey("SANOMA_SIGNING_KEY"), purpose);
|
package/src/describe.ts
CHANGED
|
@@ -4,7 +4,8 @@ import { type AppEntry, appsAt } from "./apps.ts";
|
|
|
4
4
|
import { readSource, type SourceFile } from "./ast.ts";
|
|
5
5
|
import { readPolicyFiles } from "./cedar.ts";
|
|
6
6
|
import { recordSchemaOf } from "./collection.ts";
|
|
7
|
-
import {
|
|
7
|
+
import { decideCall } from "./call.ts";
|
|
8
|
+
import { connectorsOf, type ResolvedConfig, resolveConfig, rootOf, type SanomaConfig } from "./config.ts";
|
|
8
9
|
import { type Builtin, jsonSchemaOf, opIdsOf, type Use } from "./define.ts";
|
|
9
10
|
import {
|
|
10
11
|
CAPABILITIES,
|
|
@@ -17,6 +18,7 @@ import {
|
|
|
17
18
|
} from "./description.ts";
|
|
18
19
|
import { isDirect } from "./direct.ts";
|
|
19
20
|
import { type CredentialStatus, credentialsOf } from "./env.ts";
|
|
21
|
+
import { CODE_MAIL } from "./login-code.ts";
|
|
20
22
|
import { lintWorkflow } from "./lint.ts";
|
|
21
23
|
import { fresh, stampOf, stampsOf } from "./load.ts";
|
|
22
24
|
import { LOCKFILE } from "./lockfile.ts";
|
|
@@ -27,7 +29,17 @@ import { allowAll, policyOpOf } from "./policy.ts";
|
|
|
27
29
|
import { type Resource, resourceTypesOf, valueAt } from "./resource.ts";
|
|
28
30
|
import { type DeclaredResource, inAppPeople, loadDataFiles, type ResourceProblem } from "./resources.ts";
|
|
29
31
|
import { apiNameProblem } from "./sdk.ts";
|
|
30
|
-
import {
|
|
32
|
+
import {
|
|
33
|
+
apiNameOf,
|
|
34
|
+
BUILT_IN_WORKFLOWS,
|
|
35
|
+
DATA_VENDOR,
|
|
36
|
+
errorMessage,
|
|
37
|
+
LOGIN_AGENT,
|
|
38
|
+
LOGIN_CODE_WORKFLOW,
|
|
39
|
+
type PeopleResource,
|
|
40
|
+
principalOf,
|
|
41
|
+
problemLine,
|
|
42
|
+
} from "./shared.ts";
|
|
31
43
|
import { readSites, type SiteEntry } from "./sites.ts";
|
|
32
44
|
|
|
33
45
|
// `@sanoma-ai/workflows/describe`: what a UI renders from, apart from the main entry.
|
|
@@ -282,6 +294,7 @@ export function describeConfig(config: SanomaConfig): ConfigDescription {
|
|
|
282
294
|
...appProblems,
|
|
283
295
|
...appClashes(apps, collections, sites),
|
|
284
296
|
...domainProblems(dataFiles.resources, resources, sites),
|
|
297
|
+
...loginProblems(resolved, dataFiles.resources),
|
|
285
298
|
],
|
|
286
299
|
apps,
|
|
287
300
|
policy: {
|
|
@@ -291,9 +304,33 @@ export function describeConfig(config: SanomaConfig): ConfigDescription {
|
|
|
291
304
|
files: resolved.root === undefined ? [] : readPolicyFiles(resolved.root),
|
|
292
305
|
},
|
|
293
306
|
sites,
|
|
307
|
+
...(resolved.login && { login: { sender: resolved.login.sender.id } }),
|
|
294
308
|
};
|
|
295
309
|
}
|
|
296
310
|
|
|
311
|
+
/**
|
|
312
|
+
* The policy holding or denying the sign-in code: `login.sender` as the runtime's agent `login`
|
|
313
|
+
* in a run of `login-code` must be allowed, or nobody receives a code.
|
|
314
|
+
*/
|
|
315
|
+
function loginProblems(resolved: ResolvedConfig, people: readonly PeopleResource[]): ResourceProblem[] {
|
|
316
|
+
if (!resolved.login || resolved.policyProblems.length) return [];
|
|
317
|
+
const { sender } = resolved.login;
|
|
318
|
+
const agent = principalOf(LOGIN_AGENT, people)!;
|
|
319
|
+
const run = { id: "check", workflow: LOGIN_CODE_WORKFLOW, approvals: [] };
|
|
320
|
+
const decision = decideCall(resolved.policy, sender, CODE_MAIL, agent, run);
|
|
321
|
+
if (decision.kind === "allow") return [];
|
|
322
|
+
const does = decision.kind === "deny" ? "denies" : "holds";
|
|
323
|
+
const by = decision.reasons?.length ? ` (${decision.reasons.join(", ")})` : "";
|
|
324
|
+
return [
|
|
325
|
+
{
|
|
326
|
+
file: "policies",
|
|
327
|
+
message:
|
|
328
|
+
`the policy ${does} ${sender.id} for the sign-in code${by}, so nobody receives one: exempt the runtime's ` +
|
|
329
|
+
`agent, \`unless { principal == Sanoma::Principal::"${LOGIN_AGENT}" }\``,
|
|
330
|
+
},
|
|
331
|
+
];
|
|
332
|
+
}
|
|
333
|
+
|
|
297
334
|
/** That the root's `kind` `name`, in `file`, is named as an app is. */
|
|
298
335
|
const appClash = (kind: string, name: string, file: string): ResourceProblem => ({
|
|
299
336
|
file,
|
package/src/description.ts
CHANGED
|
@@ -422,5 +422,7 @@ export const ConfigDescriptionSchema = z.strictObject({
|
|
|
422
422
|
* the config does not have as its problems; none when the root has no `sites/`.
|
|
423
423
|
*/
|
|
424
424
|
sites: z.array(SiteEntrySchema),
|
|
425
|
+
/** The config's `login`: the operation its sign-in codes are sent with, by id. */
|
|
426
|
+
login: z.strictObject({ sender: z.string() }).optional(),
|
|
425
427
|
});
|
|
426
428
|
export type ConfigDescription = z.infer<typeof ConfigDescriptionSchema>;
|
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>;
|