@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.
- package/README.md +32 -30
- package/dist/apply-steps.d.ts +4 -2
- package/dist/apply-steps.js +5 -3
- package/dist/apply-steps.js.map +1 -1
- package/dist/apply.d.ts +6 -1
- package/dist/apply.js +55 -20
- package/dist/apply.js.map +1 -1
- package/dist/bundled.d.ts +29 -0
- package/dist/bundled.js +16 -0
- package/dist/bundled.js.map +1 -0
- package/dist/cedar.d.ts +4 -1
- package/dist/cedar.js +8 -1
- package/dist/cedar.js.map +1 -1
- package/dist/config.d.ts +12 -1
- package/dist/config.js +75 -9
- package/dist/config.js.map +1 -1
- package/dist/constructors.d.ts +1 -1
- package/dist/db.d.ts +7 -0
- package/dist/db.js +30 -1
- package/dist/db.js.map +1 -1
- package/dist/define.d.ts +2 -1
- package/dist/define.js +4 -2
- package/dist/define.js.map +1 -1
- package/dist/definitions.d.ts +5 -0
- package/dist/definitions.js.map +1 -1
- package/dist/describe.d.ts +1 -1
- package/dist/describe.js +16 -11
- package/dist/describe.js.map +1 -1
- package/dist/drift.d.ts +7 -0
- package/dist/drift.js +15 -3
- package/dist/drift.js.map +1 -1
- package/dist/host-derive.d.ts +145 -0
- package/dist/host-derive.js +403 -0
- package/dist/host-derive.js.map +1 -0
- package/dist/host.d.ts +70 -5
- package/dist/host.js +56 -1
- package/dist/host.js.map +1 -1
- package/dist/index.d.ts +9 -5
- package/dist/index.js +11 -5
- package/dist/index.js.map +1 -1
- package/dist/load.js +3 -2
- package/dist/load.js.map +1 -1
- package/dist/resource.d.ts +8 -1
- package/dist/resource.js +11 -0
- package/dist/resource.js.map +1 -1
- package/dist/resources.d.ts +1 -1
- package/dist/resources.js +6 -2
- package/dist/resources.js.map +1 -1
- package/dist/schema.d.ts +12 -2
- package/dist/schema.js +12 -6
- package/dist/schema.js.map +1 -1
- package/dist/shared.d.ts +25 -0
- package/dist/shared.js +7 -0
- package/dist/shared.js.map +1 -1
- package/dist/sites.d.ts +6 -0
- package/dist/sites.js +28 -4
- package/dist/sites.js.map +1 -1
- package/dist/source.d.ts +12 -5
- package/dist/source.js +32 -13
- package/dist/source.js.map +1 -1
- package/dist/wakeup-type.d.ts +1 -1
- package/dist/wakeup-type.js +1 -1
- package/dist/wakeup-type.js.map +1 -1
- package/package.json +1 -1
- package/src/apply-steps.ts +5 -4
- package/src/apply.ts +87 -24
- package/src/bundled.ts +42 -0
- package/src/cedar.ts +7 -1
- package/src/config.ts +81 -10
- package/src/db.ts +35 -1
- package/src/define.ts +4 -2
- package/src/definitions.ts +5 -0
- package/src/describe.ts +23 -10
- package/src/drift.ts +16 -3
- package/src/host-derive.ts +492 -0
- package/src/host.ts +101 -5
- package/src/index.ts +26 -2
- package/src/load.ts +5 -2
- package/src/resource.ts +33 -2
- package/src/resources.ts +5 -2
- package/src/schema.ts +17 -4
- package/src/shared.ts +20 -0
- package/src/sites.ts +33 -3
- package/src/source.ts +35 -12
- 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
|
|
65
|
-
/**
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
23
|
-
|
|
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
|
-
/**
|
|
26
|
-
|
|
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
|
-
|
|
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:
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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: {
|
|
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
|
-
/**
|
|
173
|
-
|
|
174
|
-
|
|
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");
|
package/src/wakeup-type.ts
CHANGED
|
@@ -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({
|