@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
package/src/index.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  export {
2
2
  type CallContext,
3
3
  CLIENT_IP_HEADER,
4
+ requestOrigin,
5
+ sameOrigin,
4
6
  type Connector,
5
7
  type ConnectorSetup,
6
8
  type ConnectorVendor,
@@ -12,13 +14,17 @@ export {
12
14
  type Effect,
13
15
  isOp,
14
16
  isSvgElement,
17
+ INVITE_SECONDS,
15
18
  type LoginContext,
16
19
  type LoginHalf,
20
+ type LoginIdentity,
21
+ type LoginMethod,
17
22
  type ModelContext,
18
23
  type Op,
19
24
  type OpSpec,
20
25
  type ResourceGroup,
21
26
  retryableStatus,
27
+ type SignInMethod,
22
28
  type VendorInfo,
23
29
  withSetup,
24
30
  } from "./op.ts";
@@ -33,6 +39,7 @@ export {
33
39
  type Builtin,
34
40
  type Ctx,
35
41
  type SleepRequest,
42
+ type StartedBy,
36
43
  type StepOptions,
37
44
  type Trigger,
38
45
  type Use,
@@ -56,6 +63,7 @@ export {
56
63
  export {
57
64
  declaredWakeup,
58
65
  defineConfig,
66
+ envWakeup,
59
67
  resolveConfig,
60
68
  resolveDatabaseUrl,
61
69
  type ResolvedConfig,
@@ -201,9 +209,16 @@ export {
201
209
  storeDatabaseUrl,
202
210
  } from "./database.ts";
203
211
  export type { Chat, Chats } from "./chats.ts";
212
+ export type { Tokens } from "./tokens.ts";
213
+ export { type InviteRequest, openInvite, sealInvite, sealLoginCode } from "./seal.ts";
214
+ export { postInvite, sanoma } from "./runtime-ops.ts";
215
+ export { sourceKeyPair, sshPublicKey } from "./ssh-key.ts";
204
216
  export {
205
217
  checkWakeup,
206
218
  type DeclaredWakeup,
219
+ type EnvWakeup,
220
+ HOST_URL,
221
+ WAKE_PATH,
207
222
  type WakeupOutputs,
208
223
  type WakeupRegistration,
209
224
  type WakeupSpec,
@@ -243,6 +258,8 @@ export {
243
258
  principalOf,
244
259
  RUNTIME_AGENTS,
245
260
  HOST_CHECK_AGENT,
261
+ LOGIN_AGENT,
262
+ LOGIN_CODE_WORKFLOW,
246
263
  SCHEDULE_AGENT,
247
264
  credentialReady,
248
265
  DEFAULT_SLEEP_NAME,
@@ -298,7 +315,7 @@ export {
298
315
  export { checkSecretStore, type SecretStoreCheck } from "./secret-store-check.ts";
299
316
 
300
317
  export { postgresSecretStore } from "./credentials.ts";
301
- export { deploymentKey } from "./deployment-key.ts";
318
+ export { deploymentKey, signingKeyFor } from "./deployment-key.ts";
302
319
 
303
320
  export {
304
321
  defineConnection,
package/src/ledger.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { ConnectionSnapshot } from "./connection.ts";
2
2
  import { AsyncLocalStorage } from "node:async_hooks";
3
+ import { randomUUID } from "node:crypto";
3
4
  import { appendFile, mkdir, readFile, truncate } from "node:fs/promises";
4
5
  import { join, resolve } from "node:path";
5
6
  import { setTimeout as delay } from "node:timers/promises";
@@ -8,7 +9,7 @@ import { type Db, LEDGER_TABLE as TABLE, lazyPool, pgCode } from "./db.ts";
8
9
  import type { Approver, Principal } from "./define.ts";
9
10
  import { type ErrorInfo, isFinal, keepCode } from "./errors.ts";
10
11
  import { shown, warn } from "./log.ts";
11
- import type { Effect } from "./op.ts";
12
+ import type { Effect, SignInMethod } from "./op.ts";
12
13
  import type { RecordedDecision } from "./policy.ts";
13
14
  import type { Run } from "./run.ts";
14
15
  import { errorMessage } from "./shared.ts";
@@ -137,8 +138,58 @@ export type LedgerRecord = {
137
138
  | { type: "run.finished"; output: unknown }
138
139
  /** `error` as for `op.called`. */
139
140
  | { type: "run.failed"; error: ErrorInfo }
141
+ /**
142
+ * A declared person signed in to the staff login, by `method`; written outside any run, in the
143
+ * person's own `signin:<person>` (`signInRecord`), its `at` when.
144
+ */
145
+ | { type: "signin.recorded"; person: string; method: SignInMethod }
146
+ /** `by` minted an invite for the declared `person`, good until `expiresAt` (ISO 8601); in `invite:<person>`, outside any run. */
147
+ | { type: "invite.minted"; person: string; by: string; expiresAt: string }
140
148
  );
141
149
 
150
+ /** What a sign-in's or an invite's record names as its workflow: the staff login, which is no run. */
151
+ const LOGIN = "login";
152
+
153
+ /**
154
+ * The record of a sign-in or an invite, written outside any run (`SanomaClient.recordSignIn`,
155
+ * `recordInvite`): in the run `signin:<person>` or `invite:<person>`, each with an id of its own,
156
+ * `seq` 0, so a person's records read in the order they were written.
157
+ */
158
+ export function loginRecord(
159
+ app: string,
160
+ actor: Principal,
161
+ body: Extract<LedgerBody, { type: "signin.recorded" | "invite.minted" }>,
162
+ at = Date.now(),
163
+ ): LedgerRecord {
164
+ const runId = `${body.type === "signin.recorded" ? "signin" : "invite"}:${body.person}`;
165
+ return {
166
+ v: 1,
167
+ app,
168
+ id: `${runId}:${body.type}:${at}:${randomUUID()}`,
169
+ runId,
170
+ seq: 0,
171
+ at,
172
+ actor,
173
+ workflow: LOGIN,
174
+ ...body,
175
+ } as LedgerRecord;
176
+ }
177
+
178
+ /** When each of `people` last signed in, and how, by id: from their `signin:<person>` records; one who never did is left out. */
179
+ export async function lastSignIns(
180
+ store: LedgerStore,
181
+ people: readonly string[],
182
+ ): Promise<Record<string, { at: number; method: SignInMethod }>> {
183
+ const last = await Promise.all(
184
+ people.map(async (person) => {
185
+ const records = await store.read(`signin:${person}`);
186
+ const latest = records.reduce<LedgerRecord | undefined>((a, r) => (a && a.at > r.at ? a : r), undefined);
187
+ return latest?.type === "signin.recorded" ? [[person, { at: latest.at, method: latest.method }] as const] : [];
188
+ }),
189
+ );
190
+ return Object.fromEntries(last.flat());
191
+ }
192
+
142
193
  /**
143
194
  * The `ctx.all` member a record was written in. `id` is `all:<seq>`, the run's next `seq` when
144
195
  * the `ctx.all` began, so a replay names it the same; `index` is the member's position, `size`
@@ -0,0 +1,52 @@
1
+ import { z } from "zod";
2
+ import { defineWorkflow, type WorkflowDefinition } from "./define.ts";
3
+ import type { Op } from "./op.ts";
4
+ import { openLoginCode } from "./seal.ts";
5
+ import type { LOGIN_CODE_WORKFLOW } from "./shared.ts";
6
+
7
+ // The built-in `login-code` workflow: one mail with a sign-in code, through the config's
8
+ // `login.sender`. The app starts it as the runtime's agent `login` (`LOGIN_AGENT`) with the code
9
+ // sealed (`sealLoginCode`), so neither the run's input nor the ledger shows it: the call is
10
+ // recorded with its body as `<text>` (`recorded` in `call.ts`). It is a workflow like any other:
11
+ // the call goes through `ctx`, under the policy, in the ledger.
12
+
13
+ /** An operation on `ctx`. */
14
+ type OpCall = (input: unknown) => Promise<unknown>;
15
+
16
+ /** The workflow's input: to whom, and the code, sealed. */
17
+ const LoginCodeInput = z.object({ to: z.string().min(1), code: z.string().min(1) });
18
+
19
+ /** What a sign-in code is sent as, for checks before any is: a sender must take it, and the policy is asked about it. */
20
+ export const CODE_MAIL = { to: "person@example.com", subject: "Your sign-in code", text: "<text>" };
21
+
22
+ /** The mail's text: the code, what it is for, and how long it lasts; nothing else, and no link. */
23
+ const textOf = (company: string, code: string) =>
24
+ `Your sign-in code for ${company} is ${code}.\n\n` +
25
+ "It works once, for five minutes. If you did not ask for it, ignore this mail.\n";
26
+
27
+ /** The login-code workflow for a config: it sends through `sender`, saying it is `company`'s. */
28
+ export function loginCodeWorkflow(
29
+ sender: Op,
30
+ company: string,
31
+ ): WorkflowDefinition<readonly Op[], typeof LoginCodeInput> {
32
+ return defineWorkflow({
33
+ // The literal, held to the one name: the outline finds a workflow by a string, or a const of its own file.
34
+ name: "login-code" satisfies typeof LOGIN_CODE_WORKFLOW,
35
+ title: "Send a sign-in code",
36
+ trigger: "manual",
37
+ input: LoginCodeInput,
38
+ uses: [sender],
39
+ run: async (ctx, input) => {
40
+ const { vendor, resource, name } = sender;
41
+ const mail = {
42
+ to: input.to,
43
+ subject: `Your ${company} sign-in code`,
44
+ text: textOf(company, openLoginCode(input.code, input.to)),
45
+ };
46
+ // `ctx` called directly, so the outline places the call.
47
+ return (ctx as unknown as Record<string, Record<string, Record<string, OpCall>>>)[vendor]![resource]![name]!(
48
+ mail,
49
+ );
50
+ },
51
+ });
52
+ }
package/src/op.ts CHANGED
@@ -360,12 +360,31 @@ export interface ConnectorSetup {
360
360
  /** What the app gives a connector's model half (`ConnectorSetup.model`): the secrets, as a login half reads them. */
361
361
  export type ModelContext = Pick<LoginContext, "secrets">;
362
362
 
363
- /** What the app gives a connector's login half (`ConnectorSetup.login`) when it makes it for a public site. */
363
+ /**
364
+ * What a login vouches for about who is asking: a name they gave (`id`, the loopback's), a
365
+ * verified email, a GitHub login. The app admits the declared person they match (`personOf`).
366
+ */
367
+ export interface LoginIdentity {
368
+ id?: string;
369
+ email?: string;
370
+ github?: string;
371
+ }
372
+
373
+ /** How a login half lets a person in: an email code, GitHub, an invite link, an email and a password. */
374
+ export type LoginMethod = "code" | "github" | "invite" | "password";
375
+
376
+ /** How a person signed in to the staff login, which the ledger records (`signin.recorded`): all but a password. */
377
+ export type SignInMethod = Exclude<LoginMethod, "password">;
378
+
379
+ /**
380
+ * What the app gives a connector's login half (`ConnectorSetup.login`) when it makes it: for a
381
+ * public site, once per site; for the staff login, once, with `staff`.
382
+ */
364
383
  export interface LoginContext {
365
384
  /** The company's database (`resolved.databaseUrl`), where a login like Better Auth keeps its users. */
366
385
  databaseUrl: string;
367
386
  /**
368
- * A 32-byte key derived from the deployment's `SANOMA_SECRETS_KEY` for this login (HKDF-SHA256,
387
+ * A 32-byte key derived from the deployment's `SANOMA_SIGNING_KEY` for this login (HKDF-SHA256,
369
388
  * purpose `login <connector>`), hex; a login signs its cookies with it and never asks a person for a secret.
370
389
  */
371
390
  key: string;
@@ -377,9 +396,24 @@ export interface LoginContext {
377
396
  /**
378
397
  * The origins the site answers on, scheme included and `:*` for any port:
379
398
  * `["http://www.localhost:*", "https://sanoma.example"]`; a login needs the protocol for its
380
- * cookies and allowed hosts.
399
+ * cookies and allowed hosts. Empty for the staff login, which answers on any host the app does.
381
400
  */
382
401
  origins: readonly string[];
402
+ /**
403
+ * Sends a sign-in code to an email, through the config's `login.sender` (a run of the built-in
404
+ * `login-code` workflow, as the runtime's agent `login`); absent when the config names no
405
+ * sender, and then the login offers no code.
406
+ */
407
+ sendCode?(email: string, code: string): Promise<void>;
408
+ /** For the staff login only: who it may let in, the company's social clients, and where it tells of a sign-in. */
409
+ staff?: {
410
+ /** Whether the people files declare the person `identity` names; a login never makes a user for anyone else. */
411
+ admits(identity: LoginIdentity): boolean;
412
+ /** The company's own OAuth clients, each a method when given. */
413
+ social: { github?: { clientId: string; clientSecret: string } };
414
+ /** Tells the app of a sign-in by `method` of the person `identity` names, which the ledger records. Must not throw. */
415
+ record(method: SignInMethod, identity: LoginIdentity): Promise<void>;
416
+ };
383
417
  }
384
418
 
385
419
  /**
@@ -390,23 +424,62 @@ export interface LoginContext {
390
424
  export const CLIENT_IP_HEADER = "x-sanoma-client-ip";
391
425
 
392
426
  /**
393
- * A connector's login half: how a public site tells who a visitor is. The app makes one with
394
- * `login(context)` once per public site, when it starts; a login that keeps tables in the
395
- * database migrates them itself there, serialized and idempotent. It answers the provider's
396
- * subject only; the app makes the principal from it (`visitor:<connector>:<subject>`, in
397
- * `visitors`), so a connector never sets a visitor's groups. A visitor's address, for rate
398
- * limits, is the `CLIENT_IP_HEADER` header.
427
+ * A request's origin, https when a proxy that ends TLS says so (`x-forwarded-proto: https`), as
428
+ * Start's `getRequestUrl({ xForwardedProto: true })` reads it: the absolute URLs a page or an invite writes, and a login's cookies, take it.
429
+ */
430
+ export function requestOrigin(request: Request): string {
431
+ const url = new URL(request.url);
432
+ if (request.headers.get("x-forwarded-proto") === "https") url.protocol = "https:";
433
+ return url.origin;
434
+ }
435
+
436
+ /** Whether a change comes from the page's own origin (a login's CSRF check): the browser says so, or its `Origin` names the request's host. */
437
+ export function sameOrigin(request: Request): boolean {
438
+ const site = request.headers.get("sec-fetch-site");
439
+ if (site !== null) return site === "same-origin";
440
+ const origin = URL.parse(request.headers.get("origin") ?? "");
441
+ return origin !== null && origin.host === request.headers.get("host");
442
+ }
443
+
444
+ /**
445
+ * A connector's login half: how a public site tells who a visitor is, and, made with
446
+ * `LoginContext.staff`, how the app's staff sign in. The app makes one with `login(context)` once
447
+ * per public site (and once for staff), when it starts; a login that keeps tables in the database
448
+ * migrates them itself there, serialized and idempotent. It answers the provider's subject and
449
+ * what it verified; the app makes the principal from it (`visitor:<connector>:<subject>`, in
450
+ * `visitors`, or the declared person the identity matches), so a connector never sets groups. A
451
+ * visitor's address, for rate limits, is the `CLIENT_IP_HEADER` header.
399
452
  */
400
453
  export interface LoginHalf {
401
- /** Who signed in, as the provider's stable subject, or null; never throws for a missing or bad cookie. */
402
- verify(request: Request): Promise<{ subject: string } | null>;
454
+ /**
455
+ * Who signed in, as the provider's stable subject, with their email when the provider verified
456
+ * it and their GitHub login when they signed in with GitHub; null for a missing or bad cookie,
457
+ * for which it never throws.
458
+ */
459
+ verify(request: Request): Promise<{ subject: string; email?: string; github?: string } | null>;
403
460
  /** Answers its own routes, `/auth/<vendor>/*`; undefined for any other request. */
404
461
  handle(request: Request): Promise<Response | undefined>;
405
462
  /** The path a page POSTs to sign out, which `handle` serves; the app's own Sign out POSTs it with no body. */
406
463
  signOut?: string;
464
+ /** How a person may sign in now: what the sign-in page offers. */
465
+ methods?: readonly LoginMethod[];
466
+ /**
467
+ * A staff login's invite for `email`, a declared person's: a one-use token, good for
468
+ * `INVITE_SECONDS`, which replaces the last one minted for that email. `request` is the
469
+ * request that asked for it, on the host the invite is for.
470
+ */
471
+ invite?(email: string, request: Request): Promise<string>;
472
+ /**
473
+ * The invite a token is, while it is good: for whom, and the path (on the login's routes) that
474
+ * signs them in with it, using it up.
475
+ */
476
+ invitation?(token: string): Promise<{ email: string; path: string } | undefined>;
407
477
  close?(): Promise<void>;
408
478
  }
409
479
 
480
+ /** How long an invite lasts: 24 hours. */
481
+ export const INVITE_SECONDS = 24 * 60 * 60;
482
+
410
483
  /**
411
484
  * The connector with `setup` laid over what it already carries, as a connector package exports
412
485
  * it: `export const ghost = withSetup(ghostOps, { driver: ghostDriver, fake: fakeGhost, steps: ghostSteps })`. A
package/src/outline.ts CHANGED
@@ -193,7 +193,7 @@ const PARAM =
193
193
  "run must take ctx as its first parameter, by one name: destructured, its calls cannot be read into the graph";
194
194
  const CTX_USE =
195
195
  "ctx is only called, directly (`await ctx.<vendor>.<resource>.<op>(…)`, `ctx.approval(…)`, `ctx.sleep(…)`, " +
196
- "`ctx.all([…])`) or read for ctx.runId: passed on, aliased or destructured, its calls are not in the graph";
196
+ "`ctx.all([…])`) or read for ctx.runId or ctx.startedBy: passed on, aliased or destructured, its calls are not in the graph";
197
197
  const CTX_CALL = (what: string) =>
198
198
  `ctx.${what} is not a call the graph can draw: an operation is ctx.<vendor>.<resource>.<op>(…), ` +
199
199
  "and the built-ins are ctx.approval(…), ctx.sleep(…), ctx.all([…]) and ctx.now()";
@@ -237,7 +237,7 @@ interface Where {
237
237
  }
238
238
 
239
239
  /** The members of `ctx` that are read, not placed: no record, no node. */
240
- const READS = new Set(["now", "runId"]);
240
+ const READS = new Set(["now", "runId", "startedBy"]);
241
241
 
242
242
  /**
243
243
  * The nodes of `fn`'s body (`run`'s, or a callback's the outline reads), each with an empty `path`
@@ -282,7 +282,7 @@ export function outlineBody(
282
282
  return uses;
283
283
  };
284
284
 
285
- /** `ctx.runId`, read. */
285
+ /** `ctx.runId` or `ctx.startedBy`, read. */
286
286
  const isRead = (member: Node) =>
287
287
  !member.computed &&
288
288
  unwrap(member.object)?.type === "Identifier" &&
package/src/resource.ts CHANGED
@@ -238,7 +238,7 @@ const record = z.record(z.string(), z.unknown());
238
238
  export const PLAN_ACTIONS = ["none", "create", "update", "replace", "delete"] as const;
239
239
  export type PlanAction = (typeof PLAN_ACTIONS)[number];
240
240
 
241
- /** The operations of a writable type that change its objects: only the apply workflow calls them (`isChangeOp`). */
241
+ /** The operations of a writable type that change its objects (`isChangeOp`): apply's for a declared object, any workflow's for another. */
242
242
  export const CHANGE_OPS = ["create", "update", "delete"] as const;
243
243
 
244
244
  const opsOf = <S extends z.ZodObject>(schema: S) => {
@@ -293,7 +293,8 @@ export type ResourceOps<S extends z.ZodObject, W extends boolean = false> = Spec
293
293
 
294
294
  /**
295
295
  * True for an operation that changes a writable type's objects (`CHANGE_OPS`), which only the
296
- * apply workflow may call: by its vendor, resource and name, so a copy of a connector is told too.
296
+ * apply workflow may call on a declared object (a workflow's call is refused one, `refuseDeclared`
297
+ * in `call.ts`): by its vendor, resource and name, so a copy of a connector is told too.
297
298
  */
298
299
  export function isChangeOp(
299
300
  op: { readonly vendor: string; readonly resource: string; readonly name: string },
@@ -565,8 +566,9 @@ function pathsIn(value: unknown, paths: ReadonlySet<string>, prefix = ""): strin
565
566
  * returns the same: `{ gone: true }` when the object no longer exists, else its fresh
566
567
  * `state`. A driver may import first when it is given no state.
567
568
  *
568
- * A `writable` type has the change operations too, which only the apply workflow may call
569
- * (`isChangeOp`): `create`, `update` and `delete` effect `write` (`delete` idempotent), and `plan`:
569
+ * A `writable` type has the change operations too, which only the apply workflow may call on a
570
+ * declared object, and any workflow on another (`isChangeOp`): `create`, `update` and `delete`
571
+ * effect `write` (`delete` idempotent), and `plan`:
570
572
  *
571
573
  * - `plan` (`read`, idempotent) takes `{ id?, state?, handle?, desired? }`, no `desired` for a
572
574
  * delete and no `id` for a create, and returns `{ action, planned?, unknown, replace }`:
@@ -691,7 +693,8 @@ export function defineResource<
691
693
  if (!underAny(path, writeOnly)) {
692
694
  throw new Error(`${what}: ${at} is not write-only, so it takes a value, not a secret (${value.$secret})`);
693
695
  }
694
- if (isSanomaSecret(value.$secret) && !(host && underAny(path, new Set([host.inputs.secrets])))) {
696
+ const hostSecrets = host?.inputs.secrets;
697
+ if (isSanomaSecret(value.$secret) && !(hostSecrets !== undefined && underAny(path, new Set([hostSecrets])))) {
695
698
  throw new Error(`${what}: ${at} names ${value.$secret}, one of sanoma's own: only a host's secrets may`);
696
699
  }
697
700
  secrets.add(at);
package/src/resources.ts CHANGED
@@ -17,6 +17,7 @@ import {
17
17
  ANONYMOUS_PRINCIPAL,
18
18
  type At,
19
19
  declaresPeople,
20
+ isToken,
20
21
  isVisitor,
21
22
  type PeopleResource,
22
23
  personOf,
@@ -138,8 +139,10 @@ export function readPeople(root: string | undefined): DeclaredPeople {
138
139
  * (`signIn`), of the person it matches (`personOf`: email, GitHub login, then id), never an agent.
139
140
  * While no person is declared, whoever names an id is taken as given. A visitor's id (`isVisitor`)
140
141
  * is taken whatever the files say, with the groups stamped here (`visitors`, or none for
141
- * `anonymous`), never through a sign-in: the staff logins admit no visitor. Throws
142
- * `NotAdmittedError` for anyone else, and for everyone while the people's files have problems.
142
+ * `anonymous`), never through a sign-in: the staff logins admit no visitor. A visitor keeps the
143
+ * `email` its login verified; a `subject` is never taken from the caller (a token's is its row's,
144
+ * which `SanomaClient.start` reads). Throws `NotAdmittedError` for anyone else, and for
145
+ * everyone while the people's files have problems.
143
146
  */
144
147
  export function admit(
145
148
  asking: { id?: string; email?: string; github?: string; groups?: string[]; onBehalfOf?: string },
@@ -149,7 +152,10 @@ export function admit(
149
152
  const who = asking.github ?? asking.id ?? asking.email ?? "This sign-in";
150
153
  if (asking.id !== undefined && isVisitor({ id: asking.id })) {
151
154
  if (signIn) throw new NotAdmittedError(who);
152
- return asking.id === ANONYMOUS ? ANONYMOUS_PRINCIPAL : { id: asking.id, groups: [VISITORS] };
155
+ if (asking.id === ANONYMOUS) return ANONYMOUS_PRINCIPAL;
156
+ // A token's groups are its row's (`SanomaClient.tokens`), which only the public front reads; a visitor's are stamped.
157
+ if (isToken({ id: asking.id })) return { id: asking.id, groups: [...(asking.groups ?? [])] };
158
+ return { id: asking.id, groups: [VISITORS], ...(asking.email && { email: asking.email }) };
153
159
  }
154
160
  if (problems.length) {
155
161
  // The runtime's own agents are declared by no file, so a broken one does not stop them (`check`'s scenarios).
@@ -160,7 +166,10 @@ export function admit(
160
166
  }
161
167
  if (!declaresPeople(people)) {
162
168
  const given = Principal.safeParse(asking).data;
163
- if (given) return given;
169
+ if (given) {
170
+ const { email: _email, subject: _subject, ...principal } = given;
171
+ return principal;
172
+ }
164
173
  } else {
165
174
  const id = signIn ? personOf(asking, people) : asking.id;
166
175
  const admitted = id === undefined ? undefined : principalOf(id, people);
package/src/run.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { ConnectionRef, ConnectionRegistry, ConnectionSnapshot } from "./connection.ts";
2
2
  import { AsyncLocalStorage } from "node:async_hooks";
3
3
  import type { Collection } from "./collection.ts";
4
+ import type { DeclaredObjects } from "./config.ts";
4
5
  import type { DeadlineRegistry } from "./deadlines.ts";
5
6
  import type { ApprovalState, Approver, Principal, WorkflowDefinition } from "./define.ts";
6
7
  import type { Fake } from "./fake.ts";
@@ -53,6 +54,8 @@ export interface WorkerState {
53
54
  /** The worker's `source`, when it was given one. */
54
55
  source?: Source;
55
56
  types: ReadonlyMap<string, Resource>;
57
+ /** The objects the data files declare, read at each call: a workflow's change operation may not aim at one (`refuseDeclared`). */
58
+ declaredObjects(): Promise<DeclaredObjects>;
56
59
  /** The collections, by name, which scenarios' steps may name (`data.<name>`). */
57
60
  collections: ReadonlyMap<string, Collection>;
58
61
  steps: readonly Steps[];