@sanoma-ai/workflows 0.11.1 → 0.11.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 (57) hide show
  1. package/README.md +2 -2
  2. package/dist/apply.d.ts +2 -2
  3. package/dist/apply.js +4 -2
  4. package/dist/apply.js.map +1 -1
  5. package/dist/bundled.d.ts +29 -0
  6. package/dist/bundled.js +16 -0
  7. package/dist/bundled.js.map +1 -0
  8. package/dist/cedar.d.ts +4 -1
  9. package/dist/cedar.js +8 -1
  10. package/dist/cedar.js.map +1 -1
  11. package/dist/config.d.ts +8 -1
  12. package/dist/config.js +23 -4
  13. package/dist/config.js.map +1 -1
  14. package/dist/constructors.d.ts +1 -1
  15. package/dist/db.d.ts +7 -0
  16. package/dist/db.js +30 -1
  17. package/dist/db.js.map +1 -1
  18. package/dist/define.d.ts +2 -1
  19. package/dist/define.js +4 -2
  20. package/dist/define.js.map +1 -1
  21. package/dist/describe.js +27 -4
  22. package/dist/describe.js.map +1 -1
  23. package/dist/host.d.ts +14 -0
  24. package/dist/host.js +2 -0
  25. package/dist/host.js.map +1 -1
  26. package/dist/index.d.ts +5 -1
  27. package/dist/index.js +7 -1
  28. package/dist/index.js.map +1 -1
  29. package/dist/load.js +3 -2
  30. package/dist/load.js.map +1 -1
  31. package/dist/resources.d.ts +1 -1
  32. package/dist/resources.js +5 -1
  33. package/dist/resources.js.map +1 -1
  34. package/dist/schema.d.ts +12 -2
  35. package/dist/schema.js +12 -6
  36. package/dist/schema.js.map +1 -1
  37. package/dist/shared.d.ts +29 -0
  38. package/dist/shared.js +12 -1
  39. package/dist/shared.js.map +1 -1
  40. package/dist/wakeup-type.d.ts +1 -1
  41. package/dist/wakeup-type.js +3 -1
  42. package/dist/wakeup-type.js.map +1 -1
  43. package/package.json +1 -1
  44. package/src/apply.ts +5 -4
  45. package/src/bundled.ts +42 -0
  46. package/src/cedar.ts +7 -1
  47. package/src/config.ts +22 -5
  48. package/src/db.ts +35 -1
  49. package/src/define.ts +4 -2
  50. package/src/describe.ts +25 -3
  51. package/src/host.ts +15 -0
  52. package/src/index.ts +7 -0
  53. package/src/load.ts +5 -2
  54. package/src/resources.ts +4 -1
  55. package/src/schema.ts +17 -4
  56. package/src/shared.ts +28 -1
  57. package/src/wakeup-type.ts +3 -1
package/src/config.ts CHANGED
@@ -57,7 +57,8 @@ import {
57
57
  VENDOR,
58
58
  withSetup,
59
59
  } from "./op.ts";
60
- import { type CedarPolicySource, policyAt } from "./cedar.ts";
60
+ import { rootSnapshot, type RootSnapshot } from "./bundled.ts";
61
+ import { type CedarPolicySource, policyAt, readPolicyFiles } from "./cedar.ts";
61
62
  import type { Policy } from "./policy.ts";
62
63
  import { checkFromRefs, type Resource, resourceTypesOf, withoutWriteOnly } from "./resource.ts";
63
64
  import { codeMail, loginCodeWorkflow } from "./login-code.ts";
@@ -76,7 +77,7 @@ import { tokenStore } from "./tokens.ts";
76
77
  import type { Steps } from "./steps.ts";
77
78
  import { checkSchedule } from "./schedules.ts";
78
79
  import { changeCodeOf, computeVersion } from "./version.ts";
