@sanoma-ai/workflows 0.10.2 → 0.10.3

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 (118) hide show
  1. package/README.md +43 -11
  2. package/dist/apply.js +1 -1
  3. package/dist/apply.js.map +1 -1
  4. package/dist/call.d.ts +5 -2
  5. package/dist/call.js +65 -20
  6. package/dist/call.js.map +1 -1
  7. package/dist/change.d.ts +5 -6
  8. package/dist/change.js +6 -9
  9. package/dist/change.js.map +1 -1
  10. package/dist/client.d.ts +18 -0
  11. package/dist/client.js +35 -3
  12. package/dist/client.js.map +1 -1
  13. package/dist/config.d.ts +42 -4
  14. package/dist/config.js +71 -10
  15. package/dist/config.js.map +1 -1
  16. package/dist/db.d.ts +2 -0
  17. package/dist/db.js +7 -0
  18. package/dist/db.js.map +1 -1
  19. package/dist/deadlines.d.ts +26 -13
  20. package/dist/deadlines.js +50 -11
  21. package/dist/deadlines.js.map +1 -1
  22. package/dist/define.d.ts +22 -0
  23. package/dist/define.js +2 -0
  24. package/dist/define.js.map +1 -1
  25. package/dist/deployment-key.d.ts +20 -1
  26. package/dist/deployment-key.js +28 -3
  27. package/dist/deployment-key.js.map +1 -1
  28. package/dist/describe.js +28 -1
  29. package/dist/describe.js.map +1 -1
  30. package/dist/description.d.ts +3 -0
  31. package/dist/description.js +2 -0
  32. package/dist/description.js.map +1 -1
  33. package/dist/host.d.ts +13 -4
  34. package/dist/host.js +14 -5
  35. package/dist/host.js.map +1 -1
  36. package/dist/index.d.ts +10 -6
  37. package/dist/index.js +8 -5
  38. package/dist/index.js.map +1 -1
  39. package/dist/ledger.d.ts +30 -1
  40. package/dist/ledger.js +31 -0
  41. package/dist/ledger.js.map +1 -1
  42. package/dist/login-code.d.ts +17 -0
  43. package/dist/login-code.js +32 -0
  44. package/dist/login-code.js.map +1 -0
  45. package/dist/op.d.ts +78 -10
  46. package/dist/op.js +20 -0
  47. package/dist/op.js.map +1 -1
  48. package/dist/outline.js +3 -3
  49. package/dist/outline.js.map +1 -1
  50. package/dist/resource.d.ts +6 -4
  51. package/dist/resource.js +8 -5
  52. package/dist/resource.js.map +1 -1
  53. package/dist/resources.d.ts +4 -2
  54. package/dist/resources.js +15 -6
  55. package/dist/resources.js.map +1 -1
  56. package/dist/run.d.ts +3 -0
  57. package/dist/run.js.map +1 -1
  58. package/dist/runtime-ops.d.ts +123 -0
  59. package/dist/runtime-ops.js +222 -0
  60. package/dist/runtime-ops.js.map +1 -0
  61. package/dist/seal.d.ts +27 -0
  62. package/dist/seal.js +76 -0
  63. package/dist/seal.js.map +1 -0
  64. package/dist/secret-store.d.ts +6 -4
  65. package/dist/secret-store.js +11 -6
  66. package/dist/secret-store.js.map +1 -1
  67. package/dist/secret.d.ts +7 -0
  68. package/dist/secret.js +13 -1
  69. package/dist/secret.js.map +1 -1
  70. package/dist/shared.d.ts +15 -4
  71. package/dist/shared.js +27 -7
  72. package/dist/shared.js.map +1 -1
  73. package/dist/ssh-key.d.ts +11 -0
  74. package/dist/ssh-key.js +44 -0
  75. package/dist/ssh-key.js.map +1 -0
  76. package/dist/tokens.d.ts +35 -0
  77. package/dist/tokens.js +71 -0
  78. package/dist/tokens.js.map +1 -0
  79. package/dist/wakeup-service.js +2 -7
  80. package/dist/wakeup-service.js.map +1 -1
  81. package/dist/wakeup.d.ts +18 -0
  82. package/dist/wakeup.js +15 -0
  83. package/dist/wakeup.js.map +1 -1
  84. package/dist/worker.d.ts +2 -2
  85. package/dist/worker.js +17 -9
  86. package/dist/worker.js.map +1 -1
  87. package/oxlint.json +1 -1
  88. package/package.json +1 -1
  89. package/src/apply.ts +1 -1
  90. package/src/call.ts +88 -18
  91. package/src/change.ts +9 -20
  92. package/src/client.ts +48 -7
  93. package/src/config.ts +111 -17
  94. package/src/db.ts +8 -1
  95. package/src/deadlines.ts +66 -20
  96. package/src/define.ts +25 -0
  97. package/src/deployment-key.ts +34 -8
  98. package/src/describe.ts +39 -2
  99. package/src/description.ts +2 -0
  100. package/src/host.ts +22 -6
  101. package/src/index.ts +18 -1
  102. package/src/ledger.ts +52 -1
  103. package/src/login-code.ts +52 -0
  104. package/src/op.ts +84 -11
  105. package/src/outline.ts +3 -3
  106. package/src/resource.ts +8 -5
  107. package/src/resources.ts +13 -4
  108. package/src/run.ts +3 -0
  109. package/src/runtime-ops.ts +234 -0
  110. package/src/seal.ts +95 -0
  111. package/src/secret-store.ts +12 -9
  112. package/src/secret.ts +16 -1
  113. package/src/shared.ts +31 -7
  114. package/src/ssh-key.ts +48 -0
  115. package/src/tokens.ts +100 -0
  116. package/src/wakeup-service.ts +2 -8
  117. package/src/wakeup.ts +21 -0
  118. package/src/worker.ts +29 -11
