@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/ssh-key.ts ADDED
@@ -0,0 +1,48 @@
1
+ import { createPrivateKey, createPublicKey, generateKeyPair, type KeyObject } from "node:crypto";
2
+ import { promisify } from "node:util";
3
+
4
+ // SSH key pairs, as a deploy key needs them: what going live makes (the app's `goLive`) and
5
+ // `sanoma.secret.generate`'s `ssh-rsa` kind.
6
+
7
+ /** RFC 4253's `string`: the bytes after their length, 32 bits big-endian. */
8
+ function sshString(bytes: Buffer) {
9
+ const length = Buffer.alloc(4);
10
+ length.writeUInt32BE(bytes.length);
11
+ return Buffer.concat([length, bytes]);
12
+ }
13
+
14
+ /** RFC 4253's `mpint` of a JWK's integer, unsigned with no leading zero: one first when its top bit is set. */
15
+ function sshMpint(value: string) {
16
+ const bytes = Buffer.from(value, "base64url");
17
+ return sshString(bytes[0]! & 0x80 ? Buffer.concat([Buffer.of(0), bytes]) : bytes);
18
+ }
19
+
20
+ /** An RSA public key in OpenSSH's one-line format, as GitHub takes it: `ssh-rsa`, then the base64 of the strings `ssh-rsa`, `e` and `n`. */
21
+ function openSshLine(publicKey: KeyObject): string {
22
+ const { e, n } = publicKey.export({ format: "jwk" });
23
+ const wire = Buffer.concat([sshString(Buffer.from("ssh-rsa")), sshMpint(e!), sshMpint(n!)]);
24
+ return `ssh-rsa ${wire.toString("base64")}`;
25
+ }
26
+
27
+ /**
28
+ * A new deploy key: its private half RSA 3072 in PKCS#1 PEM, which OpenSSH reads as it is (Node
29
+ * writes no OpenSSH private key, which ed25519 would need), and its public half in OpenSSH's
30
+ * one-line format (`sshPublicKey`).
31
+ */
32
+ export async function sourceKeyPair(): Promise<{ privateKey: string; publicKey: string }> {
33
+ const { privateKey, publicKey } = await promisify(generateKeyPair)("rsa", { modulusLength: 3072 });
34
+ return {
35
+ privateKey: privateKey.export({ type: "pkcs1", format: "pem" }).toString(),
36
+ publicKey: openSshLine(publicKey),
37
+ };
38
+ }
39
+
40
+ /** The public half of an RSA private key in PEM, in OpenSSH's one-line format; undefined for anything else. */
41
+ export function sshPublicKey(privateKey: string): string | undefined {
42
+ try {
43
+ const key = createPublicKey(createPrivateKey(privateKey));
44
+ return key.asymmetricKeyType === "rsa" ? openSshLine(key) : undefined;
45
+ } catch {
46
+ return undefined;
47
+ }
48
+ }
package/src/tokens.ts ADDED
@@ -0,0 +1,100 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import type { Pool } from "pg";
3
+ import { API_TOKENS_TABLE as TABLE } from "./db.ts";
4
+ import type { Principal } from "./define.ts";
5
+ import { generateSecret, tokenHash as hashOf } from "./secret.ts";
6
+
7
+ // The api tokens, in the config's database beside the runtime's other tables: a caller of a
8
+ // public site's api (`callFn`) with `Authorization: Bearer sca_…` is the principal `token:<id>`,
9
+ // a visitor, with the row's groups. Only a token's SHA-256 is kept; the token is shown once, at mint.
10
+
11
+ /** What every token starts with, so a bearer that is not one is told at a glance. */
12
+ const PREFIX = "sca_";
13
+
14
+ /** The api tokens' store, `SanomaClient.tokens`. */
15
+ export interface Tokens {
16
+ /**
17
+ * Mints a token acting for `subject` (the principal it stands for, such as the account's
18
+ * `visitor:better-auth:<id>`) in `groups`: its id (`id`, or a new one) and the token, which is
19
+ * shown only here. An id another token has is refused.
20
+ */
21
+ mint(token: { id?: string; subject: string; groups: readonly string[] }): Promise<{ id: string; token: string }>;
22
+ /** Revokes the token `id`: it resolves to nobody from now on. Revoking one revoked or missing does nothing. */
23
+ revoke(token: { id: string }): Promise<void>;
24
+ /**
25
+ * Who a bearer token is (`token:<id>`, in its row's groups), with the subject it acts for;
26
+ * undefined for anything not a token minted here, or one revoked.
27
+ */
28
+ resolve(token: string): Promise<{ principal: Principal; subject: string } | undefined>;
29
+ /** The subject the token `id` acts for (a workflow's `ctx.startedBy.subject`); undefined for one revoked or missing. */
30
+ subjectOf(id: string): Promise<string | undefined>;
31
+ }
32
+
33
+ export function tokenStore(db: () => Promise<Pool>): Tokens {
34
+ return {
35
+ async mint({ id = randomUUID(), subject, groups }) {
36
+ const token = `${PREFIX}${generateSecret()}`;
37
+ await (
38
+ await db()
39
+ ).query(`INSERT INTO ${TABLE} (id, hash, subject, groups) VALUES ($1, $2, $3, $4::jsonb)`, [
40
+ id,
41
+ hashOf(token),
42
+ subject,
43
+ JSON.stringify(groups),
44
+ ]);
45
+ return { id, token };
46
+ },
47
+ async revoke({ id }) {
48
+ await (await db()).query(`UPDATE ${TABLE} SET revoked_at = now() WHERE id = $1 AND revoked_at IS NULL`, [id]);
49
+ },
50
+ async resolve(token) {
51
+ if (!token.startsWith(PREFIX)) return undefined;
52
+ // Looked up by its hash: the token itself is never stored, so there is nothing to compare it to.
53
+ const { rows } = await (
54
+ await db()
55
+ ).query<{ id: string; subject: string; groups: string[] }>(
56
+ `SELECT id, subject, groups FROM ${TABLE} WHERE hash = $1 AND revoked_at IS NULL`,
57
+ [hashOf(token)],
58
+ );
59
+ const row = rows[0];
60
+ if (!row) return undefined;
61
+ return { principal: { id: `token:${row.id}`, groups: row.groups }, subject: row.subject };
62
+ },
63
+ async subjectOf(id) {
64
+ const { rows } = await (
65
+ await db()
66
+ ).query<{ subject: string }>(`SELECT subject FROM ${TABLE} WHERE id = $1 AND revoked_at IS NULL`, [id]);
67
+ return rows[0]?.subject;
68
+ },
69
+ };
70
+ }
71
+
72
+ /** The api tokens in memory, for a sandbox run's `sanoma.token.*`: the same contract, nothing kept past the process. */
73
+ export function memoryTokens(): Tokens {
74
+ const rows = new Map<string, { hash: string; subject: string; groups: string[]; revoked: boolean }>();
75
+ return {
76
+ async mint({ id = randomUUID(), subject, groups }) {
77
+ if (rows.has(id)) throw new Error(`A token with the id ${id} exists`);
78
+ const token = `${PREFIX}${generateSecret()}`;
79
+ rows.set(id, { hash: hashOf(token), subject, groups: [...groups], revoked: false });
80
+ return { id, token };
81
+ },
82
+ async revoke({ id }) {
83
+ const row = rows.get(id);
84
+ if (row) row.revoked = true;
85
+ },
86
+ async resolve(token) {
87
+ const hash = hashOf(token);
88
+ for (const [id, row] of rows) {
89
+ if (row.hash === hash && !row.revoked) {
90
+ return { principal: { id: `token:${id}`, groups: [...row.groups] }, subject: row.subject };
91
+ }
92
+ }
93
+ return undefined;
94
+ },
95
+ async subjectOf(id) {
96
+ const row = rows.get(id);
97
+ return row && !row.revoked ? row.subject : undefined;
98
+ },
99
+ };
100
+ }
@@ -1,7 +1,7 @@
1
- import { createHash, randomUUID } from "node:crypto";
1
+ import { randomUUID } from "node:crypto";
2
2
  import { connectMaking, createTable, type Db, openPool } from "./db.ts";
