@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
@@ -0,0 +1,313 @@
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
+ import {
11
+ identityProvisioningCode,
12
+ memoryIdentityProvisioning,
13
+ type IdentityProvisioning,
14
+ } from "./identity-provisioning.ts";
15
+
16
+ // What a workflow cannot do itself and the runtime does for it, as operations of the vendor
17
+ // `sanoma` (`sanoma.secret.*`, `sanoma.invite.mint`, `sanoma.token.*`): named in `uses`, decided by
18
+ // the policy, recorded in the ledger, and never answering a secret's value. `resolveConfig`
19
+ // registers them in every config with their driver (`runtimeDriver`), so a policy may name them;
20
+ // a sandbox run calls `sandboxRuntimeDriver`, over its own secrets and tokens, posting nothing.
21
+
22
+ /** A secret's name, as the store takes it (upper case), other than the runtime's own (`SANOMA_…`: its keys and credentials). */
23
+ const SecretName = z
24
+ .string()
25
+ .regex(SECRET_NAME, "a secret's name: upper case, A-Z, 0-9 and _")
26
+ .refine((name) => !name.startsWith("SANOMA_"), "a SANOMA_ name is the runtime's own, not a workflow's to use");
27
+
28
+ /** How long an instance gets to answer `POST /auth/invite`. */
29
+ const INVITE_TIMEOUT_MS = 10_000;
30
+
31
+ const DeploymentIdentity = z.object({
32
+ deployment: z.string().min(1).max(256),
33
+ company: z.string().min(1).max(256),
34
+ environment: z.string().min(1).max(256),
35
+ serviceAccount: z.string().regex(/^[a-z][a-z0-9-]+@[a-z][a-z0-9-]+\.iam\.gserviceaccount\.com$/),
36
+ });
37
+ const Generation = z.number().int().positive().max(Number.MAX_SAFE_INTEGER);
38
+ const IdentityTarget = ({ deployment }: { deployment: string }) => deployment;
39
+
40
+ // Enrollment implementations can see credential hashes and database credentials. Neither their
41
+ // errors nor causes may be captured in durable driver history, including on an uncertain commit.
42
+ async function identityWrite(write: () => Promise<void>): Promise<void> {
43
+ try {
44
+ await write();
45
+ } catch (error) {
46
+ const code = identityProvisioningCode(error) ?? "unavailable";
47
+ throw new DriverError(`Identity provisioning: ${code}`, { retryable: code === "unavailable" });
48
+ }
49
+ }
50
+
51
+ /**
52
+ * The runtime's own operations a workflow calls: `import { sanoma } from "sanoma"`, then
53
+ * `uses: [sanoma.secret.generate]` and `ctx.sanoma.secret.generate({ name, kind })`.
54
+ */
55
+ export const sanoma = defineConnector("sanoma", {
56
+ identity: {
57
+ enroll: {
58
+ effect: "access",
59
+ idempotent: true,
60
+ description:
61
+ "Enroll a deployment identity, keeping its bootstrap credential in the secret store. A higher generation rotates it; mappings are immutable",
62
+ input: DeploymentIdentity.extend({ generation: Generation, validUntil: z.iso.datetime(), store: SecretName }),
63
+ output: z.object({ deployment: z.string(), generation: Generation, store: z.string() }),
64
+ target: IdentityTarget,
65
+ createsSecrets: ({ store }: { store: string }) => [store],
66
+ },
67
+ retire: {
68
+ effect: "access",
69
+ idempotent: true,
70
+ description:
71
+ "Shorten an old identity generation's validity after delivering its replacement; never extend access",
72
+ input: DeploymentIdentity.extend({ generation: Generation, validUntil: z.iso.datetime() }),
73
+ output: z.object({}),
74
+ target: IdentityTarget,
75
+ },
76
+ revoke: {
77
+ effect: "access",
78
+ idempotent: true,
79
+ description: "Disable all token renewal for a deployment. This deployment id cannot be re-enrolled",
80
+ input: DeploymentIdentity,
81
+ output: z.object({}),
82
+ target: IdentityTarget,
83
+ },
84
+ },
85
+ secret: {
86
+ generate: {
87
+ effect: "write",
88
+ // A value stored is kept: a retry answers what the first try stored.
89
+ idempotent: true,
90
+ description:
91
+ "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",
92
+ input: z.object({
93
+ name: SecretName,
94
+ kind: z.enum(["random", "ssh-rsa"]).describe("random: 32 random bytes; ssh-rsa: an RSA deploy key"),
95
+ }),
96
+ output: z.object({
97
+ name: z.string(),
98
+ publicKey: z.string().optional().describe("For ssh-rsa: the public half, `ssh-rsa <base64>`"),
99
+ }),
100
+ target: ({ name }: { name: string }) => name,
101
+ createsSecrets: ({ name }: { name: string }) => [name],
102
+ },
103
+ delete: {
104
+ effect: "write",
105
+ idempotent: true,
106
+ description: "Delete a stored secret; one not stored is nothing to do",
107
+ input: z.object({ name: SecretName }),
108
+ output: z.object({}),
109
+ target: ({ name }: { name: string }) => name,
110
+ createsSecrets: ({ name }: { name: string }) => [name],
111
+ },
112
+ },
113
+ invite: {
114
+ mint: {
115
+ effect: "access",
116
+ // Each try seals a new request (good for `sealInvite`'s few minutes), and the instance's newest
117
+ // invite for an email replaces the last.
118
+ idempotent: true,
119
+ description:
120
+ "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",
121
+ input: z.object({
122
+ host: z.url({ protocol: /^https?$/ }).describe("The instance's address, such as https://acme.sanoma.work"),
123
+ email: z.email().describe("The email the instance's people file declares"),
124
+ key: z
125
+ .strictObject({ $secret: SecretName })
126
+ .describe('The instance\'s SANOMA_SIGNING_KEY, as stored here: secret("NAME")'),
127
+ }),
128
+ output: z.object({ url: z.string().describe("The one-use invite link the instance minted") }),
129
+ target: ({ host }: { host: string }) => host,
130
+ // A link that signs someone in: the ledger records that there was one, not what it was.
131
+ opaque: ["url"],
132
+ },
133
+ },
134
+ token: {
135
+ mint: {
136
+ effect: "access",
137
+ description:
138
+ "Mint an api token acting for a subject in groups, store it under a name, and answer its id (never the token)",
139
+ input: z.object({
140
+ subject: z.string().regex(/^\S+$/, "a principal's id, such as visitor:better-auth:<id>"),
141
+ groups: z.array(z.string().min(1)),
142
+ store: SecretName.describe("The secret the token is stored under"),
143
+ }),
144
+ output: z.object({ id: z.string() }),
145
+ target: ({ subject }: { subject: string }) => subject,
146
+ createsSecrets: ({ store }: { store: string }) => [store],
147
+ },
148
+ revoke: {
149
+ effect: "access",
150
+ idempotent: true,
151
+ description: "Revoke an api token: it resolves to nobody from now on",
152
+ input: z.object({ id: z.string().min(1) }),
153
+ output: z.object({}),
154
+ target: ({ id }: { id: string }) => `token:${id}`,
155
+ },
156
+ },
157
+ });
158
+
159
+ /** A `DriverError` that a retry would not change. */
160
+ const refuse = (message: string) => new DriverError(`sanoma: ${message}`, { retryable: false });
161
+
162
+ /** The token id a call mints, from its idempotency key: the same on every try of one call, so a retry finds its own. */
163
+ function tokenIdOf(call: CallContext) {
164
+ const hex = createHash("sha256").update(call.idempotencyKey).digest("hex");
165
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`;
166
+ }
167
+
168
+ /** The 32 bytes of a signing key stored as text (base64, or base64url as `generate` makes one), or a refusal. */
169
+ function signingKeyOf(secret: string, text: string | undefined): Buffer {
170
+ if (text === undefined) throw refuse(`the secret ${secret} is not set: store the instance's signing key under it`);
171
+ const key = keyBytes(text);
172
+ if (!key) throw refuse(`${secret} is not a signing key: 32 bytes in base64`);
173
+ return key;
174
+ }
175
+
176
+ /**
177
+ * Posts the sealed `request` to the instance's `/auth/invite`, and answers the link it minted
178
+ * (`sanoma.invite.mint`, and going live's sign-in); a `DriverError` otherwise, retryable by status.
179
+ */
180
+ export async function postInvite(host: string, request: string): Promise<{ url: string }> {
181
+ const at = new URL("/auth/invite", host);
182
+ let res: Response;
183
+ try {
184
+ res = await fetch(at, {
185
+ method: "POST",
186
+ headers: { "content-type": "application/json" },
187
+ body: JSON.stringify({ request }),
188
+ // A redirect is answered as it is (a 3xx, not retried): the sealed request goes to `host` alone.
189
+ redirect: "manual",
190
+ signal: AbortSignal.timeout(INVITE_TIMEOUT_MS),
191
+ });
192
+ } catch (err) {
193
+ throw new DriverError(`sanoma: ${at.host} could not be reached: ${errorMessage(err)}`, {
194
+ retryable: true,
195
+ cause: err,
196
+ });
197
+ }
198
+ const body = (await res.json().catch(() => undefined)) as { url?: unknown; error?: unknown } | undefined;
199
+ if (res.ok && typeof body?.url === "string") return { url: body.url };
200
+ const why = typeof body?.error === "string" ? `: ${body.error}` : res.ok ? ": no url" : "";
201
+ throw new DriverError(`sanoma: ${at.host} answered ${res.status} to POST /auth/invite${why}`, {
202
+ retryable: !res.ok && retryableStatus(res.status),
203
+ status: res.status,
204
+ });
205
+ }
206
+
207
+ /**
208
+ * The driver of the runtime's own operations, over the call's secrets and `tokens` (the config's
209
+ * database's, `tokenStore`); `invite` posts a sealed request to an instance (`postInvite` by default).
210
+ */
211
+ export function runtimeDriver({
212
+ tokens,
213
+ identities,
214
+ invite = postInvite,
215
+ }: {
216
+ tokens: Tokens;
217
+ identities?: IdentityProvisioning;
218
+ invite?: (host: string, request: string, call: CallContext) => Promise<{ url: string }>;
219
+ }) {
220
+ return defineDriver(sanoma, {
221
+ identity: {
222
+ async enroll({ store, ...input }, call) {
223
+ if (!identities) throw refuse("identity provisioning is not configured");
224
+ // Store before enrolling: a lost DB response retries with the same credential, never a new bearer.
225
+ const credential = await call.secrets.ensure(store, generateSecret);
226
+ if (!/^[A-Za-z0-9_-]{43}$/.test(credential)) throw refuse("invalid identity bootstrap credential");
227
+ await identityWrite(() =>
228
+ identities.enroll({ ...input, hash: createHash("sha256").update(credential).digest("hex") }),
229
+ );
230
+ return { deployment: input.deployment, generation: input.generation, store };
231
+ },
232
+ async retire(input) {
233
+ if (!identities) throw refuse("identity provisioning is not configured");
234
+ await identityWrite(() => identities.retire(input));
235
+ return {};
236
+ },
237
+ async revoke(input) {
238
+ if (!identities) throw refuse("identity provisioning is not configured");
239
+ await identityWrite(() => identities.revoke(input));
240
+ return {};
241
+ },
242
+ },
243
+ secret: {
244
+ async generate({ name, kind }, call) {
245
+ if (kind === "random") {
246
+ await call.secrets.ensure(name, generateSecret);
247
+ return { name };
248
+ }
249
+ // A pair made only when none is stored: the public half is always derived from what is.
250
+ let stored = await call.secrets.get(name);
251
+ if (stored === undefined) {
252
+ const pair = await sourceKeyPair();
253
+ stored = await call.secrets.ensure(name, () => pair.privateKey);
254
+ }
255
+ const publicKey = sshPublicKey(stored);
256
+ if (!publicKey)
257
+ throw refuse(`${name} holds a value that is not an RSA private key: delete it, or name another`);
258
+ return { name, publicKey };
259
+ },
260
+ async delete({ name }, call) {
261
+ await call.secrets.delete(name);
262
+ return {};
263
+ },
264
+ },
265
+ invite: {
266
+ async mint({ host, email, key }, call) {
267
+ const signingKey = signingKeyOf(key.$secret, await call.secrets.get(key.$secret));
268
+ const request = sealInvite({ email }, signingKey);
269
+ return invite(host, request, call);
270
+ },
271
+ },
272
+ token: {
273
+ async mint({ subject, groups, store }, call) {
274
+ const id = tokenIdOf(call);
275
+ const held = await call.secrets.get(store);
276
+ if (held !== undefined) {
277
+ // This call's own, stored by a try whose answer was lost.
278
+ if ((await tokens.resolve(held))?.principal.id === `token:${id}`) return { id };
279
+ throw refuse(`${store} holds a value already: delete it, or store the token under another name`);
280
+ }
281
+ let token: string;
282
+ try {
283
+ ({ token } = await tokens.mint({ id, subject, groups }));
284
+ } catch (err) {
285
+ // A try that minted this id and stopped before storing it left a token nobody holds: revoked here.
286
+ if ((await tokens.subjectOf(id)) === undefined) throw err;
287
+ await tokens.revoke({ id });
288
+ throw refuse(`an earlier try minted token ${id} and stopped before storing it: it is revoked, start again`);
289
+ }
290
+ if ((await call.secrets.ensure(store, () => token)) !== token) {
291
+ await tokens.revoke({ id });
292
+ throw refuse(`${store} was stored by someone else meanwhile: the token minted is revoked`);
293
+ }
294
+ return { id };
295
+ },
296
+ async revoke({ id }) {
297
+ await tokens.revoke({ id });
298
+ return {};
299
+ },
300
+ },
301
+ });
302
+ }
303
+
304
+ /**
305
+ * The runtime's own operations for one sandbox run: the run's secrets (in memory), tokens in
306
+ * memory, and an invite that posts nothing, answering a link on the host named.
307
+ */
308
+ export const sandboxRuntimeDriver = () =>
309
+ runtimeDriver({
310
+ tokens: memoryTokens(),
311
+ identities: memoryIdentityProvisioning(),
312
+ invite: async (host, _request, call) => ({ url: new URL(`/auth/invite/sandbox-${tokenIdOf(call)}`, host).href }),
313
+ });
package/src/scenario.ts CHANGED
@@ -13,7 +13,7 @@ import {
13
13
  } from "@cucumber/messages";