@@ -0,0 +1,234 @@
1
+ import { createHash } from "node:crypto";
2
+ import { z } from "zod";
3
+ import { keyBytes } from "./deployment-key.ts";
4
+ import { type CallContext, defineConnector, defineDriver, DriverError, retryableStatus } from "./op.ts";
5
+ import { generateSecret, SECRET_NAME } from "./secret.ts";
6
+ import { sealInvite } from "./seal.ts";
7
+ import { errorMessage } from "./shared.ts";
8
+ import { sourceKeyPair, sshPublicKey } from "./ssh-key.ts";
9
+ import { memoryTokens, type Tokens } from "./tokens.ts";
10
+
11
+ // What a workflow cannot do itself and the runtime does for it, as operations of the vendor
12
+ // `sanoma` (`sanoma.secret.*`, `sanoma.invite.mint`, `sanoma.token.*`): named in `uses`, decided by
13
+ // the policy, recorded in the ledger, and never answering a secret's value. `resolveConfig`
14
+ // registers them in every config with their driver (`runtimeDriver`), so a policy may name them;
15
+ // a sandbox run calls `sandboxRuntimeDriver`, over its own secrets and tokens, posting nothing.
16
+
17
+ /** A secret's name, as the store takes it (upper case), other than the runtime's own (`SANOMA_…`: its keys and credentials). */
18
+ const SecretName = z
19
+ .string()
20
+ .regex(SECRET_NAME, "a secret's name: upper case, A-Z, 0-9 and _")
21
+ .refine((name) => !name.startsWith("SANOMA_"), "a SANOMA_ name is the runtime's own, not a workflow's to use");
22
+
23
+ /** How long an instance gets to answer `POST /auth/invite`. */
24
+ const INVITE_TIMEOUT_MS = 10_000;
25
+
26
+ /**
27
+ * The runtime's own operations a workflow calls: `import { sanoma } from "sanoma"`, then
28
+ * `uses: [sanoma.secret.generate]` and `ctx.sanoma.secret.generate({ name, kind })`.
29
+ */
30
+ export const sanoma = defineConnector("sanoma", {
31
+ secret: {
32
+ generate: {
33
+ effect: "write",
34
+ // A value stored is kept: a retry answers what the first try stored.
35
+ idempotent: true,
36
+ description:
37
+ "Make a secret and store it under a name: a random value, or an RSA key pair whose private half (PEM) is stored and whose public half (OpenSSH) is answered. A name stored already is kept",
38
+ input: z.object({
39
+ name: SecretName,
40
+ kind: z.enum(["random", "ssh-rsa"]).describe("random: 32 random bytes; ssh-rsa: an RSA deploy key"),
41
+ }),
42
+ output: z.object({
43
+ name: z.string(),
44
+ publicKey: z.string().optional().describe("For ssh-rsa: the public half, `ssh-rsa <base64>`"),
45
+ }),
46
+ target: ({ name }: { name: string }) => name,
47
+ createsSecrets: ({ name }: { name: string }) => [name],
48
+ },
49
+ delete: {
50
+ effect: "write",
51
+ idempotent: true,
52
+ description: "Delete a stored secret; one not stored is nothing to do",
53
+ input: z.object({ name: SecretName }),
54
+ output: z.object({}),
55
+ target: ({ name }: { name: string }) => name,
56
+ createsSecrets: ({ name }: { name: string }) => [name],
57
+ },
58
+ },
59
+ invite: {
60
+ mint: {
61
+ effect: "access",
62
+ // Each try seals a new request (good for `sealInvite`'s few minutes), and the instance's newest
63
+ // invite for an email replaces the last.
64
+ idempotent: true,
65
+ description:
66
+ "Ask an instance for an invite link that signs a declared person in: a request sealed under the instance's signing key, named by a secret marker, posted to its /auth/invite",
67
+ input: z.object({
68
+ host: z.url({ protocol: /^https?$/ }).describe("The instance's address, such as https://acme.sanoma.work"),
69
+ email: z.email().describe("The email the instance's people file declares"),
70
+ key: z
71
+ .strictObject({ $secret: SecretName })
72
+ .describe('The instance\'s SANOMA_SIGNING_KEY, as stored here: secret("NAME")'),
73
+ }),
74
+ output: z.object({ url: z.string().describe("The one-use invite link the instance minted") }),
75
+ target: ({ host }: { host: string }) => host,
76
+ // A link that signs someone in: the ledger records that there was one, not what it was.
77
+ opaque: ["url"],
78
+ },
79
+ },
80
+ token: {
81
+ mint: {
82
+ effect: "access",
83
+ description:
84
+ "Mint an api token acting for a subject in groups, store it under a name, and answer its id (never the token)",
85
+ input: z.object({
86
+ subject: z.string().regex(/^\S+$/, "a principal's id, such as visitor:better-auth:<id>"),
87
+ groups: z.array(z.string().min(1)),
88
+ store: SecretName.describe("The secret the token is stored under"),
89
+ }),
90
+ output: z.object({ id: z.string() }),
91
+ target: ({ subject }: { subject: string }) => subject,
92
+ createsSecrets: ({ store }: { store: string }) => [store],
93
+ },
94
+ revoke: {
95
+ effect: "access",
96
+ idempotent: true,
97
+ description: "Revoke an api token: it resolves to nobody from now on",
98
+ input: z.object({ id: z.string().min(1) }),
99
+ output: z.object({}),
100
+ target: ({ id }: { id: string }) => `token:${id}`,
101
+ },
102
+ },
103
+ });
104
+
105
+ /** A `DriverError` that a retry would not change. */
106
+ const refuse = (message: string) => new DriverError(`sanoma: ${message}`, { retryable: false });
107
+
108
+ /** The token id a call mints, from its idempotency key: the same on every try of one call, so a retry finds its own. */
109
+ function tokenIdOf(call: CallContext) {
110
+ const hex = createHash("sha256").update(call.idempotencyKey).digest("hex");
111
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`;
112
+ }
113
+
114
+ /** The 32 bytes of a signing key stored as text (base64, or base64url as `generate` makes one), or a refusal. */
115
+ function signingKeyOf(secret: string, text: string | undefined): Buffer {
116
+ if (text === undefined) throw refuse(`the secret ${secret} is not set: store the instance's signing key under it`);
117
+ const key = keyBytes(text);
118
+ if (!key) throw refuse(`${secret} is not a signing key: 32 bytes in base64`);
119
+ return key;
120
+ }
121
+
122
+ /**
123
+ * Posts the sealed `request` to the instance's `/auth/invite`, and answers the link it minted
124
+ * (`sanoma.invite.mint`, and going live's sign-in); a `DriverError` otherwise, retryable by status.
125
+ */
126
+ export async function postInvite(host: string, request: string): Promise<{ url: string }> {
127
+ const at = new URL("/auth/invite", host);
128
+ let res: Response;
129
+ try {
130
+ res = await fetch(at, {
131
+ method: "POST",
132
+ headers: { "content-type": "application/json" },
133
+ body: JSON.stringify({ request }),
134
+ // A redirect is answered as it is (a 3xx, not retried): the sealed request goes to `host` alone.
135
+ redirect: "manual",
136
+ signal: AbortSignal.timeout(INVITE_TIMEOUT_MS),
137
+ });
138
+ } catch (err) {
139
+ throw new DriverError(`sanoma: ${at.host} could not be reached: ${errorMessage(err)}`, {
140
+ retryable: true,
141
+ cause: err,
142
+ });
143
+ }
144
+ const body = (await res.json().catch(() => undefined)) as { url?: unknown; error?: unknown } | undefined;
145
+ if (res.ok && typeof body?.url === "string") return { url: body.url };
146
+ const why = typeof body?.error === "string" ? `: ${body.error}` : res.ok ? ": no url" : "";
147
+ throw new DriverError(`sanoma: ${at.host} answered ${res.status} to POST /auth/invite${why}`, {
148
+ retryable: !res.ok && retryableStatus(res.status),
149
+ status: res.status,
150
+ });
151
+ }
152
+
153
+ /**
154
+ * The driver of the runtime's own operations, over the call's secrets and `tokens` (the config's
155
+ * database's, `tokenStore`); `invite` posts a sealed request to an instance (`postInvite` by default).
156
+ */
157
+ export function runtimeDriver({
158
+ tokens,
159
+ invite = postInvite,
160
+ }: {
161
+ tokens: Tokens;
162
+ invite?: (host: string, request: string, call: CallContext) => Promise<{ url: string }>;
163
+ }) {
164
+ return defineDriver(sanoma, {
165
+ secret: {
166
+ async generate({ name, kind }, call) {
167
+ if (kind === "random") {
168
+ await call.secrets.ensure(name, generateSecret);
169
+ return { name };
170
+ }
171
+ // A pair made only when none is stored: the public half is always derived from what is.
172
+ let stored = await call.secrets.get(name);
173
+ if (stored === undefined) {
174
+ const pair = await sourceKeyPair();
175
+ stored = await call.secrets.ensure(name, () => pair.privateKey);
176
+ }
177
+ const publicKey = sshPublicKey(stored);
178
+ if (!publicKey)
179
+ throw refuse(`${name} holds a value that is not an RSA private key: delete it, or name another`);
180
+ return { name, publicKey };
181
+ },
182
+ async delete({ name }, call) {
183
+ await call.secrets.delete(name);
184
+ return {};
185
+ },
186
+ },
187
+ invite: {
188
+ async mint({ host, email, key }, call) {
189
+ const signingKey = signingKeyOf(key.$secret, await call.secrets.get(key.$secret));
190
+ const request = sealInvite({ email }, signingKey);
191
+ return invite(host, request, call);
192
+ },
193
+ },
194
+ token: {
195
+ async mint({ subject, groups, store }, call) {
196
+ const id = tokenIdOf(call);
197
+ const held = await call.secrets.get(store);
198
+ if (held !== undefined) {
199
+ // This call's own, stored by a try whose answer was lost.
200
+ if ((await tokens.resolve(held))?.principal.id === `token:${id}`) return { id };
201
+ throw refuse(`${store} holds a value already: delete it, or store the token under another name`);
202
+ }
203
+ let token: string;
204
+ try {
205
+ ({ token } = await tokens.mint({ id, subject, groups }));
206
+ } catch (err) {
207
+ // A try that minted this id and stopped before storing it left a token nobody holds: revoked here.
208
+ if ((await tokens.subjectOf(id)) === undefined) throw err;
209
+ await tokens.revoke({ id });
210
+ throw refuse(`an earlier try minted token ${id} and stopped before storing it: it is revoked, start again`);
211
+ }
212
+ if ((await call.secrets.ensure(store, () => token)) !== token) {
213
+ await tokens.revoke({ id });
214
+ throw refuse(`${store} was stored by someone else meanwhile: the token minted is revoked`);
215
+ }
216
+ return { id };
217
+ },
218
+ async revoke({ id }) {
219
+ await tokens.revoke({ id });
220
+ return {};
221
+ },
222
+ },
223
+ });
224
+ }
225
+
226
+ /**
227
+ * The runtime's own operations for one sandbox run: the run's secrets (in memory), tokens in
228
+ * memory, and an invite that posts nothing, answering a link on the host named.
229
+ */
230
+ export const sandboxRuntimeDriver = () =>
231
+ runtimeDriver({
232
+ tokens: memoryTokens(),
233
+ invite: async (host, _request, call) => ({ url: new URL(`/auth/invite/sandbox-${tokenIdOf(call)}`, host).href }),
234
+ });
package/src/seal.ts ADDED
@@ -0,0 +1,95 @@
1
+ import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
2
+ import { deriveSigningKey, signingKeyFor } from "./deployment-key.ts";
3
+
4
+ // Small values sealed under a key of their own, derived from the deployment's SANOMA_SIGNING_KEY
5
+ // for one purpose (HKDF-SHA256): a sign-in code in a `login-code` run's input, and a request for
6
+ // an invite that a holder of the key sends to the instance (`POST /auth/invite`). AES-256-GCM, the
7
+ // purpose as its additional data, so a value sealed for one purpose never opens for another.
8
+
9
+ const IV = 12;
10
+ const TAG = 16;
11
+
12
+ /** The key for `purpose`: derived from `signingKey`, a deployment's signing key, else from this process's own. */
13
+ const keyFor = (purpose: string, signingKey?: Buffer) =>
14
+ signingKey ? deriveSigningKey(signingKey, purpose) : signingKeyFor(purpose);
15
+
16
+ /**
17
+ * `value` as JSON, sealed for `purpose` under `signingKey` (this process's own by default): base64url
18
+ * of the IV, the tag and the ciphertext.
19
+ */
20
+ function seal(purpose: string, value: unknown, signingKey?: Buffer): string {
21
+ const iv = randomBytes(IV);
22
+ const cipher = createCipheriv("aes-256-gcm", keyFor(purpose, signingKey), iv).setAAD(Buffer.from(purpose));
23
+ const text = Buffer.concat([cipher.update(JSON.stringify(value), "utf8"), cipher.final()]);
24
+ return Buffer.concat([iv, cipher.getAuthTag(), text]).toString("base64url");
25
+ }
26
+
27
+ /** What `seal(purpose, …)` sealed, or undefined for anything else: another key, another purpose, a change. */
28
+ function unseal(purpose: string, sealed: string): unknown {
29
+ const bytes = Buffer.from(sealed, "base64url");
30
+ if (bytes.length <= IV + TAG) return undefined;
31
+ try {
32
+ const decipher = createDecipheriv("aes-256-gcm", keyFor(purpose), bytes.subarray(0, IV))
33
+ .setAAD(Buffer.from(purpose))
34
+ .setAuthTag(bytes.subarray(IV, IV + TAG));
35
+ const text = Buffer.concat([decipher.update(bytes.subarray(IV + TAG)), decipher.final()]);
36
+ return JSON.parse(text.toString("utf8"));
37
+ } catch {
38
+ return undefined;
39
+ }
40
+ }
41
+
42
+ const CODE = "login code";
43
+ const INVITE = "invite request";
44
+
45
+ /**
46
+ * A sign-in code sealed with the email it is for, for a `login-code` run's input, so the run's
47
+ * input never shows it and a copy of it opens for no one else.
48
+ */
49
+ export const sealLoginCode = (to: string, code: string): string => seal(CODE, { to, code });
50
+
51
+ /** The code `sealLoginCode` sealed for `to`; throws for anything else, a code sealed for another email among it. */
52
+ export function openLoginCode(sealed: string, to: string): string {
53
+ const opened = unseal(CODE, sealed) as { to?: unknown; code?: unknown } | undefined;
54
+ if (typeof opened?.code !== "string" || opened.to !== to) {
55
+ throw new Error(`The sign-in code is not one this deployment sealed for ${to}`);
56
+ }
57
+ return opened.code;
58
+ }
59
+
60
+ /** A request for an invite (`POST /auth/invite { request }`): for whom, until when (ms since the epoch), and a nonce. */
61
+ export interface InviteRequest {
62
+ email: string;
63
+ exp: number;
64
+ nonce: string;
65
+ }
66
+
67
+ /** How long a sealed invite request is good for when `exp` is not given: five minutes. */
68
+ const INVITE_REQUEST_MS = 5 * 60_000;
69
+
70
+ /**
71
+ * A request for `email`'s invite, sealed under a key derived from `SANOMA_SIGNING_KEY` (purpose
72
+ * `invite request`), or from `signingKey`, another deployment's signing key (its 32 bytes): what
73
+ * a holder of the deployment's signing key sends the instance as `POST /auth/invite { request }`
74
+ * (going live, and Sanoma Cloud's birth invite, `sanoma.invite.mint`). `exp` defaults to five
75
+ * minutes from now, `nonce` to 16 random bytes.
76
+ */
77
+ export function sealInvite(
78
+ { email, exp, nonce }: { email: string; exp?: number; nonce?: string },
79
+ signingKey?: Buffer,
80
+ ): string {
81
+ const request = {
82
+ email,
83
+ exp: exp ?? Date.now() + INVITE_REQUEST_MS,
84
+ nonce: nonce ?? randomBytes(16).toString("base64url"),
85
+ } satisfies InviteRequest;
86
+ return seal(INVITE, request, signingKey);
87
+ }
88
+
89
+ /** The request `sealInvite` sealed, while `exp` is after `now`; undefined for anything else, or once it expired. */
90
+ export function openInvite(sealed: string, now = Date.now()): InviteRequest | undefined {
91
+ const request = unseal(INVITE, sealed) as Partial<InviteRequest> | undefined;
92
+ if (typeof request?.email !== "string" || typeof request.exp !== "number" || typeof request.nonce !== "string")
93
+ return undefined;
94
+ return request.exp > now ? (request as InviteRequest) : undefined;
95
+ }
@@ -1,4 +1,4 @@
1
- import { deploymentKey } from "./deployment-key.ts";
1
+ import { DEPLOYMENT_KEYS, type DeploymentKeyName, deploymentKey } from "./deployment-key.ts";
2
2
  /** Opaque values belong only in trusted runtime code, never workflow history or descriptions. */