3
3
  import { info, warn } from "./log.ts";
4
- import { generateSecret } from "./secret.ts";
4
+ import { generateSecret, tokenHash as hashOf } from "./secret.ts";
5
5
  import { errorMessage } from "./shared.ts";
6
6
 
7
7
  /*
@@ -110,12 +110,6 @@ export const WAKEUP_TOKEN = "SANOMA_WAKEUP_TOKEN";
110
110
  /** An instance token the service takes: `swi_`, then at least as many base64url characters as a minted one has. */
111
111
  export const INSTANCE_TOKEN = /^swi_[\w-]{43,256}$/;
112
112
 
113
- /**
114
- * The token's sha256, which is all the tables keep: a token is found by its hash (a unique index),
115
- * so the secret itself is never compared, and a guess's timing says nothing an attacker can steer.
116
- */
117
- const hashOf = (token: string) => createHash("sha256").update(token).digest("hex");
118
-
119
113
  /** A date-time as `Date.toISOString` writes one, or with an offset: four-digit years only. */
120
114
  const ISO_DATE_TIME = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d{1,9})?)?(Z|[+-]\d{2}:\d{2})$/;
121
115
 
package/src/wakeup.ts CHANGED
@@ -11,6 +11,27 @@ import type { DeclaredResource } from "./resources.ts";
11
11
  // replaced registration starts a worker that finds nothing to do and stops again. The contract is
