@sanoma-ai/workflows 0.11.2 → 0.11.4

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 (85) hide show
  1. package/README.md +32 -30
  2. package/dist/apply-steps.d.ts +4 -2
  3. package/dist/apply-steps.js +5 -3
  4. package/dist/apply-steps.js.map +1 -1
  5. package/dist/apply.d.ts +6 -1
  6. package/dist/apply.js +55 -20
  7. package/dist/apply.js.map +1 -1
  8. package/dist/bundled.d.ts +29 -0
  9. package/dist/bundled.js +16 -0
  10. package/dist/bundled.js.map +1 -0
  11. package/dist/cedar.d.ts +4 -1
  12. package/dist/cedar.js +8 -1
  13. package/dist/cedar.js.map +1 -1
  14. package/dist/config.d.ts +12 -1
  15. package/dist/config.js +75 -9
  16. package/dist/config.js.map +1 -1
  17. package/dist/constructors.d.ts +1 -1
  18. package/dist/db.d.ts +7 -0
  19. package/dist/db.js +30 -1
  20. package/dist/db.js.map +1 -1
  21. package/dist/define.d.ts +2 -1
  22. package/dist/define.js +4 -2
  23. package/dist/define.js.map +1 -1
  24. package/dist/definitions.d.ts +5 -0
  25. package/dist/definitions.js.map +1 -1
  26. package/dist/describe.d.ts +1 -1
  27. package/dist/describe.js +16 -11
  28. package/dist/describe.js.map +1 -1
  29. package/dist/drift.d.ts +7 -0
  30. package/dist/drift.js +15 -3
  31. package/dist/drift.js.map +1 -1
  32. package/dist/host-derive.d.ts +145 -0
  33. package/dist/host-derive.js +403 -0
  34. package/dist/host-derive.js.map +1 -0
  35. package/dist/host.d.ts +70 -5
  36. package/dist/host.js +56 -1
  37. package/dist/host.js.map +1 -1
  38. package/dist/index.d.ts +9 -5
  39. package/dist/index.js +11 -5
  40. package/dist/index.js.map +1 -1
  41. package/dist/load.js +3 -2
  42. package/dist/load.js.map +1 -1
  43. package/dist/resource.d.ts +8 -1
  44. package/dist/resource.js +11 -0
  45. package/dist/resource.js.map +1 -1
  46. package/dist/resources.d.ts +1 -1
  47. package/dist/resources.js +6 -2
  48. package/dist/resources.js.map +1 -1
  49. package/dist/schema.d.ts +12 -2
  50. package/dist/schema.js +12 -6
  51. package/dist/schema.js.map +1 -1
  52. package/dist/shared.d.ts +25 -0
  53. package/dist/shared.js +7 -0
  54. package/dist/shared.js.map +1 -1
  55. package/dist/sites.d.ts +6 -0
  56. package/dist/sites.js +28 -4
  57. package/dist/sites.js.map +1 -1
  58. package/dist/source.d.ts +12 -5
  59. package/dist/source.js +32 -13
  60. package/dist/source.js.map +1 -1
  61. package/dist/wakeup-type.d.ts +1 -1
  62. package/dist/wakeup-type.js +1 -1
  63. package/dist/wakeup-type.js.map +1 -1
  64. package/package.json +1 -1
  65. package/src/apply-steps.ts +5 -4
  66. package/src/apply.ts +87 -24
  67. package/src/bundled.ts +42 -0
  68. package/src/cedar.ts +7 -1
  69. package/src/config.ts +81 -10
  70. package/src/db.ts +35 -1
  71. package/src/define.ts +4 -2
  72. package/src/definitions.ts +5 -0
  73. package/src/describe.ts +23 -10
  74. package/src/drift.ts +16 -3
  75. package/src/host-derive.ts +492 -0
  76. package/src/host.ts +101 -5
  77. package/src/index.ts +26 -2
  78. package/src/load.ts +5 -2
  79. package/src/resource.ts +33 -2
  80. package/src/resources.ts +5 -2
  81. package/src/schema.ts +17 -4
  82. package/src/shared.ts +20 -0
  83. package/src/sites.ts +33 -3
  84. package/src/source.ts +35 -12
  85. package/src/wakeup-type.ts +1 -1