3
3
  export interface SecretValue {
4
4
  value: string;
@@ -65,7 +65,7 @@ export interface SecretStore {
65
65
  export type SecretBinding =
66
66
  | { path: string; management: "sanoma" | "external"; env?: never; bootstrap?: never }
67
67
  | { env: string; management?: "external"; path?: never; bootstrap?: never }
68
- | { bootstrap: "SANOMA_SIGNING_KEY" | "SANOMA_SECRETS_KEY"; management?: "external"; env?: never; path?: never };
68
+ | { bootstrap: DeploymentKeyName; management?: "external"; env?: never; path?: never };
69
69
 
70
70
  export interface SecretConfiguration {
71
71
  /** Omitted: built-in encrypted Postgres, supplied by the runtime. */
@@ -145,10 +145,10 @@ export function checkSecretConfiguration(config: SecretConfiguration): void {
145
145
  invalid();
146
146
  for (const [name, b] of Object.entries(config.bindings ?? {})) {
147
147
  if (!namePattern.test(name) || !b || typeof b !== "object") invalid();
148
- if (["SANOMA_SIGNING_KEY", "SANOMA_SECRETS_KEY"].includes(name) && !("env" in b) && !("bootstrap" in b)) invalid();
148
+ if (DEPLOYMENT_KEYS.includes(name) && !("env" in b) && !("bootstrap" in b)) invalid();
149
149
  if ("bootstrap" in b) {
150
150
  if (
151
- !["SANOMA_SIGNING_KEY", "SANOMA_SECRETS_KEY"].includes(b.bootstrap!) ||
151
+ !DEPLOYMENT_KEYS.includes(b.bootstrap!) ||
152
152
  b.path !== undefined ||
153
153
  b.env !== undefined ||
154
154
  (b.management !== undefined && b.management !== "external")
@@ -194,8 +194,7 @@ export class SecretAccess {
194
194
  const binding = Object.hasOwn(this.bindings, name) ? this.bindings[name] : undefined;
195
195
  if (binding?.bootstrap !== undefined)
196
196
  return { store: "bootstrap", namespace: "", path: binding.bootstrap, management: "external" };
197
- if (["SANOMA_SIGNING_KEY", "SANOMA_SECRETS_KEY"].includes(name) && !binding)
198
- throw new SecretStoreError("invalid_configuration");
197
+ if (DEPLOYMENT_KEYS.includes(name) && !binding) throw new SecretStoreError("invalid_configuration");
199
198
  if (binding?.env !== undefined)
200
199
  return { store: "environment", namespace: "", path: binding.env, management: "external" };
201
200
  return {
@@ -213,7 +212,7 @@ export class SecretAccess {
213
212
  async read(name: string): Promise<SecretValue | undefined> {
214
213
  const location = this.location(name);
215
214
  if (location.store === "bootstrap" && location.namespace === "") {
216
- const value = deploymentKey(location.path as "SANOMA_SIGNING_KEY" | "SANOMA_SECRETS_KEY").toString("base64");
215
+ const value = deploymentKey(location.path as DeploymentKeyName).toString("base64");
217
216
  return { value, version: "bootstrap" };
218
217
  }
219
218
  if (location.store === "environment" && location.namespace === "") {
@@ -238,10 +237,14 @@ export class SecretAccess {
238
237
  return secretStoreCall(() => this.store.write(location, value, options));
239
238
  }
240
239
 
241
- async delete(name: string, options: { expectedVersion: string; by: string }): Promise<void> {
240
+ /** Deletes the value at `expectedVersion`; without one, the value stored now, and none stored is nothing to do. */
241
+ async delete(name: string, options: { expectedVersion?: string; by?: string } = {}): Promise<void> {
242
242
  const location = this.writable(name);
243
243
  if (this.store.capabilities.deletion === "unsupported") throw new SecretStoreError("unsupported");
244
- await secretStoreCall(() => this.store.delete(location, options));
244
+ const expectedVersion = options.expectedVersion ?? (await this.read(name))?.version;
245
+ if (expectedVersion === undefined) return;
246
+ const by = options.by ?? "sanoma";
247
+ await secretStoreCall(() => this.store.delete(location, { expectedVersion, by }));
245
248
  }
246
249
 
247
250
  async ensure(name: string, make: () => string): Promise<string> {
package/src/secret.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { randomBytes } from "node:crypto";
1
+ import { createHash, randomBytes } from "node:crypto";
2
2
  import { DriverError } from "./op.ts";
3
3
 
4
4
  // Secrets are named, never written: `secret("NAME")` in a data file is a marker for a value in
@@ -44,6 +44,8 @@ export interface Secrets {
44
44
  get(name: string): Promise<string | undefined>;
45
45
  /** The stored value; when there is none, `make()`'s, stored first. */
46
46
  ensure(name: string, make: () => string): Promise<string>;
47
+ /** Deletes the stored value; none stored is nothing to do. The runtime's own `sanoma.secret.delete` calls it. */
48
+ delete(name: string): Promise<void>;
47
49
  }
48
50
 
49
51
  /** A named call can resolve only the auxiliary secret markers present in its authorized input. */
@@ -72,12 +74,22 @@ export function secretsForInput(input: unknown, secrets: Secrets, creates: reado
72
74
  check(name, true);
73
75
  return secrets.ensure(name, make);
74
76
  },
77
+ async delete(name) {
78
+ check(name, true);
79
+ return secrets.delete(name);
80
+ },
75
81
  };
76
82
  }
77
83
 
78
84
  /** A value for a secret with `generate`: 32 random bytes, base64url. */
79
85
  export const generateSecret = (): string => randomBytes(32).toString("base64url");
80
86
 
87
+ /**
88
+ * A bearer token's sha256, hex: all a table keeps of one (the wakeup service's, the api tokens). A
89
+ * token is found by its hash (a unique index), so the secret itself is never compared.
90
+ */
91
+ export const tokenHash = (token: string): string => createHash("sha256").update(token).digest("hex");
92
+
81
93
  /** `T` with each secret marker's place holding its value: what `resolveSecrets` returns. */
82
94
  export type Resolved<T> = T extends Secret
83
95
  ? string
@@ -117,5 +129,8 @@ export function memorySecrets(initial: Record<string, string> = {}): Secrets {
117
129
  if (!values.has(name)) values.set(name, make());
118
130
  return values.get(name)!;
119
131
  },
132
+ async delete(name) {
133
+ values.delete(name);
134
+ },
120
135
  };
121
136
  }
package/src/shared.ts CHANGED
@@ -285,12 +285,20 @@ export const CHANGE_TOPIC = "change:event";
285
285
  /** Lists the apps on GitHub with Sanoma's claims: the one runtime operation a page may call (`isDirect`). */
286
286
  export const APPS_LIST = "sanoma.apps.list";
287
287
 
288
- /** The workflows the runtime adds itself, which a config's own may not be named: drift, apply, call and change. */
288
+ /**
289
+ * The built-in workflow that sends a staff or visitor sign-in code through the config's
290
+ * `login.sender`, started by the runtime's agent `login` (`LOGIN_AGENT`): its input's code is
291
+ * sealed, and the mail's body is recorded as `<text>` and `<html>`.
292
+ */
293
+ export const LOGIN_CODE_WORKFLOW = "login-code";
294
+
295
+ /** The workflows the runtime adds itself, which a config's own may not be named: drift, apply, call, change and login-code. */
289
296
  export const BUILT_IN_WORKFLOWS: ReadonlySet<string> = new Set([
290
297
  DRIFT_WORKFLOW,
291
298
  APPLY_WORKFLOW,
292
299
  CALL_WORKFLOW,
293
300
  CHANGE_WORKFLOW,
301
+ LOGIN_CODE_WORKFLOW,
294
302
  ]);
295
303
 
296
304
  /**
@@ -361,14 +369,22 @@ export const VISITORS = "visitors";
361
369
  /** The principal a public page runs as when nobody is signed in: no groups. */
362
370
  export const ANONYMOUS = "anonymous";
363
371
 
364
- /** `visitor:<login connector's vendor>:<provider subject>`: the only form a visitor's id takes. */
372
+ /** `visitor:<login connector's vendor>:<provider subject>`: the form a signed-in visitor's id takes. */
365
373
  const VISITOR_ID = /^visitor:[^\s:]+:\S+$/;
366
374
 
375
+ /** `token:<id>`: a caller of a public site's api with a token (`SanomaClient.tokens`), a visitor too. */
376
+ const TOKEN_ID = /^token:\S+$/;
377
+
367
378
  /**
368
- * True for `anonymous` and for a visitor's id: a principal no data file declares and the staff
369
- * logins never admit. Marked by the id alone, since a principal crosses to the worker as JSON.
379
+ * True for `anonymous`, a visitor's id and a token's (`token:<id>`): a principal no data file
380
+ * declares and the staff logins never admit. Marked by the id alone, since a principal crosses to
381
+ * the worker as JSON.
370
382
  */
371
- export const isVisitor = (p: Pick<Principal, "id">): boolean => p.id === ANONYMOUS || VISITOR_ID.test(p.id);
383
+ export const isVisitor = (p: Pick<Principal, "id">): boolean =>
384
+ p.id === ANONYMOUS || VISITOR_ID.test(p.id) || isToken(p);
385
+
386
+ /** True for an api token's principal (`token:<id>`), whose groups are its row's. */
387
+ export const isToken = (p: Pick<Principal, "id">): boolean => TOKEN_ID.test(p.id);
372
388
 
373
389
  /**
374
390
  * The visitor a public site's login vouches for: `subject` from the provider behind the `login`
@@ -389,8 +405,16 @@ export const WAKEUP_AGENT = "wakeup";
389
405
  export const SCHEDULE_AGENT = "schedule";
390
406
  /** The runtime's agent that checks and deploys the hosts: drift's, and apply's after a push to `main` (`SanomaClient.remoteEvent`). */
391
407
  export const HOST_CHECK_AGENT = "host-check";
392
- /** The runtime's own agents (scenarios, the host check, scheduled runs, the wakeup's registration), declared whether a data file names them or not. */
393
- export const RUNTIME_AGENTS: readonly string[] = ["scenarios", HOST_CHECK_AGENT, SCHEDULE_AGENT, WAKEUP_AGENT];
408
+ /** The runtime's agent that sends a sign-in code (`LOGIN_CODE_WORKFLOW`): a company's policy exempts it from its holds on sending. */
409
+ export const LOGIN_AGENT = "login";
410
+ /** The runtime's own agents (scenarios, the host check, scheduled runs, the wakeup's registration, sign-in codes), declared whether a data file names them or not. */
411
+ export const RUNTIME_AGENTS: readonly string[] = [
412
+ "scenarios",
413
+ HOST_CHECK_AGENT,
414
+ SCHEDULE_AGENT,
415
+ WAKEUP_AGENT,
416
+ LOGIN_AGENT,
417
+ ];
394
418
 
395
419
  /** The declared resources of one of the runtime's people types, by `desired`. */
396
420
  const peopleOfType = <T>(resources: readonly PeopleResource[], type: "person" | "agent" | "group") =>
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
+ }