@crombolo/server 0.0.0-stage → 0.10.0
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/LICENSE +21 -0
- package/NOTICE +69 -0
- package/package.json +41 -4
- package/src/access.ts +114 -0
- package/src/adapters/assistant.ts +229 -0
- package/src/adapters/blueprints.ts +163 -0
- package/src/adapters/bundles.ts +507 -0
- package/src/adapters/content.ts +783 -0
- package/src/adapters/dictionary.ts +76 -0
- package/src/adapters/documents.ts +637 -0
- package/src/adapters/elements.ts +403 -0
- package/src/adapters/file-system.ts +195 -0
- package/src/adapters/media.ts +347 -0
- package/src/adapters/member-groups.ts +90 -0
- package/src/adapters/members.ts +261 -0
- package/src/adapters/public-access.ts +116 -0
- package/src/adapters/redirects.ts +73 -0
- package/src/adapters/references.ts +67 -0
- package/src/adapters/schema-files.ts +204 -0
- package/src/adapters/schema-proposals.ts +180 -0
- package/src/adapters/template-files.ts +58 -0
- package/src/adapters/template-query.ts +127 -0
- package/src/adapters/users.ts +963 -0
- package/src/assistant.ts +487 -0
- package/src/backup.ts +52 -0
- package/src/bundle-install.ts +417 -0
- package/src/bundle-sections.ts +424 -0
- package/src/bundle-tables.ts +161 -0
- package/src/bundles.ts +609 -0
- package/src/component-snippets.ts +163 -0
- package/src/components.ts +266 -0
- package/src/config.ts +556 -0
- package/src/content-type-udt.ts +256 -0
- package/src/database.ts +148 -0
- package/src/diagnostics.ts +143 -0
- package/src/dictionary-transfer.ts +123 -0
- package/src/domains.ts +384 -0
- package/src/editor-types.ts +108 -0
- package/src/email-http.ts +79 -0
- package/src/email-notice.ts +36 -0
- package/src/email-postmark.ts +78 -0
- package/src/email-resend.ts +69 -0
- package/src/email-ses.ts +307 -0
- package/src/email.ts +402 -0
- package/src/extensions-notice.ts +35 -0
- package/src/file-intake.ts +84 -0
- package/src/form-files.ts +241 -0
- package/src/form-submit.ts +397 -0
- package/src/form-workflows.ts +397 -0
- package/src/forms.ts +170 -0
- package/src/git-github.ts +171 -0
- package/src/git-routes.ts +154 -0
- package/src/git.ts +88 -0
- package/src/imaging.ts +434 -0
- package/src/index.ts +65 -0
- package/src/jobs.ts +140 -0
- package/src/listen.ts +36 -0
- package/src/log-expressions.ts +524 -0
- package/src/log-viewer.ts +190 -0
- package/src/logging.ts +126 -0
- package/src/marketplace.ts +382 -0
- package/src/media-files.ts +276 -0
- package/src/media-store-azure.ts +218 -0
- package/src/media-store-s3.ts +113 -0
- package/src/media-store.ts +248 -0
- package/src/member-auth.ts +384 -0
- package/src/models-builder.ts +106 -0
- package/src/no-nodes.ts +64 -0
- package/src/not-implemented.ts +44 -0
- package/src/oembed.ts +144 -0
- package/src/ports.ts +553 -0
- package/src/redirects.ts +132 -0
- package/src/schema-classify.ts +82 -0
- package/src/schema-store.ts +145 -0
- package/src/schema.ts +388 -0
- package/src/seo.ts +371 -0
- package/src/server-events.ts +131 -0
- package/src/server.ts +1642 -0
- package/src/status.ts +275 -0
- package/src/tar.ts +85 -0
- package/src/udt.ts +168 -0
- package/src/value-schemas.ts +64 -0
- package/src/view-runtime.ts +42 -0
- package/src/zip.ts +152 -0
- package/README.md +0 -3
package/src/domains.ts
ADDED
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `domains.toml`: which hostnames reach which page, and in which culture.
|
|
3
|
+
*
|
|
4
|
+
* Hostnames are the one piece of site metadata that differs in every
|
|
5
|
+
* environment, so they are neither content nor schema. They are a file of their
|
|
6
|
+
* own at the site root — data, not code, so `crombolo domains set` can write it
|
|
7
|
+
* — with `${VAR}` read from the environment at sync time, which is how one
|
|
8
|
+
* committed file serves development, staging and production.
|
|
9
|
+
*
|
|
10
|
+
* The file is the truth. Boot converges the `domain` table onto it, so deleting
|
|
11
|
+
* an entry takes effect, exactly as `syncConfiguredRedirects` does for rules in
|
|
12
|
+
* the config. A site with no `domains.toml` is left alone entirely: nothing to
|
|
13
|
+
* converge onto means the backoffice stays the only way, as it was before.
|
|
14
|
+
*/
|
|
15
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
16
|
+
import { join } from 'node:path'
|
|
17
|
+
import { ObjectTypes } from '@crombolo/core'
|
|
18
|
+
import {
|
|
19
|
+
type Db,
|
|
20
|
+
DocumentRepository,
|
|
21
|
+
DomainRepository,
|
|
22
|
+
describeRefFailure,
|
|
23
|
+
LanguageRepository,
|
|
24
|
+
NodeRepository,
|
|
25
|
+
type NodeRow,
|
|
26
|
+
resolveNodeRef,
|
|
27
|
+
} from '@crombolo/data'
|
|
28
|
+
|
|
29
|
+
export const DOMAINS_FILE = 'domains.toml'
|
|
30
|
+
|
|
31
|
+
/** One hostname bound to one node, as the file declares it. */
|
|
32
|
+
export interface DomainDeclaration {
|
|
33
|
+
/** A content path (`/`, `/fr`) or a node uuid; resolved when the file is applied. */
|
|
34
|
+
node: string
|
|
35
|
+
/** A hostname, optionally with a port and a path, and optionally `${VAR}`. */
|
|
36
|
+
host: string
|
|
37
|
+
/** The culture this hostname serves; absent means the site's default. */
|
|
38
|
+
culture?: string
|
|
39
|
+
/** The culture the node itself falls back to, with or without a hostname. */
|
|
40
|
+
defaultCulture?: string
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface DomainsFileResult {
|
|
44
|
+
declarations: DomainDeclaration[]
|
|
45
|
+
problems: string[]
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const str = (value: unknown): string | undefined =>
|
|
49
|
+
typeof value === 'string' && value.trim() !== '' ? value.trim() : undefined
|
|
50
|
+
|
|
51
|
+
export function parseDomainsFile(source: string): DomainsFileResult {
|
|
52
|
+
const problems: string[] = []
|
|
53
|
+
let raw: Record<string, unknown>
|
|
54
|
+
try {
|
|
55
|
+
raw = Bun.TOML.parse(source) as Record<string, unknown>
|
|
56
|
+
} catch (error) {
|
|
57
|
+
return { declarations: [], problems: [`invalid TOML: ${(error as Error).message}`] }
|
|
58
|
+
}
|
|
59
|
+
const entries = Array.isArray(raw.domain) ? (raw.domain as Record<string, unknown>[]) : []
|
|
60
|
+
if (raw.domain !== undefined && entries.length === 0)
|
|
61
|
+
problems.push('`domain` must be a list of `[[domain]]` entries')
|
|
62
|
+
|
|
63
|
+
const declarations: DomainDeclaration[] = []
|
|
64
|
+
for (const [index, entry] of entries.entries()) {
|
|
65
|
+
const where = `domain[${index}]`
|
|
66
|
+
const node = str(entry.node)
|
|
67
|
+
const host = str(entry.host)
|
|
68
|
+
const culture = str(entry.culture)
|
|
69
|
+
const defaultCulture = str(entry['default-culture'])
|
|
70
|
+
if (!node) {
|
|
71
|
+
problems.push(`${where}: no \`node\``)
|
|
72
|
+
continue
|
|
73
|
+
}
|
|
74
|
+
// An entry may carry only a default culture: a node can vary by culture
|
|
75
|
+
// without a hostname of its own, which is what Umbraco's `*<id>` row means.
|
|
76
|
+
if (!host && !defaultCulture) {
|
|
77
|
+
problems.push(`${where}: needs a \`host\`, a \`default-culture\`, or both`)
|
|
78
|
+
continue
|
|
79
|
+
}
|
|
80
|
+
declarations.push({ node, host: host ?? '', culture, defaultCulture })
|
|
81
|
+
}
|
|
82
|
+
return { declarations, problems }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Model → canonical TOML, so a rewrite by the CLI reads as a hand-written file. */
|
|
86
|
+
export function writeDomainsFile(declarations: readonly DomainDeclaration[]): string {
|
|
87
|
+
const quote = (value: string) => JSON.stringify(value)
|
|
88
|
+
const lines: string[] = [
|
|
89
|
+
'# Which hostnames reach which page, and in which culture.',
|
|
90
|
+
`# \`\${VAR}\` is read from the environment when this is applied, so one file`,
|
|
91
|
+
'# serves every environment. `crombolo domains` prints what is in force.',
|
|
92
|
+
]
|
|
93
|
+
for (const declaration of declarations) {
|
|
94
|
+
lines.push('', '[[domain]]', `node = ${quote(declaration.node)}`)
|
|
95
|
+
if (declaration.host) lines.push(`host = ${quote(declaration.host)}`)
|
|
96
|
+
if (declaration.culture) lines.push(`culture = ${quote(declaration.culture)}`)
|
|
97
|
+
if (declaration.defaultCulture)
|
|
98
|
+
lines.push(`default-culture = ${quote(declaration.defaultCulture)}`)
|
|
99
|
+
}
|
|
100
|
+
return `${lines.join('\n')}\n`
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const PLACEHOLDER = /\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g
|
|
104
|
+
|
|
105
|
+
/** `${VAR}` from the environment; an unset one is reported rather than guessed. */
|
|
106
|
+
export function resolvePlaceholders(
|
|
107
|
+
value: string,
|
|
108
|
+
env: Record<string, string | undefined> = Bun.env,
|
|
109
|
+
): { value: string; missing: string[] } {
|
|
110
|
+
const missing: string[] = []
|
|
111
|
+
const resolved = value.replace(PLACEHOLDER, (_, name: string) => {
|
|
112
|
+
const found = env[name]
|
|
113
|
+
if (found === undefined || found === '') {
|
|
114
|
+
missing.push(name)
|
|
115
|
+
return ''
|
|
116
|
+
}
|
|
117
|
+
return found
|
|
118
|
+
})
|
|
119
|
+
return { value: resolved, missing }
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export interface DomainsSyncReport {
|
|
123
|
+
/** No `domains.toml`: nothing was read and nothing was changed. */
|
|
124
|
+
action: 'absent' | 'applied'
|
|
125
|
+
/** Hostnames now bound, after interpolation. */
|
|
126
|
+
applied: Array<{ node: string; host: string; culture?: string }>
|
|
127
|
+
/** Bindings removed because the file no longer declares them. */
|
|
128
|
+
removed: number
|
|
129
|
+
/** Anything that stopped one entry being applied; the rest still are. */
|
|
130
|
+
problems: string[]
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function domainsFilePath(siteDir: string): string {
|
|
134
|
+
return join(siteDir, DOMAINS_FILE)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** What `domains undo` restores: the file as it was before the last write. */
|
|
138
|
+
export const DOMAINS_BACKUP_FILE = `${DOMAINS_FILE}.bak`
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Writes the file, keeping the one it replaces.
|
|
142
|
+
*
|
|
143
|
+
* A wrong hostname takes a site off the air — once a root page has one, that is
|
|
144
|
+
* the only name it answers on — so every write leaves the previous text beside
|
|
145
|
+
* it and `undoDomainsFile` puts it back. On a production node the backup is as
|
|
146
|
+
* ephemeral as the file itself, which is why the change is worth committing.
|
|
147
|
+
*/
|
|
148
|
+
export async function writeDomains(
|
|
149
|
+
siteDir: string,
|
|
150
|
+
declarations: readonly DomainDeclaration[],
|
|
151
|
+
): Promise<{ file: string; backup: string | undefined }> {
|
|
152
|
+
const file = domainsFilePath(siteDir)
|
|
153
|
+
const backup = join(siteDir, DOMAINS_BACKUP_FILE)
|
|
154
|
+
const kept = existsSync(file) ? readFileSync(file, 'utf8') : undefined
|
|
155
|
+
if (kept !== undefined) await Bun.write(backup, kept)
|
|
156
|
+
await Bun.write(file, writeDomainsFile(declarations))
|
|
157
|
+
return { file, backup: kept === undefined ? undefined : backup }
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Puts back what the last write replaced; the current file becomes the backup. */
|
|
161
|
+
export async function undoDomainsFile(
|
|
162
|
+
siteDir: string,
|
|
163
|
+
): Promise<{ ok: boolean; file: string; message?: string }> {
|
|
164
|
+
const file = domainsFilePath(siteDir)
|
|
165
|
+
const backup = join(siteDir, DOMAINS_BACKUP_FILE)
|
|
166
|
+
if (!existsSync(backup))
|
|
167
|
+
return { ok: false, file, message: `there is no ${DOMAINS_BACKUP_FILE} to go back to` }
|
|
168
|
+
const previous = readFileSync(backup, 'utf8')
|
|
169
|
+
const current = existsSync(file) ? readFileSync(file, 'utf8') : undefined
|
|
170
|
+
await Bun.write(file, previous)
|
|
171
|
+
// So that undo is itself undoable, which is what somebody who has just undone
|
|
172
|
+
// the wrong thing needs.
|
|
173
|
+
await Bun.write(backup, current ?? '')
|
|
174
|
+
return { ok: true, file }
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* What to call a page in the file: whatever it is already called there, so a
|
|
179
|
+
* readable path written by a person stays a path, and its uuid when the file
|
|
180
|
+
* has not mentioned it before.
|
|
181
|
+
*/
|
|
182
|
+
export async function nodeReferenceFor(
|
|
183
|
+
db: Db,
|
|
184
|
+
declarations: readonly DomainDeclaration[],
|
|
185
|
+
node: NodeRow,
|
|
186
|
+
): Promise<string> {
|
|
187
|
+
for (const name of new Set(declarations.map((declaration) => declaration.node))) {
|
|
188
|
+
const resolved = await resolveDomainNode(db, name)
|
|
189
|
+
if (resolved.ok && resolved.node.id === node.id) return name
|
|
190
|
+
}
|
|
191
|
+
return node.key
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Every entry except the ones that belong to this page.
|
|
196
|
+
*
|
|
197
|
+
* Which they are is decided by resolving each entry, not by matching the text:
|
|
198
|
+
* `/` and `/Home` can be the same page, so clearing one by the name the file
|
|
199
|
+
* did not use would otherwise leave it bound.
|
|
200
|
+
*/
|
|
201
|
+
export async function withoutNode(
|
|
202
|
+
db: Db,
|
|
203
|
+
declarations: readonly DomainDeclaration[],
|
|
204
|
+
node: NodeRow,
|
|
205
|
+
): Promise<DomainDeclaration[]> {
|
|
206
|
+
const kept: DomainDeclaration[] = []
|
|
207
|
+
for (const declaration of declarations) {
|
|
208
|
+
const owner = await resolveDomainNode(db, declaration.node)
|
|
209
|
+
if (owner.ok && owner.node.id === node.id) continue
|
|
210
|
+
kept.push(declaration)
|
|
211
|
+
}
|
|
212
|
+
return kept
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Replaces one page's entries, leaving every other entry exactly as written.
|
|
217
|
+
*
|
|
218
|
+
* The file is rewritten from the model, not from the database: regenerating it
|
|
219
|
+
* wholesale would resolve `${VAR}` in other entries into whatever this machine
|
|
220
|
+
* happens to have, baking one environment's hostnames into a file that serves
|
|
221
|
+
* all of them.
|
|
222
|
+
*/
|
|
223
|
+
export function replaceNodeDeclarations(
|
|
224
|
+
existing: readonly DomainDeclaration[],
|
|
225
|
+
node: string,
|
|
226
|
+
hosts: ReadonlyArray<{ host: string; culture?: string }>,
|
|
227
|
+
defaultCulture?: string,
|
|
228
|
+
): DomainDeclaration[] {
|
|
229
|
+
const others = existing.filter((declaration) => declaration.node !== node)
|
|
230
|
+
if (hosts.length === 0 && !defaultCulture) return others
|
|
231
|
+
if (hosts.length === 0) return [...others, { node, host: '', defaultCulture }]
|
|
232
|
+
return [
|
|
233
|
+
...others,
|
|
234
|
+
...hosts.map((entry, index) => ({
|
|
235
|
+
node,
|
|
236
|
+
host: entry.host,
|
|
237
|
+
culture: entry.culture,
|
|
238
|
+
defaultCulture: index === 0 ? defaultCulture : undefined,
|
|
239
|
+
})),
|
|
240
|
+
]
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* The page a declaration names: a path, a uuid, or `/` for the root page.
|
|
245
|
+
*
|
|
246
|
+
* `/` is what a visitor types, and on a site with one root it is unambiguous —
|
|
247
|
+
* but a hostname binds to a *node*, so with several roots it is refused with
|
|
248
|
+
* their names rather than guessed at.
|
|
249
|
+
*/
|
|
250
|
+
export async function resolveDomainNode(
|
|
251
|
+
db: Db,
|
|
252
|
+
ref: string,
|
|
253
|
+
): Promise<{ ok: true; node: NodeRow } | { ok: false; message: string }> {
|
|
254
|
+
if (ref.trim() === '/') {
|
|
255
|
+
const roots = (await new DocumentRepository(db).children(null, 0, 50)).items
|
|
256
|
+
const first = roots[0]
|
|
257
|
+
if (!first) return { ok: false, message: 'this site has no pages at the root' }
|
|
258
|
+
if (roots.length > 1)
|
|
259
|
+
return {
|
|
260
|
+
ok: false,
|
|
261
|
+
message: `"/" matches ${roots.length} root pages (${roots.map((r) => r.text).join(', ')}) — name one`,
|
|
262
|
+
}
|
|
263
|
+
return { ok: true, node: first }
|
|
264
|
+
}
|
|
265
|
+
const resolved = await resolveNodeRef(db, [ObjectTypes.Document], ref)
|
|
266
|
+
return resolved.ok
|
|
267
|
+
? { ok: true, node: resolved.node }
|
|
268
|
+
: { ok: false, message: describeRefFailure(ref, resolved) }
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
export function readDomainsFile(siteDir: string): DomainsFileResult | undefined {
|
|
272
|
+
const file = domainsFilePath(siteDir)
|
|
273
|
+
return existsSync(file) ? parseDomainsFile(readFileSync(file, 'utf8')) : undefined
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Converges the `domain` table onto the file.
|
|
278
|
+
*
|
|
279
|
+
* Entries are grouped by node because an assignment is replaced per node, as
|
|
280
|
+
* the backoffice's dialog replaces it: stating a node's hostnames says what
|
|
281
|
+
* they are. Nodes the file does not name keep nothing — the file is the truth,
|
|
282
|
+
* so a binding it dropped goes — but a site with no file at all is untouched.
|
|
283
|
+
*/
|
|
284
|
+
export async function syncDomainsFile(
|
|
285
|
+
db: Db,
|
|
286
|
+
siteDir: string,
|
|
287
|
+
env: Record<string, string | undefined> = Bun.env,
|
|
288
|
+
): Promise<DomainsSyncReport> {
|
|
289
|
+
const read = readDomainsFile(siteDir)
|
|
290
|
+
if (!read) return { action: 'absent', applied: [], removed: 0, problems: [] }
|
|
291
|
+
|
|
292
|
+
const problems = [...read.problems]
|
|
293
|
+
const applied: DomainsSyncReport['applied'] = []
|
|
294
|
+
const domains = new DomainRepository(db)
|
|
295
|
+
const nodes = new NodeRepository(db)
|
|
296
|
+
// A hostname with no culture of its own serves the site's default one, which
|
|
297
|
+
// is what the dialog means by leaving the language unset — the repository
|
|
298
|
+
// wants an actual language, so it is resolved here rather than sent empty.
|
|
299
|
+
const languages = await new LanguageRepository(db).all()
|
|
300
|
+
const defaultCulture = languages.find((language) => language.isDefault)?.isoCode ?? ''
|
|
301
|
+
|
|
302
|
+
const byNode = new Map<string, DomainDeclaration[]>()
|
|
303
|
+
for (const declaration of read.declarations) {
|
|
304
|
+
const group = byNode.get(declaration.node)
|
|
305
|
+
if (group) group.push(declaration)
|
|
306
|
+
else byNode.set(declaration.node, [declaration])
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const kept = new Set<number>()
|
|
310
|
+
const namedBy = new Map<number, string>()
|
|
311
|
+
for (const [node, declarations] of byNode) {
|
|
312
|
+
const resolved = await resolveDomainNode(db, node)
|
|
313
|
+
if (!resolved.ok) {
|
|
314
|
+
problems.push(resolved.message)
|
|
315
|
+
continue
|
|
316
|
+
}
|
|
317
|
+
// `/` and `/Home` can be the same page. Each group replaces that page's
|
|
318
|
+
// whole assignment, so without this the later one silently wins and the
|
|
319
|
+
// earlier one's hostnames are gone with nothing having said so.
|
|
320
|
+
const already = namedBy.get(resolved.node.id)
|
|
321
|
+
if (already !== undefined) {
|
|
322
|
+
problems.push(`${node}: the same page as "${already}" — name it once`)
|
|
323
|
+
continue
|
|
324
|
+
}
|
|
325
|
+
namedBy.set(resolved.node.id, node)
|
|
326
|
+
const hosts: Array<{ domainName: string; isoCode: string }> = []
|
|
327
|
+
const wanted: DomainsSyncReport['applied'] = []
|
|
328
|
+
let defaultIsoCode: string | null = null
|
|
329
|
+
for (const declaration of declarations) {
|
|
330
|
+
if (declaration.defaultCulture) defaultIsoCode = declaration.defaultCulture
|
|
331
|
+
if (!declaration.host) continue
|
|
332
|
+
const host = resolvePlaceholders(declaration.host, env)
|
|
333
|
+
if (host.missing.length > 0) {
|
|
334
|
+
// Skipped rather than bound to a half-interpolated name: a hostname
|
|
335
|
+
// built from an unset variable would answer for nobody and hide the
|
|
336
|
+
// mistake. The site still starts; the problem is reported.
|
|
337
|
+
problems.push(
|
|
338
|
+
`${node}: ${declaration.host} is not applied — ${host.missing.join(', ')} ${host.missing.length === 1 ? 'is' : 'are'} not set in this environment`,
|
|
339
|
+
)
|
|
340
|
+
continue
|
|
341
|
+
}
|
|
342
|
+
hosts.push({ domainName: host.value, isoCode: declaration.culture ?? defaultCulture })
|
|
343
|
+
wanted.push({ node, host: host.value, culture: declaration.culture })
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const result = await domains.setForNode(resolved.node.id, { defaultIsoCode, domains: hosts })
|
|
347
|
+
if (result.status !== 'ok') {
|
|
348
|
+
problems.push(`${node}: ${describeSetFailure(result)}`)
|
|
349
|
+
continue
|
|
350
|
+
}
|
|
351
|
+
// Only once it is in the database: reporting a binding that was refused is
|
|
352
|
+
// how a hostname that is not serving anything looks like one that is.
|
|
353
|
+
applied.push(...wanted)
|
|
354
|
+
kept.add(resolved.node.id)
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// Anything bound to a node the file no longer names. `setForNode` already
|
|
358
|
+
// replaced the assignments of the nodes it does name.
|
|
359
|
+
let removed = 0
|
|
360
|
+
const nodeIds = new Set((await domains.all()).map((row) => row.nodeId))
|
|
361
|
+
for (const nodeId of nodeIds) {
|
|
362
|
+
if (kept.has(nodeId)) continue
|
|
363
|
+
const node = await nodes.byId(nodeId)
|
|
364
|
+
if (!node) continue
|
|
365
|
+
const before = await domains.forNode(nodeId)
|
|
366
|
+
await domains.setForNode(nodeId, { defaultIsoCode: null, domains: [] })
|
|
367
|
+
removed += before.domains.length + (before.defaultIsoCode ? 1 : 0)
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
return { action: 'applied', applied, removed, problems }
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function describeSetFailure(result: { status: string; domainName?: string; isoCode?: string }) {
|
|
374
|
+
switch (result.status) {
|
|
375
|
+
case 'invalidLanguage':
|
|
376
|
+
return `no language "${result.isoCode}" is configured here`
|
|
377
|
+
case 'invalidName':
|
|
378
|
+
return `"${result.domainName}" is not a valid hostname`
|
|
379
|
+
case 'conflict':
|
|
380
|
+
return `"${result.domainName}" is already bound to another page`
|
|
381
|
+
default:
|
|
382
|
+
return result.status
|
|
383
|
+
}
|
|
384
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The declarations the backoffice's view editor loads into monaco, so a view is
|
|
3
|
+
* checked in the editor against the same types `tsc` checks it against on disk.
|
|
4
|
+
*
|
|
5
|
+
* Nothing here is generated from the types: `@crombolo/render` ships its own
|
|
6
|
+
* sources, and those sources are what `@crombolo/crombolo` re-exports to a view, so they
|
|
7
|
+
* are served as they are and cannot drift — with React's DOM types and the CSS
|
|
8
|
+
* types under them, which the sources import for each tag's attributes. The one
|
|
9
|
+
* generated file is the schema's `content-types.d.ts`, read fresh on every
|
|
10
|
+
* request so a content type added a minute ago is already in the editor.
|
|
11
|
+
*/
|
|
12
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
13
|
+
import { readdir, readFile } from 'node:fs/promises'
|
|
14
|
+
import { createRequire } from 'node:module'
|
|
15
|
+
import { dirname, join } from 'node:path'
|
|
16
|
+
import { fileURLToPath } from 'node:url'
|
|
17
|
+
import { intendedTypes } from './models-builder.ts'
|
|
18
|
+
|
|
19
|
+
/** A file in the editor's virtual file system. `path` is a URL, not a disk path. */
|
|
20
|
+
export interface EditorTypeLib {
|
|
21
|
+
path: string
|
|
22
|
+
content: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Where `import … from '@crombolo/crombolo'` lands, by TypeScript's own directory-index
|
|
27
|
+
* lookup: no import map and no `paths` entry, because the editor's compiler
|
|
28
|
+
* options have neither a base URL nor a working directory to resolve against.
|
|
29
|
+
*/
|
|
30
|
+
const MODULE_DIR = 'file:///node_modules/@crombolo/crombolo'
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The generated types, at the depth they sit on disk. A view is opened under
|
|
34
|
+
* `components/`, so `../schema/content-types.d.ts` resolves in the editor exactly as
|
|
35
|
+
* it does in the site.
|
|
36
|
+
*/
|
|
37
|
+
export const EDITOR_CONTENT_TYPES = 'file:///schema/content-types.d.ts'
|
|
38
|
+
|
|
39
|
+
/** Where the render sources' `import type … from 'react'` lands, by TypeScript's `@types` lookup. */
|
|
40
|
+
const REACT_TYPES_DIR = 'file:///node_modules/@types/react'
|
|
41
|
+
/** Where React's own `import … from 'csstype'` lands. */
|
|
42
|
+
const CSSTYPE_DIR = 'file:///node_modules/csstype'
|
|
43
|
+
|
|
44
|
+
/** The JSX modes a site may choose, by the `jsxImportSource` that selects each. */
|
|
45
|
+
export const JSX_IMPORT_SOURCES = ['@crombolo/crombolo', '@crombolo/crombolo/loose'] as const
|
|
46
|
+
export type JsxImportSource = (typeof JSX_IMPORT_SOURCES)[number]
|
|
47
|
+
|
|
48
|
+
let cached: Promise<EditorTypeLib[]> | undefined
|
|
49
|
+
|
|
50
|
+
/** `@crombolo/render`'s sources, read from the installed package, and the type packages they import. */
|
|
51
|
+
function moduleLibs(): Promise<EditorTypeLib[]> {
|
|
52
|
+
cached ??= (async () => {
|
|
53
|
+
const dir = dirname(fileURLToPath(import.meta.resolve('@crombolo/render')))
|
|
54
|
+
const sources = async (folder: string) =>
|
|
55
|
+
Promise.all(
|
|
56
|
+
(await readdir(join(dir, folder)))
|
|
57
|
+
.filter((name) => name.endsWith('.ts'))
|
|
58
|
+
.sort()
|
|
59
|
+
.map(async (name) => ({
|
|
60
|
+
path: `${MODULE_DIR}/${folder ? `${folder}/` : ''}${name}`,
|
|
61
|
+
content: await readFile(join(dir, folder, name), 'utf8'),
|
|
62
|
+
})),
|
|
63
|
+
)
|
|
64
|
+
// Resolved from the render package, as its own `import type` resolves them.
|
|
65
|
+
const reactDir = dirname(
|
|
66
|
+
createRequire(join(dir, 'index.ts')).resolve('@types/react/package.json'),
|
|
67
|
+
)
|
|
68
|
+
const cssDir = dirname(
|
|
69
|
+
createRequire(join(reactDir, 'index.d.ts')).resolve('csstype/package.json'),
|
|
70
|
+
)
|
|
71
|
+
const declaration = async (from: string, name: string, to: string) => ({
|
|
72
|
+
path: `${to}/${name}`,
|
|
73
|
+
content: await readFile(join(from, name), 'utf8'),
|
|
74
|
+
})
|
|
75
|
+
return [
|
|
76
|
+
...(await sources('')),
|
|
77
|
+
...(await sources('loose')),
|
|
78
|
+
await declaration(reactDir, 'index.d.ts', REACT_TYPES_DIR),
|
|
79
|
+
await declaration(reactDir, 'global.d.ts', REACT_TYPES_DIR),
|
|
80
|
+
await declaration(cssDir, 'index.d.ts', CSSTYPE_DIR),
|
|
81
|
+
]
|
|
82
|
+
})()
|
|
83
|
+
return cached
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The JSX mode the site's `tsconfig.json` chooses, so the editor checks a view
|
|
88
|
+
* as `tsc` would. Read leniently, since a tsconfig may carry comments; anything
|
|
89
|
+
* other than a mode this package serves is the default.
|
|
90
|
+
*/
|
|
91
|
+
export function siteJsxImportSource(siteDir: string): JsxImportSource {
|
|
92
|
+
const file = join(siteDir, 'tsconfig.json')
|
|
93
|
+
if (!existsSync(file)) return '@crombolo/crombolo'
|
|
94
|
+
const found = /"jsxImportSource"\s*:\s*"([^"]+)"/.exec(readFileSync(file, 'utf8'))?.[1]
|
|
95
|
+
return (JSX_IMPORT_SOURCES as readonly string[]).includes(found ?? '')
|
|
96
|
+
? (found as JsxImportSource)
|
|
97
|
+
: '@crombolo/crombolo'
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export async function editorTypeLibs(options: { schemaDir: string }): Promise<EditorTypeLib[]> {
|
|
101
|
+
const libs = [...(await moduleLibs())]
|
|
102
|
+
const intended = intendedTypes(options.schemaDir)
|
|
103
|
+
// Left out rather than stubbed when the schema does not generate: a view that
|
|
104
|
+
// imports it then says the file is missing, which is true, instead of being
|
|
105
|
+
// checked against types that silently have nothing in them.
|
|
106
|
+
if ('types' in intended) libs.push({ path: EDITOR_CONTENT_TYPES, content: intended.types })
|
|
107
|
+
return libs
|
|
108
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* E-mail through an endpoint the site owns.
|
|
3
|
+
*
|
|
4
|
+
* The escape hatch, and the answer for providers whose API cannot be reached
|
|
5
|
+
* with a key alone — SES wants a SigV4 signature, and signing by hand is code
|
|
6
|
+
* that cannot be honestly verified without an AWS account. A small function in
|
|
7
|
+
* front of it, or any internal mail relay with an HTTP face, works here instead.
|
|
8
|
+
*
|
|
9
|
+
* The body is this CMS's own shape rather than any provider's, because the
|
|
10
|
+
* endpoint on the other end is being written for it:
|
|
11
|
+
*
|
|
12
|
+
* { from, to: [{email, name}], cc, bcc, replyTo, subject, text, html,
|
|
13
|
+
* attachments: [{filename, contentType, content}] } // content is base64
|
|
14
|
+
*
|
|
15
|
+
* Any 2xx is a success. A JSON body with an `id` is recorded, and no body is
|
|
16
|
+
* fine.
|
|
17
|
+
*/
|
|
18
|
+
import {
|
|
19
|
+
assertSecureEndpoint,
|
|
20
|
+
base64,
|
|
21
|
+
type EmailAdapterOptions,
|
|
22
|
+
type EmailPort,
|
|
23
|
+
postToProvider,
|
|
24
|
+
validateMessage,
|
|
25
|
+
} from './email.ts'
|
|
26
|
+
|
|
27
|
+
export interface CustomEmailOptions extends EmailAdapterOptions {
|
|
28
|
+
url: string
|
|
29
|
+
/** Sent as `authorization: Bearer …` when set. */
|
|
30
|
+
apiKey?: string
|
|
31
|
+
/** Anything else the endpoint needs. */
|
|
32
|
+
headers?: Record<string, string>
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function customEmail(options: CustomEmailOptions): EmailPort {
|
|
36
|
+
if (!options.url) throw new Error('A custom e-mail adapter needs a URL.')
|
|
37
|
+
assertSecureEndpoint(options.url, 'custom')
|
|
38
|
+
const call = options.fetch ?? fetch
|
|
39
|
+
|
|
40
|
+
return {
|
|
41
|
+
description: `custom (${options.url})`,
|
|
42
|
+
provider: 'custom',
|
|
43
|
+
|
|
44
|
+
async send(message) {
|
|
45
|
+
const from = message.from ?? options.from
|
|
46
|
+
const invalid = validateMessage(message, from)
|
|
47
|
+
if (invalid) return { ok: false, error: invalid }
|
|
48
|
+
|
|
49
|
+
return postToProvider(
|
|
50
|
+
call,
|
|
51
|
+
options.url,
|
|
52
|
+
{
|
|
53
|
+
method: 'POST',
|
|
54
|
+
headers: {
|
|
55
|
+
'content-type': 'application/json',
|
|
56
|
+
...(options.apiKey ? { authorization: `Bearer ${options.apiKey}` } : {}),
|
|
57
|
+
...options.headers,
|
|
58
|
+
},
|
|
59
|
+
body: JSON.stringify({
|
|
60
|
+
from,
|
|
61
|
+
to: message.to,
|
|
62
|
+
cc: message.cc,
|
|
63
|
+
bcc: message.bcc,
|
|
64
|
+
replyTo: message.replyTo,
|
|
65
|
+
subject: message.subject,
|
|
66
|
+
text: message.text,
|
|
67
|
+
html: message.html,
|
|
68
|
+
attachments: message.attachments?.map((attachment) => ({
|
|
69
|
+
filename: attachment.filename,
|
|
70
|
+
contentType: attachment.contentType,
|
|
71
|
+
content: base64(attachment.bytes),
|
|
72
|
+
})),
|
|
73
|
+
}),
|
|
74
|
+
},
|
|
75
|
+
(body) => (body as { id?: string }).id ?? null,
|
|
76
|
+
)
|
|
77
|
+
},
|
|
78
|
+
}
|
|
79
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a site cannot do because it has no e-mail, said once at boot.
|
|
3
|
+
*
|
|
4
|
+
* The alternative is an operator finding out when an invitation never arrives,
|
|
5
|
+
* which is the worst moment and the hardest to diagnose: nothing errored.
|
|
6
|
+
*/
|
|
7
|
+
import type { EmailAvailability } from './email.ts'
|
|
8
|
+
|
|
9
|
+
export interface EmailNoticeLog {
|
|
10
|
+
info(message: string, properties?: Record<string, unknown>): void
|
|
11
|
+
warning(message: string, properties?: Record<string, unknown>): void
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function noticeAboutEmail(
|
|
15
|
+
availability: EmailAvailability,
|
|
16
|
+
log: EmailNoticeLog,
|
|
17
|
+
/** Whether anything would actually be blocked; a developer is told, not warned. */
|
|
18
|
+
options: { development?: boolean } = {},
|
|
19
|
+
): void {
|
|
20
|
+
if (availability.available) {
|
|
21
|
+
log.info('E-mail is sent through {provider}.', { provider: availability.description })
|
|
22
|
+
return
|
|
23
|
+
}
|
|
24
|
+
// The console stand-in is development's expected state, and it prints every
|
|
25
|
+
// message it is given. Announcing it on each boot is noise in the one place
|
|
26
|
+
// the noise is loudest.
|
|
27
|
+
if (availability.provider === 'log') return
|
|
28
|
+
const message = 'No e-mail provider is configured, so {affected} unavailable. {hint}'
|
|
29
|
+
const properties = {
|
|
30
|
+
affected: `${availability.affects.join(', ')} ${availability.affects.length === 1 ? 'is' : 'are'}`,
|
|
31
|
+
hint: availability.reason,
|
|
32
|
+
}
|
|
33
|
+
// In development this is the expected state and not worth a warning.
|
|
34
|
+
if (options.development) log.info(message, properties)
|
|
35
|
+
else log.warning(message, properties)
|
|
36
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* E-mail through Postmark's HTTPS API.
|
|
3
|
+
*
|
|
4
|
+
* Postmark separates transactional and broadcast traffic into message streams
|
|
5
|
+
* and rejects a send to the wrong one, so `messageStream` is configurable and
|
|
6
|
+
* defaults to the transactional stream every account has.
|
|
7
|
+
*/
|
|
8
|
+
import {
|
|
9
|
+
assertSecureEndpoint,
|
|
10
|
+
base64,
|
|
11
|
+
type EmailAdapterOptions,
|
|
12
|
+
type EmailPort,
|
|
13
|
+
formatAddress,
|
|
14
|
+
postToProvider,
|
|
15
|
+
validateMessage,
|
|
16
|
+
} from './email.ts'
|
|
17
|
+
|
|
18
|
+
const ENDPOINT = 'https://api.postmarkapp.com/email'
|
|
19
|
+
|
|
20
|
+
export interface PostmarkOptions extends EmailAdapterOptions {
|
|
21
|
+
/** Postmark calls it a server token; it is per-server, not per-account. */
|
|
22
|
+
apiKey: string
|
|
23
|
+
messageStream?: string
|
|
24
|
+
endpoint?: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function postmarkEmail(options: PostmarkOptions): EmailPort {
|
|
28
|
+
if (!options.apiKey) throw new Error('A Postmark e-mail adapter needs a server token.')
|
|
29
|
+
const call = options.fetch ?? fetch
|
|
30
|
+
const endpoint = options.endpoint ?? ENDPOINT
|
|
31
|
+
assertSecureEndpoint(endpoint, 'Postmark')
|
|
32
|
+
const stream = options.messageStream || 'outbound'
|
|
33
|
+
|
|
34
|
+
return {
|
|
35
|
+
description: stream === 'outbound' ? 'postmark' : `postmark (${stream})`,
|
|
36
|
+
provider: 'postmark',
|
|
37
|
+
|
|
38
|
+
async send(message) {
|
|
39
|
+
const from = message.from ?? options.from
|
|
40
|
+
const invalid = validateMessage(message, from)
|
|
41
|
+
if (invalid) return { ok: false, error: invalid }
|
|
42
|
+
|
|
43
|
+
// Postmark takes recipients as one comma-separated header value, not a list.
|
|
44
|
+
const list = (addresses: typeof message.to | undefined) =>
|
|
45
|
+
addresses && addresses.length > 0 ? addresses.map(formatAddress).join(', ') : undefined
|
|
46
|
+
|
|
47
|
+
return postToProvider(
|
|
48
|
+
call,
|
|
49
|
+
endpoint,
|
|
50
|
+
{
|
|
51
|
+
method: 'POST',
|
|
52
|
+
headers: {
|
|
53
|
+
'x-postmark-server-token': options.apiKey,
|
|
54
|
+
'content-type': 'application/json',
|
|
55
|
+
accept: 'application/json',
|
|
56
|
+
},
|
|
57
|
+
body: JSON.stringify({
|
|
58
|
+
From: formatAddress(from),
|
|
59
|
+
To: list(message.to),
|
|
60
|
+
Cc: list(message.cc),
|
|
61
|
+
Bcc: list(message.bcc),
|
|
62
|
+
ReplyTo: message.replyTo ? formatAddress(message.replyTo) : undefined,
|
|
63
|
+
Subject: message.subject,
|
|
64
|
+
TextBody: message.text,
|
|
65
|
+
HtmlBody: message.html,
|
|
66
|
+
MessageStream: stream,
|
|
67
|
+
Attachments: message.attachments?.map((attachment) => ({
|
|
68
|
+
Name: attachment.filename,
|
|
69
|
+
Content: base64(attachment.bytes),
|
|
70
|
+
ContentType: attachment.contentType,
|
|
71
|
+
})),
|
|
72
|
+
}),
|
|
73
|
+
},
|
|
74
|
+
(body) => (body as { MessageID?: string }).MessageID ?? null,
|
|
75
|
+
)
|
|
76
|
+
},
|
|
77
|
+
}
|
|
78
|
+
}
|