package/src/host.ts CHANGED
@@ -47,6 +47,15 @@ export const wakeUrlOf = (outputs: HostOutputs): string => outputs.wakeUrl ?? ou
47
47
  * A resource type's `host`: the fields it takes the runtime's inputs in, by dotted path into its
48
48
  * declared fields, and how its state reads as `HostOutputs`. A host is drifted when
49
49
  * `outputs(state).revision` differs from what `sanoma.source.resolve` says `ref` is now.
50
+ *
51
+ * A host that `derives` (`cloudflare.host`, `cloud.host`) is declared with the decisions only: its
52
+ * name, its `hostname` and the `database` it runs on. Apply fills the rest before it plans
53
+ * (`deriveHosts`): `image` from the installed `sanoma`, `source` and `ref` from the repository the
54
+ * app runs from and its default branch (each declarable as an override), `secrets` (markers only,
55
+ * never a value) and `domains` from the decisions, the config and the sites, which a data file may
56
+ * not declare; the port is the image's (`RUNTIME_PORT`). One that does not derive is declared
57
+ * whole, its `secrets` and `port` named in its data file (`docker.container`, the deprecated
58
+ * `cloudflare.container`).
50
59
  */
51
60
  export interface HostSpec {
52
61
  capabilities: HostCapabilities;
@@ -57,12 +66,16 @@ export interface HostSpec {
57
66
  ref: string;
58
67
  /**
59
68
  * The secrets it passes the runtime (`SANOMA_DATABASE_URL`, `SANOMA_SECRETS_KEY`): a write-only
60
- * field. Absent for a host that binds its own bootstrap (`bindsBootstrap`).
69
+ * field. Absent for a host that binds its own bootstrap (`bindsBootstrap`). A host that derives
70
+ * has it filled by apply, never declared.
61
71
  */
62
72
  secrets?: string;
63
- /** The port the app listens on. */
64
- port: string;
65
- /** A public site's domain, by site name: the app serves each site at the root of its domain (`SANOMA_DOMAINS`). */
73
+ /** The port the app listens on, for a host that does not derive: one that does runs the image's, `RUNTIME_PORT`. */
74
+ port?: string;
75
+ /**
76
+ * A public site's domain, by site name: the app serves each site at the root of its domain
77
+ * (`SANOMA_DOMAINS`). A host that derives has it filled from the sites' `domain`, never declared.
78
+ */
66
79
  domains?: string;
67
80
  /**
68
81
  * The hostname the app is served at. A host with one reports `url` as `https://<hostname>` (its
@@ -70,6 +83,22 @@ export interface HostSpec {
70
83
  * as `wakeUrl`, where its wakeup reaches it. It is never one of `domains`' values.
71
84
  */
72
85
  hostname?: string;
86
+ /**
87
+ * The runtime image, for a host that derives: the installed `sanoma`'s (`runtimeImage`) when a
88
+ * data file leaves it out, an override otherwise.
89
+ */
90
+ image?: string;
91
+ /**
92
+ * A reference to the declared database the host runs the company on (`database: store`), for a
93
+ * host that derives: apply gives the host that database's URL, as `SANOMA_DATABASE_URL`.
94
+ */
95
+ database?: string;
96
+ /**
97
+ * The host's name, for a host that derives: the repository's name (`owner/<name>`, made a
98
+ * name of lower-case letters, digits and dashes) when a data file leaves it out. Its type's
99
+ * `find` gives `UNNAMED` until apply fills it.
100
+ */
101
+ name?: string;
73
102
  };
74
103
  /**
75
104
  * The field apply fills with the commit `ref` resolves to, before it plans: hidden, as a data
@@ -81,9 +110,26 @@ export interface HostSpec {
81
110
  * tokens) and binds it itself, as Sanoma Cloud's does: such a type takes no `secrets`.
82
111
  */
83
112
  bindsBootstrap?: boolean;
113
+ /**
114
+ * True for a host whose declaration says only the decisions (see above): apply derives its
115
+ * image, source, ref, secrets and domains, and a data file may not declare `secrets` or `domains`.
116
+ */
117
+ derives?: boolean;
84
118
  outputs: (state: Record<string, unknown>) => HostOutputs;
85
119
  }