14
14
  import type { z } from "zod";
15
15
  import type { RunSummary, SanomaClient } from "./client.ts";
16
- import type { Approver } from "./define.ts";
16
+ import type { Approver, Principal } from "./define.ts";
17
17
  import type { ResolvedConfig } from "./config.ts";
18
18
  import {
19
19
  buildLibrary,
@@ -162,8 +162,11 @@ export interface Scenario {
162
162
  text: string;
163
163
  /** Every step in order, for a list and for marking graph nodes. */
164
164
  steps: ScenarioStep[];
165
- /** Who starts the run, by id, when a `Given <who> is acting` says: a declared person or agent, `anonymous` or a visitor. */
166
- startedBy?: string;
165
+ /**
166
+ * Who starts the run when a `Given … is acting` says: a declared person or agent or `anonymous`
167
+ * by id, or a visitor, by id or, with the email and GitHub login its login verified, as a principal.
168
+ */
169
+ startedBy?: Principal | string;
167
170
  given: Given[];
168
171
  input: unknown;
169
172
  decisions: Decision[];
@@ -208,11 +211,11 @@ interface Then {
208
211
 
209
212
  /**
210
213
  * What a step of each kind reads into; `any` is a decision, which a step of any keyword may make,
211
- * and `actor` a `Given` saying who starts the run (`{who} is acting`), by id.
214
+ * and `actor` a `Given` saying who starts the run (`{who} is acting`), by id or as a principal.
212
215
  */
213
216
  interface Items {
214
217
  given: Unstepped<Given>[];
215
- actor: string;
218
+ actor: Principal | string;
216
219
  when: When;
217
220
  then: Then;
218
221
  any: Unstepped<Decision>;
@@ -398,6 +401,12 @@ function builtFor(scope: Scope): Built {
398
401
  /** A `Then` step of the generic steps: its expectation, checked against the run's records. */
399
402
  const expecting = (expect: CoreExpectation): Then => ({ expect, verify: (run) => detailOf(expect, run.records) });
400
403
 
404
+ /** The scenario's visitor (`a visitor is acting`) with what its login verified, as `publicFront` passes it on. */
405
+ const visitorWith = (verified: { email: unknown; github?: unknown }): Principal => ({
406
+ ...visitorPrincipal("scenario", "visitor"),
407
+ ...(verified as Pick<Principal, "email" | "github">),
408
+ });
409
+
401
410
  /** The rules a scope has, and the parameter types their expressions use. */
402
411
  function rulesOf(scope: Scope): { rules: Rule[]; parameterTypes: ParameterTypeSpec[] } {
403
412
  const { types, collections } = scope;
@@ -455,6 +464,13 @@ function rulesOf(scope: Scope): { rules: Rule[]; parameterTypes: ParameterTypeSp
455
464
  { kind: "actor", source: "{who} is acting", read: ([who]) => actorNamed(who as string) },
456
465
  // A visitor of a public site, as a login would vouch for one; `anonymous` is a `{who}`.
457
466
  { kind: "actor", source: "a visitor is acting", read: () => visitorPrincipal("scenario", "visitor").id },
467
+ // A visitor with the email its login verified, and a GitHub login it linked.
468
+ { kind: "actor", source: "a visitor {string} is acting", read: ([email]) => visitorWith({ email }) },
469
+ {
470
+ kind: "actor",
471
+ source: "a visitor {string} with GitHub {string} is acting",
472
+ read: ([email, github]) => visitorWith({ email, github }),
473
+ },
458
474
  {
459
475
  kind: "given",
460
476
  source: "{op} was called with",
@@ -985,7 +1001,9 @@ function compileWorld(built: Built, specs: Map<string, Spec>) {
985
1001
  switch (rule.kind) {
986
1002
  case "actor":
987
1003
  if (spec.startedBy !== undefined)
988
- throw new Error(`${spec.startedBy} is acting already: a run has one actor`);
1004
+ throw new Error(
1005
+ `${typeof spec.startedBy === "string" ? spec.startedBy : spec.startedBy.id} is acting already: a run has one actor`,
1006
+ );
989
1007
  spec.startedBy = rule.read(args, pickle);
990
1008
  break;
991
1009
  case "given": {
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") =>