12
12
  // here; the worker's side is `deadlines.ts`.
13
13
 
14
+ /**
15
+ * A host's own wake route, where a wakeup that the host holds itself takes a registration: `PUT`
16
+ * with `WakeupRegistration` and the instance's `SANOMA_WAKEUP_TOKEN` as a bearer, answered 204.
17
+ * The Cloudflare Worker serves it; a hosted instance that declares no wakeup registers there
18
+ * (`EnvWakeup`).
19
+ */
20
+ export const WAKE_PATH = "/__sanoma/wake";
21
+
22
+ /**
23
+ * The wakeup a hosted instance's environment names, when its data files declare none: its own
24
+ * host's URL (`SANOMA_HOST_URL`) and its token (`SANOMA_WAKEUP_TOKEN`), which the host gives the
25
+ * runtime. The worker registers each deadline at `{url}{WAKE_PATH}` itself, with no driver.
26
+ */
27
+ export interface EnvWakeup {
28
+ url: string;
29
+ token: string;
30
+ }
31
+
32
+ /** Where a host gives the runtime its own URL: the Cloudflare Worker's plain-text binding. */
33
+ export const HOST_URL = "SANOMA_HOST_URL";
34
+
14
35
  /** What a wakeup's state says of it. */
15
36
  export interface WakeupOutputs {
16
37
  healthy: boolean;
package/src/worker.ts CHANGED
@@ -2,10 +2,19 @@ import { DBOS, type ScheduledWorkflowFn } from "@dbos-inc/dbos-sdk";
2
2
  import { awaitApproval } from "./approvals.ts";
3
3
  import { buildCtx, decideStart, isInfrastructureError, startHoldOf } from "./call.ts";
4
4
  import { dbosStatusesOf } from "./client.ts";
5
- import { declaredWakeup, type ResolvedConfig, resolveConfig, type SanomaConfig } from "./config.ts";
5
+ import { declaredWakeup, envWakeup, type ResolvedConfig, resolveConfig, type SanomaConfig } from "./config.ts";
6
6
  import { configuredSecrets } from "./credentials.ts";
7
7
  import { type Db, DBOS_DATABASE_OPTIONS, lazyPool, prepareDatabase, stampVersion } from "./db.ts";
8
- import { deadlineRegistry, type Deadlines, idle, onOwnQueue, type SelfStop, stopWhenIdle } from "./deadlines.ts";
8
+ import {
9
+ deadlineRegistry,
10
+ type Deadlines,
11
+ declaredRegister,
12
+ envRegister,
13
+ idle,
14
+ onOwnQueue,
15
+ type SelfStop,
16
+ stopWhenIdle,
17
+ } from "./deadlines.ts";
9
18
  import type { WorkflowDefinition } from "./define.ts";
10
19
  import { credentialsOf, unconfigured, unconfiguredSummary } from "./env.ts";
11
20
  import { errorInfo, parseOrThrow, PolicyDeniedError } from "./errors.ts";
@@ -79,10 +88,10 @@ export interface WorkerOptions {
79
88
  * and other short waits never stop it.
80
89
  */
81
90
  idleHorizonMs?: number;
82
- /** How often a worker whose data files declare a wakeup checks whether it may stop: every 15 s by default. */
91
+ /** How often a worker with a wakeup (declared, or named by its environment: `envWakeup`) checks whether it may stop: every 15 s by default. */
83
92
  stopCheckMs?: number;
84
93
  /**
85
- * Given, and with a wakeup declared, the worker stops itself when idle (`stopCheckMs`); without
94
+ * Given, and with a wakeup (declared, or named by the environment), the worker stops itself when idle (`stopCheckMs`); without
86
95
  * it, nobody would start another, so it never does. Called once the worker has stopped itself, with the deadline it registered with the
87
96
  * wakeup (null for none): a supervisor starts a fresh worker on the next write or wake, since a
88
97
  * stopped one cannot launch again.
@@ -108,9 +117,12 @@ export async function startWorker(config: SanomaConfig, options: WorkerOptions =
108
117
  setLogLevel(options.logLevel);
109
118
  // Read once, when it matters: a worker stops itself, registering its next deadline there, only
110
119
  // when its caller hears it stop (`onStopped`).
111
- const wakeup = options.onStopped || config.wakeup === true ? await declaredWakeup(config) : undefined;
120
+ const declared = options.onStopped || config.wakeup === true ? await declaredWakeup(config) : undefined;
121
+ // Else the one a host Sanoma Cloud made names in the environment, which an instance running the
122
+ // wakeup service ignores: it must not stop.
123
+ const wakeup = declared ?? (options.onStopped && config.wakeup !== true ? envWakeup(process.env) : undefined);
112
124
  // The wakeup service wakes the instances that stop: one that runs it must not stop with its worker.
113
- if (config.wakeup === true && wakeup) {
125
+ if (config.wakeup === true && declared) {
114
126
  throw new Error(
115
127
  "The config runs the wakeup service (`wakeup: true`) and declares a wakeup of its own: an instance that runs the service must not stop, so leave out one of them",
116
128
  );
@@ -130,6 +142,7 @@ export async function startWorker(config: SanomaConfig, options: WorkerOptions =
130
142
  ...(resolved.scenarios && { scenarios: resolved.scenarios }),
131
143
  ...(options.source && { source: options.source }),
132
144
  types: resolved.types,
145
+ declaredObjects: resolved.declaredObjects,
133
146
  collections: resolved.collections,
134
147
  steps: resolved.steps,
135
148
  policy: resolved.policy,
@@ -232,14 +245,19 @@ export async function startWorker(config: SanomaConfig, options: WorkerOptions =
232
245
  warn(`the policies do not hold, so every call and start is refused:\n ${resolved.policyProblems.join("\n ")}`);
233
246
  }
234
247
  if (wakeup && options.onStopped) {
235
- const registerId = wakeup.type.wakeup!.register;
236
248
  stopRule = stopWhenIdle({
237
249
  // The row is one more of the runtime's tables: the secrets' pool, on the same database.
238
250
  db: () => secrets.db(),
239
- wakeup: wakeup.binding ?? wakeup.id,
240
- // `resolveConfig` checked the operation is the config's (`checkWakeupOps`).
241
- register: { op: resolved.ops.get(registerId)!, fn: resolved.drivers.get(registerId) },
242
- secrets: state.secrets,
251
+ register:
252
+ "type" in wakeup
253
+ ? declaredRegister({
254
+ // `resolveConfig` checked the operation is the config's (`checkWakeupOps`).
255
+ op: resolved.ops.get(wakeup.type.wakeup!.register)!,
256
+ fn: resolved.drivers.get(wakeup.type.wakeup!.register),
257
+ secrets: state.secrets,
258
+ wakeup: wakeup.binding ?? wakeup.id,
259
+ })
260
+ : envRegister(wakeup),
243
261
  deadlines: state.deadlines,
244
262
  idle: () => worker.idle(),
245
263
  everyMs: options.stopCheckMs ?? 15_000,