86
120
 
121
+ /** The runtime image a host runs, tagged with the installed `sanoma`'s version: GitHub's registry. */
122
+ export const RUNTIME_IMAGE = "ghcr.io/sanoma-ai/runtime";
123
+
124
+ /** The runtime image of `sanoma` at `version`: `ghcr.io/sanoma-ai/runtime:<version>`. */
125
+ export const runtimeImage = (version: string): string => `${RUNTIME_IMAGE}:${version}`;
126
+
127
+ /** A deriving host's identity before apply names it after its repository (`HostSpec.inputs.name`): never sent to a vendor. */
128
+ export const UNNAMED = "(the repository's name)";
129
+
130
+ /** The port the runtime image's app listens on (its `PORT`): a host that derives runs it there. */
131
+ export const RUNTIME_PORT = 3000;
132
+
87
133
  const LIFETIMES: readonly HostLifetime[] = ["long-lived", "stops-when-quiet"];
88
134
 
89
135
  /** True when `schema` has a field at the dotted `path`, looking through optional, nullable and default wrappers. */
@@ -108,7 +154,9 @@ function unwrap(schema: z.ZodType): z.ZodType {
108
154
  * Throws what is wrong with a resource type's `host`: an input or `revision` that names no field,
109
155
  * `secrets` that is not write-only, missing (unless the host binds its own bootstrap) or given
110
156
  * beside `bindsBootstrap`, a `revision` that is write-only, an unknown lifetime, a type apply
111
- * cannot change (not `writable`).
157
+ * cannot change (not `writable`); for a host that does not derive, no `port`, or an `image`, a
158
+ * `database` or a `name` (inputs of a host that derives); for one that does, a `port` (it runs the image's),
159
+ * or a `database` that is not write-only (it stands for a URL, and is never compared).
112
160
  */
113
161
  export function checkHost(
114
162
  what: string,
@@ -131,7 +179,55 @@ export function checkHost(
131
179
  } else if (secrets === undefined || !type.fields.writeOnly.includes(secrets)) {
132
180
  problems.push("inputs.secrets must be a write-only field, unless the host binds its own bootstrap");
133
181
  }
182
+ if (host.derives) {
183
+ if (host.inputs?.port !== undefined)
184
+ problems.push("inputs.port is not taken by a host that derives: it runs the image's");
185
+ const database = host.inputs?.database;
186
+ if (database !== undefined && !type.fields.writeOnly.includes(database)) {
187
+ problems.push("inputs.database must be a write-only field: it stands for the database's URL, never compared");
188
+ }
189
+ } else {
190
+ if (host.inputs?.port === undefined) problems.push("inputs.port names the port, unless the host derives");
191
+ for (const input of ["image", "database", "name"] as const) {
192
+ if (host.inputs?.[input] !== undefined) problems.push(`inputs.${input} is taken by a host that derives only`);
193
+ }
194
+ }
134
195
  if (type.fields.writeOnly.includes(host.revision)) problems.push("revision must not be write-only: drift reads it");
135
196
  if (typeof host.outputs !== "function") problems.push("outputs must be a function of the state");
136
197
  if (problems.length) throw new Error(`${what}: its host: ${problems.join("; ")}`);
137
198
  }
199
+
200
+ /** The id apply gives the deploy key it declares for the host `hostId`: `resources/sanoma/infra.ts#host.sourceKey`. */
201
+ export const sourceKeyId = (hostId: string) => `${hostId}.sourceKey`;
202
+
203
+ /**
204
+ * The lockfile keys that are a deriving host's deploy key (`sourceKeyId`) among `resources`: never
205
+ * an orphan, whether or not apply declares the key this read (before its source key is stored, or
206
+ * where the store does not hold it), so apply never deletes the key a host clones with.
207
+ */
208
+ export function sourceKeyIds(
209
+ resources: readonly { id: string; vendor: string; type: string }[],
210
+ types: ReadonlyMap<string, { host?: HostSpec }>,
211
+ ): Set<string> {
212
+ return new Set(
213
+ resources.filter((r) => types.get(`${r.vendor}.${r.type}`)?.host?.derives).map((r) => sourceKeyId(r.id)),
214
+ );
215
+ }
216
+
217
+ /**
218
+ * For a host that derives its domains (`inputs.domains`) from the sites: the domains its state
219
+ * says are attached, `<domain> (<site>)`, when no site declares one, which a change would detach
220
+ * (a data file of before declared them on the host). Empty otherwise: once a site declares a
221
+ * domain, the declared ones are attached and the dropped ones detached.
222
+ */
223
+ export function detaching(host: HostSpec, desired: Record<string, unknown>, state: Record<string, unknown>): string[] {
224
+ const path = host.inputs.domains;
225
+ if (!host.derives || path === undefined) return [];
226
+ const at = (value: Record<string, unknown>) =>
227
+ path.split(".").reduce<unknown>((o, key) => (o as Record<string, unknown> | null | undefined)?.[key], value);
228
+ const declared = at(desired);
229
+ if (declared && typeof declared === "object" && Object.keys(declared).length) return [];
230
+ const attached = at(state);
231
+ if (!attached || typeof attached !== "object") return [];
232
+ return Object.entries(attached as Record<string, unknown>).map(([site, domain]) => `${String(domain)} (${site})`);
233
+ }
package/src/index.ts CHANGED
@@ -59,6 +59,7 @@ export {
59
59
  type ResourceFields,
60
60
  type ResourceOps,
61
61
  type ResourceSpec,
62
+ valueAt,
62
63
  withoutWriteOnly,
63
64
  } from "./resource.ts";
64
65
  export { type DnsRecord, dnsRecord } from "./dns.ts";
@@ -67,6 +68,7 @@ export {
67
68
  defineConfig,
68
69
  envWakeup,
69
70
  type Login,
71
+ readRootSnapshot,
70
72
  resolveConfig,
71
73
  resolveDatabaseUrl,
72
74
  type ResolvedConfig,
@@ -189,6 +191,7 @@ export {
189
191
  type Draft,
190
192
  draftBranch,
191
193
  type DraftDiff,
194
+ defaultBranch,
192
195
  draftOf,
193
196
  type HostEnforcement,
194
197
  type PullRequest,
@@ -203,7 +206,23 @@ export {
203
206
  type SourceHost,
204
207
  } from "./source.ts";
205
208
  export type { HostCapabilities, HostLifetime, HostOutputs, HostSpec } from "./host.ts";
206
- export { wakeUrlOf } from "./host.ts";
209
+ export { RUNTIME_IMAGE, RUNTIME_PORT, runtimeImage, sourceKeyId, UNNAMED, wakeUrlOf } from "./host.ts";
210
+ export {
211
+ cloneUrl,
212
+ deriveHosts,
213
+ type HostFacts,
214
+ hostFacts,
215
+ DEPLOY_KEY_TITLE,
216
+ DEPLOY_KEY_TYPE,
217
+ GITHUB_APP,
218
+ githubRepository,
219
+ HOST_SIGNING_KEY,
220
+ installedSanoma,
221
+ originUrl,
222
+ SOURCE_KEY,
223
+ sourceRepository,
224
+ wokenBy,
225
+ } from "./host-derive.ts";
207
226
  export {
208
227
  checkDatabase,
209
228
  type DatabaseCapabilities,
@@ -242,6 +261,11 @@ export {
242
261
  } from "./client.ts";
243
262
  // A raw DBOS client on the runtime's database, for tests and tools that read DBOS directly.
244
263
  export { dbosClient } from "./db.ts";
264
+ // The runtime bundled where a company's files are not (the Cloudflare host's Worker): the root's
265
+ // people, policies and app name as data, Cedar's web build, and a database it only checks.
266
+ export { type RootSnapshot, useRootSnapshot } from "./bundled.ts";
267
+ export { checkPreparedOnly } from "./db.ts";
268
+ export { type CedarWasm, useCedarWasm } from "./schema.ts";
245
269
  // Also at `@sanoma-ai/workflows/shared`, which a browser bundle can import.
246
270
  export {
247
271
  AGENTS,
@@ -320,7 +344,7 @@ export {
320
344
  export { checkSecretStore, type SecretStoreCheck } from "./secret-store-check.ts";
321
345
 
322
346
  export { postgresSecretStore } from "./credentials.ts";
323
- export { deploymentKey, signingKeyFor } from "./deployment-key.ts";
347
+ export { deploymentKey, keyBytes, signingKeyFor } from "./deployment-key.ts";
324
348
 
325
349
  export {
326
350
  defineConnection,
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/resource.ts CHANGED
@@ -131,6 +131,13 @@ export interface ResourceSpec<S extends z.ZodObject = z.ZodObject> {
131
131
  * Supabase project's health, until it is healthy, then its database URL stored.
132
132
  */
133
133
  afterCreate?: readonly AfterCreate[];
134
+ /**
135
+ * The ids (`<vendor>.<type>`) this type was known by before a rename: a lockfile binding under
136
+ * one of them binds a declaration of this type to the same object, so a rename recreates nothing
137
+ * (`cloudflare.host`, formerly `cloudflare.container`). Apply writes the new id with its next
138
+ * change to that binding.
139
+ */
140
+ formerly?: readonly string[];
134
141
  }
135
142
 
136
143
  /** One operation apply calls after a create (`ResourceSpec.afterCreate`). */
@@ -352,11 +359,20 @@ export interface Resource<
352
359
  Required<
353
360
  Omit<
354
361
  ResourceSpec<S>,
355
- "identity" | "find" | "writable" | "host" | "wakeup" | "database" | "stateless" | "afterCreate" | "vendorless"
362
+ | "identity"
363
+ | "find"
364
+ | "writable"
365
+ | "host"
366
+ | "wakeup"
367
+ | "database"
368
+ | "stateless"
369
+ | "afterCreate"
370
+ | "vendorless"
371
+ | "formerly"
356
372
  >
357
373
  >
358
374
  >,
359
- Readonly<Pick<ResourceSpec<S>, "identity" | "find" | "host" | "wakeup" | "database" | "afterCreate">> {
375
+ Readonly<Pick<ResourceSpec<S>, "identity" | "find" | "host" | "wakeup" | "database" | "afterCreate" | "formerly">> {
360
376
  (
361
377
  desired: Declaring<z.input<S>, R>,
362
378
  options?: { connection?: ConnectionRef<Id extends `${infer V}.${string}` ? V : string> },
@@ -836,6 +852,20 @@ export function defineResource<
836
852
  if (host && pathsIn(desired, new Set([host.revision])).length) {
837
853
  throw new Error(`${what}: leave out ${host.revision}: apply sets it from ${host.inputs.ref}`);
838
854
  }
855
+ // A host that derives is given its secrets and its sites' domains: a data file names neither.
856
+ if (host?.derives) {
857
+ const { secrets, domains } = host.inputs;
858
+ if (secrets !== undefined && pathsIn(desired, new Set([secrets])).length) {
859
+ throw new Error(
860
+ `${what}: leave out ${secrets}: apply gives the host its database's URL, its keys and the connections it deploys with, and no secret is written in a file`,
861
+ );
862
+ }
863
+ if (domains !== undefined && pathsIn(desired, new Set([domains])).length) {
864
+ throw new Error(
865
+ `${what}: leave out ${domains}: a public site's domain is declared on the site, \`defineSite({ public: true, domain })\`, and the host attaches it`,
866
+ );
867
+ }
868
+ }
839
869
  const vendors = pathsIn(desired, owned);
840
870
  if (vendors.length) {
841
871
  throw new Error(
@@ -874,6 +904,7 @@ export function defineResource<
874
904
  stateless: spec.stateless ?? host !== undefined,
875
905
  vendorless: spec.vendorless ?? false,
876
906
  ...(spec.afterCreate && { afterCreate: spec.afterCreate }),
907
+ ...(spec.formerly?.length && { formerly: Object.freeze([...spec.formerly]) }),
877
908
  ops,
878
909
  // A symbol, so `defineConnector` tells a resource type from a group of operations.
879
910
  [RESOURCE]: true as const,
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;
@@ -356,7 +359,7 @@ function declarationsOf(
356
359
  const why =
357
360
  value.connection !== undefined && binding.connection?.$connection !== value.connection.$connection
358
361
  ? `${id} changes the connection of a bound resource: explicitly unbind it before selecting another account`
359
- : binding.type !== value[DECLARED]
362
+ : binding.type !== value[DECLARED] && !types.get(value[DECLARED])?.formerly?.includes(binding.type)
360
363
  ? `${id} is bound to a ${binding.type} in ${LOCKFILE}, but declares a ${value[DECLARED]}`
361
364
  : first !== undefined
362
365
  ? `${id} is bound to the same ${object} as ${first} in ${LOCKFILE}: bind each object once`
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,26 @@ 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
+
144
164
  /** Where a host with a `hostname` serves the app (`HostSpec`'s `hostname`): `https://<hostname>`. */
145
165
  export const hostnameUrl = (hostname: string): string => `https://${hostname}`;
146
166
 
package/src/sites.ts CHANGED
@@ -15,6 +15,7 @@ import {
15
15
  apiNameOf,
16
16
  BUILT_IN_WORKFLOWS,
17
17
  DATA_VENDOR,
18
+ DOMAIN_NAME,
18
19
  LOCALE,
19
20
  SITE_NAME,
20
21
  siteKey,
@@ -129,7 +130,9 @@ export function readSites(
129
130
  const read = readSiteFiles(root, dir);
130
131
  const resolved = read.flatMap(({ uses }) => uses.filter((use) => !isAppCollection(use)).map(resolve));
131
132
  const pages = pagesOf(read);
132
- const { problems: optionProblems, ...options } = siteDefinitionOf(root, dir, connectors);
133
+ // Its declared domain is the production host's to attach (`siteDomains`): where the site answers
134
+ // is `domain`, which only the app sets, from the `SANOMA_DOMAINS` that host hands it.
135
+ const { problems: optionProblems, domain: _declared, ...options } = siteDefinitionOf(root, dir, connectors);
133
136
  const calls = resolved.map((r) => r.call);
134
137
  const problems = [
135
138
  ...resolved.flatMap(({ call, problem }) => {
@@ -225,6 +228,21 @@ export function siteIcons(root: string): Record<string, string> {
225
228
  );
226
229
  }
227
230
 
231
+ /**
232
+ * Each public site's domain (`defineSite({ public: true, domain })`) under `root`, by name, for
233
+ * the sites whose `site.ts` gives one without a problem there: what the production host attaches
234
+ * and hands the app as `SANOMA_DOMAINS` (`deriveHosts`). Loaded as `readSites` loads it.
235
+ */
236
+ export function siteDomains(root: string): Record<string, string> {
237
+ const none = new Set<string>();
238
+ return Object.fromEntries(
239
+ sitesUnder(root).flatMap(({ name, dir }) => {
240
+ const { domain } = siteDefinitionOf(root, dir, { vendors: none, logins: none });
241
+ return domain === undefined ? [] : [[name, domain]];
242
+ }),
243
+ );
244
+ }
245
+
228
246
  /**
229
247
  * The sites under `root`, by key (`siteKey`): each folder of `sites/` with a `routes/` whose name
230
248
  * is a URL segment, then each app's `apps/<app>/sites/`, with its folder relative to `root`.
@@ -427,10 +445,14 @@ const SiteDefinitionSchema = z.strictObject({
427
445
  public: z.boolean().optional(),
428
446
  login: z.string().optional(),
429
447
  locales: z.array(z.string()).optional(),
448
+ domain: z
449
+ .string()
450
+ .regex(DOMAIN_NAME, "a host name of two labels or more, lower case, such as `www.acme.example`")
451
+ .optional(),
430
452
  });
431
453
 
432
454
  /** What `site.ts` says of who may see a site: read together or not at all. */
433
- const ACCESS = ["public", "login", "locales"];
455
+ const ACCESS = ["public", "login", "locales", "domain"];
434
456
 
435
457
  /**
436
458
  * What the site in `dir` (relative to `root`) says of itself in its `site.ts`, loaded
@@ -445,6 +467,8 @@ function siteDefinitionOf(
445
467
  dir: string,
446
468
  connectors: SiteConnectors,
447
469
  ): Pick<SiteEntry, "at" | "title" | "icon" | "public" | "login" | "locales"> & {
470
+ /** `defineSite({ domain })`, kept only on a public site. */
471
+ domain?: string;
448
472
  problems: (ResourceProblem & { file: string })[];
449
473
  } {
450
474
  const file = `${dir}/site.ts`;
@@ -474,7 +498,7 @@ function siteDefinitionOf(
474
498
  );
475
499
  if (!parsed.success) return { problems };
476
500
  }
477
- const { title, icon, public: isPublic, login, locales } = parsed.data;
501
+ const { title, icon, public: isPublic, login, locales, domain } = parsed.data;
478
502
  const loginProblem = (): string | undefined => {
479
503
  if (!isPublic) return "a login is for a public site: add `public: true`, or remove `login`";
480
504
  if (!connectors.vendors.has(login!)) return `login names "${login}", which is not a connector in sanoma.config.ts`;
@@ -496,6 +520,11 @@ function siteDefinitionOf(
496
520
  };
497
521
  const localesMessage = locales && localesProblem(locales);
498
522
  if (localesMessage) problems.push(problem(localesMessage));
523
+ const domainMessage =
524
+ domain !== undefined && !isPublic
525
+ ? "a domain is for a public site: add `public: true`, or remove `domain`"
526
+ : undefined;
527
+ if (domainMessage) problems.push(problem(domainMessage));
499
528
  return {
500
529
  at: { file, export: "default" },
501
530
  ...(title !== undefined && { title }),
@@ -503,6 +532,7 @@ function siteDefinitionOf(
503
532
  ...(isPublic && { public: true as const }),
504
533
  ...(login !== undefined && { login }),
505
534
  ...(locales && !localesMessage && { locales }),
535
+ ...(domain !== undefined && !domainMessage && { domain }),
506
536
  problems,
507
537
  };
508
538
  }
package/src/source.ts CHANGED
@@ -130,7 +130,8 @@ export interface CheckResult {
130
130
 
131
131
  /**
132
132
  * The built-in operation `sanoma.source.resolve`: the commit `ref` names in the repository at
133
- * `url` now, by `git ls-remote` (a branch first, then a tag, peeled; a full commit id is itself).
133
+ * `url` now, by `git ls-remote` (a branch first, then a tag, peeled; a full commit id is itself),
134
+ * and the ref; with no `ref`, the repository's default branch (its `HEAD`) and the commit it names.
134
135
  * Apply calls it to fill a host's revision before it plans, and drift to see a merge. `resolveConfig`
135
136
  * registers it when a connector declares a host type.
136
137
  */
@@ -139,10 +140,11 @@ export const sourceOps = defineConnector("sanoma", {
139
140
  resolve: {
140
141
  effect: "read",
141
142
  idempotent: true,
142
- description: "Resolve a branch or tag of a git repository to the commit it names now",
143
- input: z.object({ url: z.string().min(1), ref: z.string().min(1) }),
144
- output: z.object({ revision: z.string() }),
145
- target: ({ url, ref }: { url: string; ref: string }) => `${url}#${ref}`,
143
+ description:
144
+ "Resolve a branch or tag of a git repository, its default branch by default, to the commit it names now",
145
+ input: z.object({ url: z.string().min(1), ref: z.string().min(1).optional() }),
146
+ output: z.object({ revision: z.string(), ref: z.string() }),
147
+ target: ({ url, ref }: { url: string; ref?: string }) => `${url}#${ref ?? "HEAD"}`,
146
148
  },
147
149
  },
148
150
  });
@@ -151,7 +153,10 @@ export const SOURCE_RESOLVE = "sanoma.source.resolve" satisfies typeof sourceOps
151
153
 
152
154
  /** The driver of `sanoma.source.resolve`: git on this machine, with its own credential helpers. */
153
155
  export const sourceDriver = defineDriver(sourceOps, {
154
- source: { resolve: async ({ url, ref }) => ({ revision: await resolveRevision(url, ref) }) },
156
+ source: {
157
+ resolve: async ({ url, ref }) =>
158
+ ref === undefined ? defaultBranch(url) : { revision: await resolveRevision(url, ref), ref },
159
+ },
155
160
  });
156
161
 
157
162
  const COMMIT = /^[0-9a-f]{40}([0-9a-f]{24})?$/;
@@ -159,20 +164,20 @@ const COMMIT = /^[0-9a-f]{40}([0-9a-f]{24})?$/;
159
164
  /**
160
165
  * The fake of `sanoma.source.resolve`, which a sandbox run calls (`sandboxDrivers`): no git and no
161
166
  * network, a full commit id is itself as the driver has it, and any other ref the SHA-1 of
162
- * `<url>#<ref>`, so a scenario's revisions are the same on every run.
167
+ * `<url>#<ref>`, so a scenario's revisions are the same on every run; the default branch is `main`.
163
168
  */
164
169
  export const fakeSourceDriver = defineDriver(sourceOps, {
165
170
  source: {
166
- resolve: async ({ url, ref }) => ({
171
+ resolve: async ({ url, ref = "main" }) => ({
167
172
  revision: COMMIT.test(ref) ? ref : createHash("sha1").update(`${url}#${ref}`).digest("hex"),
173
+ ref,
168
174
  }),
169
175
  },
170
176
  });
171
177
 
172
- /** The commit `ref` (a branch, a tag, `HEAD`, a full ref or a commit id) names in the repository at `url`. */
173
- export async function resolveRevision(url: string, ref: string): Promise<string> {
174
- if (COMMIT.test(ref)) return ref;
175
- const { stdout } = await promisify(execFile)("git", ["ls-remote", "--", url, ref, `${ref}^{}`], {
178
+ /** `git ls-remote` of `patterns` in the repository at `url`, with `options`, never prompting for credentials. */
179
+ async function lsRemote(url: string, patterns: string[], options: string[] = []): Promise<string> {
180
+ const { stdout } = await promisify(execFile)("git", ["ls-remote", ...options, "--", url, ...patterns], {
176
181
  // Never a prompt for credentials: a repository that needs them and has none fails.
177
182
  env: { ...process.env, GIT_TERMINAL_PROMPT: "0" },
178
183
  timeout: 30_000,
@@ -182,6 +187,24 @@ export async function resolveRevision(url: string, ref: string): Promise<string>
182
187
  cause: err,
183
188
  });
184
189
  });
190
+ return stdout;
191
+ }
192
+
193
+ /** The default branch of the repository at `url` (the branch its `HEAD` names) and the commit it names now. */
194
+ export async function defaultBranch(url: string): Promise<{ revision: string; ref: string }> {
195
+ const stdout = await lsRemote(url, ["HEAD"], ["--symref"]);
196
+ const branch = /^ref: refs\/heads\/(\S+)\tHEAD$/m.exec(stdout)?.[1];
197
+ const revision = /^([0-9a-f]{40,64})\tHEAD$/m.exec(stdout)?.[1];
198
+ if (!branch || !revision) {
199
+ throw new DriverError(`${url} names no default branch`, { retryable: false, vendorCode: "no_such_ref" });
200
+ }
201
+ return { revision, ref: branch };
202
+ }
203
+
204
+ /** The commit `ref` (a branch, a tag, `HEAD`, a full ref or a commit id) names in the repository at `url`. */
205
+ export async function resolveRevision(url: string, ref: string): Promise<string> {
206
+ if (COMMIT.test(ref)) return ref;
207
+ const stdout = await lsRemote(url, [ref, `${ref}^{}`]);
185
208
  const refs = new Map(
186
209
  stdout.split("\n").flatMap((line) => {
187
210
  const [sha, name] = line.split("\t");
@@ -15,7 +15,7 @@ import { defineResource } from "./resource.ts";
15
15
  * The host types a wakeup may name: each one a data file declares a wakeup for. A new host type
16
16
  * is added here. `testhost.app` is `@sanoma-ai/testing`'s, for the conformance kits.
17
17
  */
18
- const HOSTS = ["cloudflare.container", "docker.container", "testhost.app"] as const;
18
+ const HOSTS = ["cloudflare.host", "cloudflare.container", "docker.container", "testhost.app"] as const;
19
19
 
20
20
  /** An instance of the wakeup service, as `GET /wakeup/instances/{id}` shows it. */
21
21
  const wakeupSchema = z.object({