79
- import type { DeclaredResource, ResourceProblem } from "./resources.ts";
80
+ import { type DeclaredResource, readPeople, type ResourceProblem } from "./resources.ts";
80
81
  import { checkWakeupOps, type DeclaredWakeup, type EnvWakeup, HOST_URL } from "./wakeup.ts";
81
82
  import { instanceUrl, WAKEUP_TOKEN } from "./wakeup-service.ts";
82
83
  import { sanomaWakeupDriver, wakeupSteps } from "./wakeup-driver.ts";
@@ -425,6 +426,7 @@ export function resolveConfig(config: SanomaConfig): ResolvedConfig {
425
426
  const login = config.login && checkLogin(config.login, ops, drivers, appName);
426
427
  if (login) names.set(LOGIN_CODE_WORKFLOW, loginCodeWorkflow(login, appName));
427
428
  const { policy, policies, problems: policyProblems } = policyAt(root, { ops, workflows: names, collections });
429
+ const bundled = rootSnapshot(root);
428
430
  return {
429
431
  secrets: config.secrets ?? {},
430
432
  connections,
@@ -432,9 +434,10 @@ export function resolveConfig(config: SanomaConfig): ResolvedConfig {
432
434
  identityProvisioning,
433
435
  appName,
434
436
  databaseUrl,
435
- // The built-in's code and operations are part of the app's version, like the config's own.
436
- version: computeVersion({ appName, workflows: [...names.values()] }),
437
- changeCode: changeCodeOf(names.get(CHANGE_WORKFLOW)!),
437
+ // The built-in's code and operations are part of the app's version, like the config's own. A
438
+ // bundle's code is not the source's: it carries the hashes of the root it was read from.
439
+ version: bundled?.version ?? computeVersion({ appName, workflows: [...names.values()] }),
440
+ changeCode: bundled?.changeCode ?? changeCodeOf(names.get(CHANGE_WORKFLOW)!),
438
441
  queueName: `sanoma:${appName}`,
439
442
  ...(login && { login }),
440
443
  ops,
@@ -593,12 +596,26 @@ function makersOf<K extends keyof ConnectorSetup>(
593
596
  return makers;
594
597
  }
595
598
 
599
+ /**
600
+ * What the runtime reads from `config`'s root that a bundle carries where the files are not
601
+ * (`useRootSnapshot`): the app's name and versions as `resolved` has them, the policy files and
602
+ * the people the data files declare.
603
+ */
604
+ export function readRootSnapshot(config: SanomaConfig, resolved: ResolvedConfig = resolveConfig(config)): RootSnapshot {
605
+ const root = rootOf(config);
606
+ if (root === undefined) throw new Error("A config with no root has no files to carry");
607
+ const { appName, version, changeCode } = resolved;
608
+ return { appName, version, changeCode, policies: readPolicyFiles(root), people: readPeople(root) };
609
+ }
610
+
596
611
  /**
597
612
  * The `name` of `<root>/package.json`, kept whole (`@acme/ops`), when that file exists and names
598
613
  * one; else "sanoma", as for a config with no `root`. Never a `package.json` above the root: a
599
614
  * parent project's name must not become the app's. A file that is not JSON is refused, naming it.
600
615
  */
601
616
  function appNameAt(root: string | undefined): string {
617
+ const bundled = rootSnapshot(root);
618
+ if (bundled) return bundled.appName;
602
619
  const file = root === undefined ? undefined : join(root, "package.json");
603
620
  if (file === undefined || !existsSync(file)) return "sanoma";
604
621
  let manifest: unknown;
package/src/db.ts CHANGED
@@ -226,6 +226,19 @@ export function lazyPool(databaseUrl: string, what: string): LazyPool {
226
226
  /** Each database prepared, or being prepared, by its URL. A failed preparation is forgotten. */
227
227
  const prepared = new Map<string, Promise<void>>();
228
228
 
229
+ /** What prepares the databases instead of this process (`checkPreparedOnly`), which a refusal names. */
230
+ let preparedBy: string | undefined;
231
+
232
+ /**
233
+ * From now on, `prepareDatabase` makes nothing: it only checks that DBOS's tables and the
234
+ * runtime's are there, and refuses, naming `by`, what prepares them, when they are not. For a
235
+ * process beside the one that prepares the database, which must never migrate it: the Cloudflare
236
+ * host's Worker, whose container prepares it at its start.
237
+ */
238
+ export function checkPreparedOnly(by: string): void {
239
+ preparedBy = by;
240
+ }
241
+
229
242
  /**
230
243
  * Makes the database at `databaseUrl` ready for the runtime, once per process and URL: the
231
244
  * database itself and DBOS's tables when they are not there yet (as a worker's launch makes
@@ -237,7 +250,8 @@ const prepared = new Map<string, Promise<void>>();
237
250
  export function prepareDatabase(databaseUrl: string): Promise<void> {
238
251
  let preparing = prepared.get(databaseUrl);
239
252
  if (!preparing) {
240
- preparing = prepare(databaseUrl).catch((err: unknown) => {
253
+ const made = preparedBy === undefined ? prepare(databaseUrl) : checkPrepared(databaseUrl, preparedBy);
254
+ preparing = made.catch((err: unknown) => {
241
255
  if (prepared.get(databaseUrl) === preparing) prepared.delete(databaseUrl);
242
256
  throw err;
243
257
  });
@@ -267,6 +281,26 @@ async function prepare(databaseUrl: string): Promise<void> {
267
281
  }
268
282
  }
269
283
 
284
+ /** The last of the runtime's tables a preparation makes (`TABLES`), so its being there says they all are. */
285
+ const LAST_TABLE = API_TOKENS_TABLE;
286
+
287
+ /** Refuses when the database at `databaseUrl` lacks DBOS's tables or the runtime's: `by` makes them, not this process. */
288
+ async function checkPrepared(databaseUrl: string, by: string): Promise<void> {
289
+ const conn = await connect(databaseUrl);
290
+ try {
291
+ const { rows } = await conn.query<{ dbos: boolean; ours: boolean }>(
292
+ `SELECT to_regclass('dbos.workflow_status') IS NOT NULL AS dbos, to_regclass('${LAST_TABLE}') IS NOT NULL AS ours`,
293
+ );
294
+ const { dbos, ours } = rows[0]!;
295
+ if (!dbos || !ours) {
296
+ const missing = [...(dbos ? [] : ["DBOS's"]), ...(ours ? [] : ["Sanoma's"])].join(" and ");
297
+ throw new Error(`the database has no ${missing} tables yet: ${by} makes them, and this process never does`);
298
+ }
299
+ } finally {
300
+ await conn.end().catch(() => undefined);
301
+ }
302
+ }
303
+
270
304
  /**
271
305
  * A connection to the database at `databaseUrl` (made first when missing) holding the
272
306
  * preparation's session lock until it ends, so a second process waits, then finds everything
package/src/define.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { bundledFile } from "./bundled.ts";
1
2
  import type { ConnectionRef } from "./connection.ts";
2
3
  import type { WorkflowEntry } from "./description.ts";
3
4
  import { fileURLToPath } from "node:url";
@@ -396,11 +397,12 @@ const isTimeZone = (zone: unknown): boolean => {
396
397
 
397
398
  /**
398
399
  * The file of the code that called the function calling this one (`defineWorkflow`'s caller),
399
- * from V8's call sites, as a path; undefined where there is none.
400
+ * from V8's call sites, as a path; where they name none, the file a bundle says it is evaluating
401
+ * (`bundledFile`); undefined where there is neither.
400
402
  */
401
403
  export function callerFile(): string | undefined {
402
404
  // Frame 0 is this function, frame 1 its caller, frame 2 theirs: by index, since a bundle may hold all three.
403
- return scriptPath(getCallSites(3)[2]?.scriptName);
405
+ return scriptPath(getCallSites(3)[2]?.scriptName) ?? bundledFile();
404
406
  }
405
407
 
406
408
  /** A V8 script name (a `file:` URL for a module, a path for a script) as a path; undefined for none. */
package/src/describe.ts CHANGED
@@ -39,6 +39,7 @@ import {
39
39
  type PeopleResource,
40
40
  principalOf,
41
41
  problemLine,
42
+ siteAtHostname,
42
43
  } from "./shared.ts";
43
44
  import { readSites, type SiteEntry } from "./sites.ts";
44
45
 
@@ -361,8 +362,9 @@ function appClashes(
361
362
 
362
363
  /**
363
364
  * Each site a host's `domains` (its type's `host.inputs.domains`) names that is not a public
364
- * site, and each domain it gives more than one site, at the host's declaration: the app serves
365
- * only a public site at a domain, and one site at each.
365
+ * site, each domain it gives more than one site, and a domain that is the host's `hostname` too,
366
+ * or another host's hostname or domain, at the host's declaration: the app serves only a public
367
+ * site at a domain, one site at each, and itself at another, and one host holds each.
366
368
  */
367
369
  function domainProblems(
368
370
  declared: readonly DeclaredResource[],
@@ -371,9 +373,16 @@ function domainProblems(
371
373
  ): ResourceProblem[] {
372
374
  const isPublic = new Set(sites.filter((site) => site.public).map((site) => site.name));
373
375
  const problems: ResourceProblem[] = [];
376
+ // Each hostname a host serves the app at, and each host's domains, lowercased: one host attaches each.
377
+ const hostnames: [string, DeclaredResource][] = [];
378
+ const held = new Map<string, Set<DeclaredResource>>();
379
+ const hold = (name: string, resource: DeclaredResource) =>
380
+ held.set(name.toLowerCase(), (held.get(name.toLowerCase()) ?? new Set()).add(resource));
374
381
  for (const resource of declared) {
375
- const path = types.get(`${resource.vendor}.${resource.type}`)?.host?.inputs.domains;
382
+ const inputs = types.get(`${resource.vendor}.${resource.type}`)?.host?.inputs;
383
+ const path = inputs?.domains;
376
384
  const domains = (path === undefined ? {} : (valueAt(resource.desired, path) ?? {})) as Record<string, unknown>;
385
+ const hostname = inputs?.hostname === undefined ? undefined : valueAt(resource.desired, inputs.hostname);
377
386
  const messages = Object.keys(domains)
378
387
  .filter((name) => !isPublic.has(name))
379
388
  .map((name) => `domains names "${name}", which is not a public site (defineSite({ public: true }))`);
@@ -385,7 +394,20 @@ function domainProblems(
385
394
  const others = `${names.length === 2 ? "both " : ""}${names.slice(0, -1).join(", ")}`;
386
395
  messages.push(`${domain} is the domain of ${others} and ${names.at(-1)}`);
387
396
  }
397
+ const site = typeof hostname === "string" ? siteAtHostname(hostname, domains) : undefined;
398
+ if (site) messages.push(`${hostname as string} is the app's hostname and the domain of ${site}`);
388
399
  problems.push(...messages.map((message) => ({ ...resource.at, message })));
400
+ for (const [, domain] of named) hold(domain as string, resource);
401
+ if (typeof hostname === "string") {
402
+ hostnames.push([hostname, resource]);
403
+ hold(hostname, resource);
404
+ }
405
+ }
406
+ for (const [hostname, resource] of hostnames) {
407
+ const others = [...(held.get(hostname.toLowerCase()) ?? [])].filter((other) => other !== resource);
408
+ if (others.length === 0) continue;
409
+ const message = `${hostname} is the app's hostname here and is served by ${others.map((o) => o.id).join(", ")} too`;
410
+ problems.push({ ...resource.at, message });
389
411
  }
390
412
  return problems;
391
413
  }
package/src/host.ts CHANGED
@@ -29,11 +29,20 @@ export interface HostCapabilities {
29
29
  /** What a host's state says of it: where the app answers, which commit it runs, and whether it is up. */
30
30
  export interface HostOutputs {
31
31
  url: string;
32
+ /**
33
+ * Where a wakeup (and a check of its process) reaches it, `url` when left out: the vendor's own
34
+ * URL of a host whose `url` may be its `hostname`, so a wake never depends on a custom domain's DNS and certificate, and a
35
+ * hostname declared or dropped leaves its wakeup as it was. Apply gives a wakeup this one.
36
+ */
37
+ wakeUrl?: string;
32
38
  /** The commit it runs, when its state says. */
33
39
  revision?: string;
34
40
  healthy: boolean;
35
41
  }
36
42
 
43
+ /** Where a wakeup, and a check of the host's own process, reaches it: its `wakeUrl`, else its `url`. */
44
+ export const wakeUrlOf = (outputs: HostOutputs): string => outputs.wakeUrl ?? outputs.url;
45
+
37
46
  /**
38
47
  * A resource type's `host`: the fields it takes the runtime's inputs in, by dotted path into its
39
48
  * declared fields, and how its state reads as `HostOutputs`. A host is drifted when
@@ -55,6 +64,12 @@ export interface HostSpec {
55
64
  port: string;
56
65
  /** A public site's domain, by site name: the app serves each site at the root of its domain (`SANOMA_DOMAINS`). */
57
66
  domains?: string;
67
+ /**
68
+ * The hostname the app is served at. A host with one reports `url` as `https://<hostname>` (its
69
+ * vendor's URL otherwise), so the links made from it use the stable name, and its vendor's URL
70
+ * as `wakeUrl`, where its wakeup reaches it. It is never one of `domains`' values.
71
+ */
72
+ hostname?: string;
58
73
  };
59
74
  /**
60
75
  * The field apply fills with the commit `ref` resolves to, before it plans: hidden, as a data
package/src/index.ts CHANGED
@@ -67,6 +67,7 @@ export {
67
67
  defineConfig,
68
68
  envWakeup,
69
69
  type Login,
70
+ readRootSnapshot,
70
71
  resolveConfig,
71
72
  resolveDatabaseUrl,
72
73
  type ResolvedConfig,
@@ -203,6 +204,7 @@ export {
203
204
  type SourceHost,
204
205
  } from "./source.ts";
205
206
  export type { HostCapabilities, HostLifetime, HostOutputs, HostSpec } from "./host.ts";
207
+ export { wakeUrlOf } from "./host.ts";
206
208
  export {
207
209
  checkDatabase,
208
210
  type DatabaseCapabilities,
@@ -241,6 +243,11 @@ export {
241
243
  } from "./client.ts";
242
244
  // A raw DBOS client on the runtime's database, for tests and tools that read DBOS directly.
243
245
  export { dbosClient } from "./db.ts";
246
+ // The runtime bundled where a company's files are not (the Cloudflare host's Worker): the root's
247
+ // people, policies and app name as data, Cedar's web build, and a database it only checks.
248
+ export { type RootSnapshot, useRootSnapshot } from "./bundled.ts";
249
+ export { checkPreparedOnly } from "./db.ts";
250
+ export { type CedarWasm, useCedarWasm } from "./schema.ts";
244
251
  // Also at `@sanoma-ai/workflows/shared`, which a browser bundle can import.
245
252
  export {
246
253
  AGENTS,
package/src/load.ts CHANGED
@@ -89,7 +89,8 @@ const registry = ((globalThis as Record<symbol, unknown>)[Symbol.for("sanoma.loa
89
89
  made: new WeakMap(),
90
90
  }) as Registry;
91
91
 
92
- const require = createRequire(import.meta.url);
92
+ /** This module's `require`, made on first use: a bundle with no file URL (the Cloudflare host's Worker) never loads a file. */
93
+ let require: NodeJS.Require | undefined;
93
94
 
94
95
  /** Where `value` was made, when a company file made it with a constructor the loader served. */
95
96
  export const madeOf = (value: unknown): Made | undefined =>
@@ -205,7 +206,9 @@ function loadOne(gen: Generation, file: string, rel: string): Loaded {
205
206
  if (done) return done;
206
207
  let loaded: Loaded;
207
208
  try {
208
- const { ns } = require(`${SCHEME}${gen.id}/${pathToFileURL(file).href}`) as { ns: Record<string, unknown> };
209
+ const { ns } = (require ??= createRequire(import.meta.url))(`${SCHEME}${gen.id}/${pathToFileURL(file).href}`) as {
210
+ ns: Record<string, unknown>;
211
+ };
209
212
  loaded = { exports: Object.entries(ns) };
210
213
  } catch (error) {
211
214
  // A file that imports one that threw throws the same object: reported once, where it was thrown.
package/src/resources.ts CHANGED
@@ -9,6 +9,7 @@ import { APPS_DIR, COLLECTIONS_FILE, filesUnder, kindDirs, relPath } from "./pat
9
9
  import { peopleConnector, sanoma } from "./person.ts";
10
10
  import { wakeupOps } from "./wakeup-type.ts";
11
11
  import { DECLARED, type Declared, isDeclared, isObject, type Resource, resourceTypesOf } from "./resource.ts";
12
+ import { rootSnapshot } from "./bundled.ts";
12
13
  import { Principal } from "./define.ts";
13
14
  import { NotAdmittedError } from "./errors.ts";
14
15
  import {
@@ -89,10 +90,12 @@ export const peopleSources = (root: string): ReadonlySet<string> => filesRead(pe
89
90
  * Only the files that may declare them (`mayDeclarePeople`) are loaded, with the people's and the
90
91
  * wakeup's constructors, which the same `sanoma` declares; loaded again only when one of them
91
92
  * changed (by its size and time), so a new colleague is admitted once their file is saved, and a
92
- * request costs a `stat` per file.
93
+ * request costs a `stat` per file. A root with a bundled snapshot (`useRootSnapshot`) answers its people.
93
94
  */
94
95
  export function readPeople(root: string | undefined): DeclaredPeople {
95
96
  if (root === undefined) return { people: [], problems: [] };
97
+ const bundled = rootSnapshot(root);
98
+ if (bundled) return bundled.people;
96
99
  const loaded = peopleLoad(root);
97
100
  const cached = peopleRead.get(loaded);
98
101
  if (cached) return cached;
package/src/schema.ts CHANGED
@@ -19,11 +19,24 @@ import { CHANGE_MERGE, DATA_VENDOR, EFFECTS, groupNamesOf, type PeopleResource,
19
19
  // resolved config and the people, in memory for the evaluator, and written as text to
20
20
  // `.sanoma/schema.cedarschema` for the policies' author.
21
21
 
22
- const require = createRequire(import.meta.url);
23
- let wasm: typeof import("@cedar-policy/cedar-wasm/nodejs") | undefined;
22
+ /** Cedar's bindings: its builds (`/nodejs`, `/web`) export the same functions. */
23
+ export type CedarWasm = typeof import("@cedar-policy/cedar-wasm/nodejs");
24
+ let wasm: CedarWasm | undefined;
24
25
 
25
- /** `@cedar-policy/cedar-wasm`'s Node build, loaded (synchronously, once) on first use, so a process that never decides with Cedar never loads it. */
26
- export const cedarWasm = () => (wasm ??= require("@cedar-policy/cedar-wasm/nodejs") as typeof wasm)!;
26
+ /**
27
+ * `@cedar-policy/cedar-wasm`'s Node build, loaded (synchronously, once) on first use, so a process
28
+ * that never decides with Cedar never loads it; or the build `useCedarWasm` gave.
29
+ */
30
+ export const cedarWasm = (): CedarWasm =>
31
+ (wasm ??= createRequire(import.meta.url)("@cedar-policy/cedar-wasm/nodejs") as CedarWasm);
32
+
33
+ /**
34
+ * From now on, Cedar is `bindings`: its web build, initialised, where the Node build cannot load
35
+ * (the Cloudflare host's Worker, which bundles the runtime).
36
+ */
37
+ export function useCedarWasm(bindings: CedarWasm): void {
38
+ wasm = bindings;
39
+ }
27
40
 
28
41
  /** The schema as Cedar's JSON format has it. */
29
42
  export type CedarSchemaJson = SchemaJson<string>;
package/src/shared.ts CHANGED
@@ -141,6 +141,33 @@ export const domainsText = (domains: Readonly<Record<string, string>>): string =
141
141
  .map((site) => `${site}=${domains[site]}`)
142
142
  .join(",");
143
143
 
144
+ /**
145
+ * Where a hosted container answers, with its instance token (`SANOMA_WAKEUP_TOKEN`), the bundle
146
+ * of its public sites that a host's Worker serves: the Cloudflare host downloads it at a deploy.
147
+ */
148
+ export const SITES_BUNDLE_PATH = "/__sanoma/sites-bundle";
149
+ /** The header every answer of that route carries: a runtime without the route answers without it. */
150
+ export const SITES_BUNDLE_HEADER = "x-sanoma-sites-bundle";
151
+
152
+ /** What that route answers: what a host's Worker runs a company's public sites from (`buildWorker` in `@sanoma-ai/app/build`). */
153
+ export interface SitesBundle {
154
+ /** The sites it serves, by key: public, with a domain and no login. */
155
+ sites: string[];
156
+ /** The main module, among `modules`. */
157
+ mainModule: string;
158
+ /** The Worker's modules, by name relative to the main module, each base64. */
159
+ modules: { name: string; type: "esm" | "wasm" | "text" | "data"; content: string }[];
160
+ /** The Worker's static assets, by path from `/`, each base64: the client build and the prerendered pages. */
161
+ assets: { path: string; content: string }[];
162
+ }
163
+
164
+ /** Where a host with a `hostname` serves the app (`HostSpec`'s `hostname`): `https://<hostname>`. */
165
+ export const hostnameUrl = (hostname: string): string => `https://${hostname}`;
166
+
167
+ /** The site whose domain is also the app's `hostname`, compared in lower case; undefined when none is. */
168
+ export const siteAtHostname = (hostname: string, domains: Readonly<Record<string, unknown>>): string | undefined =>
169
+ Object.entries(domains).find(([, domain]) => typeof domain === "string" && same(domain, hostname))?.[0];
170
+
144
171
  /** The domains `domainsText` wrote, by site name; throws for a pair that is not `<site>=<hostname>`, or a site named twice. */
145
172
  export function domainsOf(text: string): Record<string, string> {
146
173
  const domains: Record<string, string> = {};
@@ -469,7 +496,7 @@ export function approverOf(name: string, resources: readonly PeopleResource[]):
469
496
  return principalOf(name, resources) && name;
470
497
  }
471
498
 
472
- /** True for two strings the same but for case: an email or a GitHub login. */
499
+ /** True for two strings the same but for case: an email, a GitHub login or a hostname. */
473
500
  const same = (a: string | undefined, b: string | undefined) =>
474
501
  a !== undefined && b !== undefined && a.toLowerCase() === b.toLowerCase();
475
502
 
@@ -49,7 +49,9 @@ export const wakeup = defineResource({
49
49
  immutable: [],
50
50
  vendorOwned: ["id", "at", "lastError"],
51
51
  writeOnly: [],
52
- references: { host: { type: HOSTS, by: ["url"] } },
52
+ // Where each host's wakeup reaches it, as apply gives it (`wakeUrlOf`): a Cloudflare host's
53
+ // `worker_url` once its `url` is its hostname, else its `url`.
54
+ references: { host: { type: HOSTS, by: ["worker_url", "url"] } },
53
55
  },
54
56
  writable: true,
55
57
  // The service holds its registered wake: apply asks before it deletes one.