@crombolo/server 0.11.0 → 0.13.1
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/package.json +12 -11
- package/src/adapters/bundles.ts +2 -1
- package/src/adapters/content.ts +2 -0
- package/src/adapters/documents.ts +28 -25
- package/src/adapters/elements.ts +3 -0
- package/src/adapters/health.ts +333 -0
- package/src/adapters/media.ts +3 -0
- package/src/adapters/members.ts +10 -2
- package/src/adapters/references.ts +13 -0
- package/src/adapters/relations.ts +48 -0
- package/src/adapters/schema-files.ts +41 -11
- package/src/adapters/schema-proposals.ts +13 -6
- package/src/adapters/webhooks.ts +76 -0
- package/src/approvals-api.ts +189 -0
- package/src/approvals.ts +823 -0
- package/src/boot-failure.ts +147 -0
- package/src/bundle-install.ts +148 -13
- package/src/bundles.ts +14 -0
- package/src/busy.ts +13 -0
- package/src/config.ts +54 -2
- package/src/database.ts +9 -7
- package/src/delivery-members.ts +387 -0
- package/src/editor-types.ts +7 -2
- package/src/extensions-notice.ts +3 -2
- package/src/form-files.ts +38 -3
- package/src/form-submit.ts +62 -5
- package/src/form-types.ts +187 -0
- package/src/form-workflows.ts +66 -6
- package/src/forms.ts +15 -7
- package/src/index.ts +11 -0
- package/src/jobs.ts +17 -0
- package/src/marketplace.ts +18 -0
- package/src/media-files.ts +3 -1
- package/src/member-auth.ts +17 -1
- package/src/models-builder.ts +12 -8
- package/src/ports.ts +66 -2
- package/src/schema.ts +58 -11
- package/src/secrets.ts +36 -0
- package/src/server.ts +388 -57
- package/src/setup.ts +288 -0
- package/src/status.ts +5 -2
- package/src/transfer-dashboard.ts +219 -0
- package/src/upgrade-summary.ts +43 -0
- package/src/url-settings.ts +91 -0
- package/src/webhook-files.ts +138 -0
- package/src/webhooks.ts +544 -0
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The public contract for a form field type or workflow type a server bundle
|
|
3
|
+
* adds (story 095, `docs/forms.md`, "Types from a bundle"). A bundle exports
|
|
4
|
+
* definitions; `collectFormTypes` turns the wired bundles' into the registry
|
|
5
|
+
* the parser, validator, renderer and runner are handed.
|
|
6
|
+
*/
|
|
7
|
+
import {
|
|
8
|
+
BUILT_IN_FORM_TYPES,
|
|
9
|
+
type FormSettingDeclaration,
|
|
10
|
+
type FormSettingValue,
|
|
11
|
+
type FormTypes,
|
|
12
|
+
formTypesWith,
|
|
13
|
+
} from '@crombolo/core'
|
|
14
|
+
import type { FormFieldRenderer } from '@crombolo/render'
|
|
15
|
+
import type { EmailMessage, EmailResult } from './email.ts'
|
|
16
|
+
|
|
17
|
+
export type { FormFieldRenderer, FormFieldRenderProps } from '@crombolo/render'
|
|
18
|
+
export type { FormSettingDeclaration, FormSettingValue }
|
|
19
|
+
|
|
20
|
+
/** A field type: what its settings are, how its value is checked, and how it draws. */
|
|
21
|
+
export interface FormFieldTypeDefinition {
|
|
22
|
+
/** camelCase, unique across the built-ins and every wired bundle. */
|
|
23
|
+
type: string
|
|
24
|
+
/** What the designer's type list calls it. */
|
|
25
|
+
label: string
|
|
26
|
+
/**
|
|
27
|
+
* Which built-in kind it stores as, which decides coercion and the entry: a
|
|
28
|
+
* value is always a list of strings, so entries, exports and workflows read it
|
|
29
|
+
* as they read any other field. `none` collects nothing, as a heading does.
|
|
30
|
+
*/
|
|
31
|
+
stores: 'string' | 'strings' | 'number' | 'boolean' | 'none'
|
|
32
|
+
/** Whether it takes a choice list (`values` or a `source`). */
|
|
33
|
+
choices?: boolean
|
|
34
|
+
/** Edited in the designer from these, and held to them in the file. */
|
|
35
|
+
settings?: readonly FormSettingDeclaration[]
|
|
36
|
+
/** After the kind's own checks: a message when the value is wrong. */
|
|
37
|
+
validate?(
|
|
38
|
+
values: readonly string[],
|
|
39
|
+
settings: Readonly<Record<string, FormSettingValue>>,
|
|
40
|
+
): string | undefined
|
|
41
|
+
/** The control, in the form's markup. The label, help and error are drawn around it. */
|
|
42
|
+
render: FormFieldRenderer
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** What a bundle's workflow handler is given: the submission and narrow capabilities, no database. */
|
|
46
|
+
export interface FormWorkflowRunContext {
|
|
47
|
+
form: { key: string; alias: string; name: string }
|
|
48
|
+
/** Each stored field's values, by alias; with labels where a field's choices have them. */
|
|
49
|
+
values: Readonly<Record<string, readonly string[]>>
|
|
50
|
+
labels: Readonly<Record<string, readonly string[]>>
|
|
51
|
+
entryId: string | null
|
|
52
|
+
settings: Readonly<Record<string, unknown>>
|
|
53
|
+
/** Which attempt this is, from 1; the queue makes up to five. */
|
|
54
|
+
attempt: number
|
|
55
|
+
/** Aborted with `signal` unless the call passes its own. */
|
|
56
|
+
fetch(input: string | URL | Request, init?: RequestInit): Promise<Response>
|
|
57
|
+
/** Answers `ok: false` when the site has no e-mail configured. */
|
|
58
|
+
sendEmail(message: EmailMessage): Promise<EmailResult>
|
|
59
|
+
log: {
|
|
60
|
+
info(message: string, properties?: Record<string, unknown>): void
|
|
61
|
+
warn(message: string, properties?: Record<string, unknown>): void
|
|
62
|
+
error(message: string, properties?: Record<string, unknown>): void
|
|
63
|
+
}
|
|
64
|
+
/** Aborted when the run has taken too long. */
|
|
65
|
+
signal: AbortSignal
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Done, or failed: `retry` true tries again on the built-ins' backoff (1, 5, 15
|
|
70
|
+
* and 60 minutes, five attempts in all), false gives up at once. A throw counts
|
|
71
|
+
* as a failure worth retrying.
|
|
72
|
+
*/
|
|
73
|
+
export type FormWorkflowRunResult =
|
|
74
|
+
| { ok: true; detail?: string }
|
|
75
|
+
| { ok: false; error: string; retry: boolean }
|
|
76
|
+
|
|
77
|
+
export interface FormWorkflowTypeDefinition {
|
|
78
|
+
type: string
|
|
79
|
+
label: string
|
|
80
|
+
settings?: readonly FormSettingDeclaration[]
|
|
81
|
+
/** Across its settings, after the declarations pass: a message when they are wrong. */
|
|
82
|
+
validate?(settings: Readonly<Record<string, unknown>>): string | undefined
|
|
83
|
+
run(context: FormWorkflowRunContext): FormWorkflowRunResult | Promise<FormWorkflowRunResult>
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** What a server bundle's `forms` adds. */
|
|
87
|
+
export interface BundleForms {
|
|
88
|
+
fields?: readonly FormFieldTypeDefinition[]
|
|
89
|
+
workflows?: readonly FormWorkflowTypeDefinition[]
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export interface CollectedFormTypes {
|
|
93
|
+
types: FormTypes
|
|
94
|
+
renderers: ReadonlyMap<string, FormFieldRenderer>
|
|
95
|
+
workflows: ReadonlyMap<string, FormWorkflowTypeDefinition>
|
|
96
|
+
problems: string[]
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const STORES = new Set(['string', 'strings', 'number', 'boolean', 'none'])
|
|
100
|
+
|
|
101
|
+
/** Every wired bundle's form types, merged over the built-ins, or why they cannot be. Pure. */
|
|
102
|
+
export function collectFormTypes(
|
|
103
|
+
bundles: readonly { id: string; forms?: BundleForms }[],
|
|
104
|
+
): CollectedFormTypes {
|
|
105
|
+
let types = BUILT_IN_FORM_TYPES
|
|
106
|
+
const renderers = new Map<string, FormFieldRenderer>()
|
|
107
|
+
const workflows = new Map<string, FormWorkflowTypeDefinition>()
|
|
108
|
+
const problems: string[] = []
|
|
109
|
+
for (const bundle of bundles) {
|
|
110
|
+
const forms = bundle.forms
|
|
111
|
+
if (!forms) continue
|
|
112
|
+
const fields = (forms.fields ?? []).filter((field) => {
|
|
113
|
+
if (STORES.has(field.stores) && typeof field.render === 'function') return true
|
|
114
|
+
problems.push(
|
|
115
|
+
`The bundle "${bundle.id}"'s field type "${field.type}" needs a render function and stores of string, strings, number, boolean or none.`,
|
|
116
|
+
)
|
|
117
|
+
return false
|
|
118
|
+
})
|
|
119
|
+
const added = formTypesWith(types, bundle.id, {
|
|
120
|
+
fields: fields.map((field) => ({
|
|
121
|
+
type: field.type,
|
|
122
|
+
label: field.label,
|
|
123
|
+
stores: field.stores,
|
|
124
|
+
choices: field.choices ?? false,
|
|
125
|
+
presentational: field.stores === 'none',
|
|
126
|
+
declared: field.settings ?? [],
|
|
127
|
+
validate: field.validate,
|
|
128
|
+
})),
|
|
129
|
+
workflows: (forms.workflows ?? []).map((workflow) => ({
|
|
130
|
+
type: workflow.type,
|
|
131
|
+
label: workflow.label,
|
|
132
|
+
declared: workflow.settings ?? [],
|
|
133
|
+
validate: workflow.validate,
|
|
134
|
+
})),
|
|
135
|
+
})
|
|
136
|
+
problems.push(...added.problems)
|
|
137
|
+
types = added.types
|
|
138
|
+
for (const field of fields)
|
|
139
|
+
if (types.fields.get(field.type)?.bundle === bundle.id)
|
|
140
|
+
renderers.set(field.type, field.render)
|
|
141
|
+
for (const workflow of forms.workflows ?? [])
|
|
142
|
+
if (types.workflows.get(workflow.type)?.bundle === bundle.id)
|
|
143
|
+
workflows.set(workflow.type, workflow)
|
|
144
|
+
}
|
|
145
|
+
return { types, renderers, workflows, problems }
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const collected = new WeakMap<readonly unknown[], CollectedFormTypes>()
|
|
149
|
+
const NONE: readonly { id: string; forms?: BundleForms }[] = []
|
|
150
|
+
|
|
151
|
+
/** The form types a site's wired bundles add, worked out once per bundle list. */
|
|
152
|
+
export function formTypesOf(config: {
|
|
153
|
+
bundles?: readonly { id: string; forms?: BundleForms }[]
|
|
154
|
+
}): CollectedFormTypes {
|
|
155
|
+
const bundles = config.bundles ?? NONE
|
|
156
|
+
const known = collected.get(bundles)
|
|
157
|
+
if (known) return known
|
|
158
|
+
const result = collectFormTypes(bundles)
|
|
159
|
+
collected.set(bundles, result)
|
|
160
|
+
return result
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** As `formTypesOf`, the registry alone: what schema loading and validation take. */
|
|
164
|
+
export const formTypesFor = (config: {
|
|
165
|
+
bundles?: readonly { id: string; forms?: BundleForms }[]
|
|
166
|
+
}): FormTypes => formTypesOf(config).types
|
|
167
|
+
|
|
168
|
+
/** The types as the designer reads them: what each is called, stores, and which settings it edits. */
|
|
169
|
+
export function describeFormTypes(types: FormTypes) {
|
|
170
|
+
return {
|
|
171
|
+
fields: [...types.fields.values()].map((info) => ({
|
|
172
|
+
type: info.type,
|
|
173
|
+
label: info.label,
|
|
174
|
+
stores: info.stores,
|
|
175
|
+
choices: info.choices,
|
|
176
|
+
presentational: info.presentational,
|
|
177
|
+
settings: info.declared,
|
|
178
|
+
builtIn: info.bundle === undefined,
|
|
179
|
+
})),
|
|
180
|
+
workflows: [...types.workflows.values()].map((info) => ({
|
|
181
|
+
type: info.type,
|
|
182
|
+
label: info.label,
|
|
183
|
+
settings: info.declared,
|
|
184
|
+
builtIn: info.bundle === undefined,
|
|
185
|
+
})),
|
|
186
|
+
}
|
|
187
|
+
}
|
package/src/form-workflows.ts
CHANGED
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
type NodeSchemaState,
|
|
27
27
|
} from '@crombolo/data'
|
|
28
28
|
import type { EmailAttachment, EmailPort } from './email.ts'
|
|
29
|
+
import type { FormWorkflowTypeDefinition } from './form-types.ts'
|
|
29
30
|
import { logger } from './logging.ts'
|
|
30
31
|
import type { MediaFileStore } from './media-files.ts'
|
|
31
32
|
|
|
@@ -40,8 +41,13 @@ export interface WorkflowContext {
|
|
|
40
41
|
form: SchemaForm
|
|
41
42
|
values: readonly FormValueToStore[]
|
|
42
43
|
entryId: string | null
|
|
44
|
+
/** Which attempt this is, from 1. */
|
|
45
|
+
attempt?: number
|
|
43
46
|
}
|
|
44
47
|
|
|
48
|
+
/** How long a bundle's workflow may run before its signal aborts. */
|
|
49
|
+
const BUNDLE_WORKFLOW_TIMEOUT_MS = 60_000
|
|
50
|
+
|
|
45
51
|
export interface WorkflowDeps {
|
|
46
52
|
db: Db
|
|
47
53
|
/** Absent when the site has no e-mail; a `sendEmail` workflow then says so. */
|
|
@@ -52,6 +58,8 @@ export interface WorkflowDeps {
|
|
|
52
58
|
nodeId?: string
|
|
53
59
|
/** Told when a workflow published something, so the cache is dropped. */
|
|
54
60
|
onContentChanged?: () => void
|
|
61
|
+
/** The workflow types wired bundles add, by type. */
|
|
62
|
+
bundleWorkflows?: ReadonlyMap<string, FormWorkflowTypeDefinition>
|
|
55
63
|
}
|
|
56
64
|
|
|
57
65
|
export type WorkflowResult =
|
|
@@ -61,10 +69,13 @@ export type WorkflowResult =
|
|
|
61
69
|
|
|
62
70
|
// ------------------------------------------------------------------ substitution
|
|
63
71
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
72
|
+
/** What a person reads for a value: the label a visitor chose, for a choice from a source. */
|
|
73
|
+
const shown = (value: FormValueToStore): string[] => value.labels ?? value.values
|
|
74
|
+
|
|
75
|
+
const firstValue = (values: readonly FormValueToStore[], alias: string): string => {
|
|
76
|
+
const found = values.find((value) => value.fieldAlias.toLowerCase() === alias.toLowerCase())
|
|
77
|
+
return found ? shown(found).join(', ') : ''
|
|
78
|
+
}
|
|
68
79
|
|
|
69
80
|
/**
|
|
70
81
|
* `{fieldAlias}` in a workflow setting becomes what was submitted for it.
|
|
@@ -79,7 +90,7 @@ export function substitute(text: string, values: readonly FormValueToStore[]): s
|
|
|
79
90
|
const found = values.find((value) => value.fieldAlias.toLowerCase() === alias.toLowerCase())
|
|
80
91
|
// An unknown alias is left as it was written, so a stray brace in a subject
|
|
81
92
|
// line reads as itself rather than vanishing.
|
|
82
|
-
return found ? found.
|
|
93
|
+
return found ? shown(found).join(', ') : whole
|
|
83
94
|
})
|
|
84
95
|
}
|
|
85
96
|
|
|
@@ -102,7 +113,7 @@ export function plainSummary(context: WorkflowContext): string {
|
|
|
102
113
|
const lines = context.values.map(
|
|
103
114
|
(value) =>
|
|
104
115
|
`${captions.get(value.fieldAlias) ?? value.fieldAlias}: ${
|
|
105
|
-
value.values.length > 0 ? value.
|
|
116
|
+
value.values.length > 0 ? shown(value).join(', ') : '(not answered)'
|
|
106
117
|
}`,
|
|
107
118
|
)
|
|
108
119
|
return [`${context.form.name}`, '', ...lines].join('\n')
|
|
@@ -298,6 +309,54 @@ export async function runWorkflow(
|
|
|
298
309
|
case 'sendToUrl':
|
|
299
310
|
return sendToUrl(workflow, context, deps)
|
|
300
311
|
}
|
|
312
|
+
const bundled = deps.bundleWorkflows?.get(workflow.type)
|
|
313
|
+
if (!bundled)
|
|
314
|
+
return {
|
|
315
|
+
ok: false,
|
|
316
|
+
retry: false,
|
|
317
|
+
error: `No wired bundle provides the workflow type "${workflow.type}".`,
|
|
318
|
+
}
|
|
319
|
+
return runBundleWorkflow(bundled, workflow, context, deps)
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** A bundle's handler, given the submission and narrow capabilities rather than the deps. */
|
|
323
|
+
async function runBundleWorkflow(
|
|
324
|
+
definition: FormWorkflowTypeDefinition,
|
|
325
|
+
workflow: SchemaFormWorkflow,
|
|
326
|
+
context: WorkflowContext,
|
|
327
|
+
deps: WorkflowDeps,
|
|
328
|
+
): Promise<WorkflowResult> {
|
|
329
|
+
const values: Record<string, readonly string[]> = {}
|
|
330
|
+
const labels: Record<string, readonly string[]> = {}
|
|
331
|
+
for (const value of context.values) {
|
|
332
|
+
values[value.fieldAlias] = value.values
|
|
333
|
+
if (value.labels) labels[value.fieldAlias] = value.labels
|
|
334
|
+
}
|
|
335
|
+
const named = logger('forms', workflow.type)
|
|
336
|
+
const signal = AbortSignal.timeout(BUNDLE_WORKFLOW_TIMEOUT_MS)
|
|
337
|
+
return definition.run({
|
|
338
|
+
form: {
|
|
339
|
+
key: context.form.key ?? context.form.alias,
|
|
340
|
+
alias: context.form.alias,
|
|
341
|
+
name: context.form.name,
|
|
342
|
+
},
|
|
343
|
+
values,
|
|
344
|
+
labels,
|
|
345
|
+
entryId: context.entryId,
|
|
346
|
+
settings: workflow.settings,
|
|
347
|
+
attempt: context.attempt ?? 1,
|
|
348
|
+
fetch: (input, init) => (deps.fetch ?? fetch)(input, { signal, ...init }),
|
|
349
|
+
sendEmail: async (message) =>
|
|
350
|
+
deps.email
|
|
351
|
+
? deps.email.send(message)
|
|
352
|
+
: { ok: false, error: 'This site has no e-mail set up.' },
|
|
353
|
+
log: {
|
|
354
|
+
info: (message, properties) => named.info(message, properties),
|
|
355
|
+
warn: (message, properties) => named.warn(message, properties),
|
|
356
|
+
error: (message, properties) => named.error(message, properties),
|
|
357
|
+
},
|
|
358
|
+
signal,
|
|
359
|
+
})
|
|
301
360
|
}
|
|
302
361
|
|
|
303
362
|
export interface WorkflowRunReport {
|
|
@@ -344,6 +403,7 @@ export function createWorkflowRunner(
|
|
|
344
403
|
form,
|
|
345
404
|
values: run.payload,
|
|
346
405
|
entryId: run.entryId,
|
|
406
|
+
attempt: run.attempts,
|
|
347
407
|
}
|
|
348
408
|
let result: WorkflowResult
|
|
349
409
|
try {
|
package/src/forms.ts
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
* what a render would use.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
+
import type { FormTypes } from '@crombolo/core'
|
|
13
14
|
import type { FormEntryState } from '@crombolo/data'
|
|
14
15
|
import { allFormFields, type SchemaForm, storingFormFields } from '@crombolo/schema'
|
|
15
16
|
|
|
@@ -26,14 +27,14 @@ export interface FormSummary {
|
|
|
26
27
|
workflowCount: number
|
|
27
28
|
}
|
|
28
29
|
|
|
29
|
-
export function formSummary(form: SchemaForm): FormSummary {
|
|
30
|
+
export function formSummary(form: SchemaForm, types?: FormTypes): FormSummary {
|
|
30
31
|
return {
|
|
31
32
|
// A form with no key in its file still has to be addressable, and its alias
|
|
32
33
|
// is unique within the set, so it stands in until a key is written.
|
|
33
34
|
key: form.key ?? form.alias,
|
|
34
35
|
alias: form.alias,
|
|
35
36
|
name: form.name,
|
|
36
|
-
fieldCount: storingFormFields(form).length,
|
|
37
|
+
fieldCount: storingFormFields(form, types).length,
|
|
37
38
|
pageCount: form.pages.length,
|
|
38
39
|
storeEntries: form.storeEntries,
|
|
39
40
|
requiresApproval: form.requiresApproval,
|
|
@@ -59,6 +60,7 @@ export function formDetail(form: SchemaForm): Record<string, unknown> {
|
|
|
59
60
|
mandatory: field.mandatory,
|
|
60
61
|
sensitive: field.sensitive,
|
|
61
62
|
values: field.values,
|
|
63
|
+
source: field.source ?? null,
|
|
62
64
|
})),
|
|
63
65
|
workflows: form.workflows.map((workflow) => ({
|
|
64
66
|
type: workflow.type,
|
|
@@ -82,10 +84,10 @@ export function formsByKey(forms: readonly SchemaForm[]): Map<string, SchemaForm
|
|
|
82
84
|
* not allowed them, so the redaction is on the way out of the server.
|
|
83
85
|
*/
|
|
84
86
|
export function redactSensitive(
|
|
85
|
-
entry: { values: { fieldAlias: string; values: string[] }[] },
|
|
87
|
+
entry: { values: { fieldAlias: string; values: string[]; labels?: string[] }[] },
|
|
86
88
|
form: SchemaForm | undefined,
|
|
87
89
|
maySeeSensitive: boolean,
|
|
88
|
-
): { fieldAlias: string; values: string[]; redacted: boolean }[] {
|
|
90
|
+
): { fieldAlias: string; values: string[]; labels?: string[]; redacted: boolean }[] {
|
|
89
91
|
const sensitive = new Set(
|
|
90
92
|
(form ? allFormFields(form) : [])
|
|
91
93
|
.filter((field) => field.sensitive)
|
|
@@ -96,6 +98,7 @@ export function redactSensitive(
|
|
|
96
98
|
return {
|
|
97
99
|
fieldAlias: value.fieldAlias,
|
|
98
100
|
values: hide ? [] : value.values,
|
|
101
|
+
...(value.labels && !hide ? { labels: value.labels } : {}),
|
|
99
102
|
redacted: hide,
|
|
100
103
|
}
|
|
101
104
|
})
|
|
@@ -117,6 +120,8 @@ function csvCell(value: string): string {
|
|
|
117
120
|
}
|
|
118
121
|
|
|
119
122
|
export interface EntriesCsvOptions {
|
|
123
|
+
/** The field types in play, so a bundle's type that collects nothing gets no column. */
|
|
124
|
+
types?: FormTypes
|
|
120
125
|
/** Without it, a sensitive column is refused rather than silently blank. */
|
|
121
126
|
includeSensitive: boolean
|
|
122
127
|
}
|
|
@@ -138,11 +143,11 @@ export function entriesCsv(
|
|
|
138
143
|
createDate: Date
|
|
139
144
|
state: string
|
|
140
145
|
spam: boolean
|
|
141
|
-
values: { fieldAlias: string; values: string[] }[]
|
|
146
|
+
values: { fieldAlias: string; values: string[]; labels?: string[] }[]
|
|
142
147
|
}[],
|
|
143
148
|
options: EntriesCsvOptions,
|
|
144
149
|
): EntriesCsvResult {
|
|
145
|
-
const fields = storingFormFields(form)
|
|
150
|
+
const fields = storingFormFields(form, options.types)
|
|
146
151
|
const sensitive = fields.filter((field) => field.sensitive)
|
|
147
152
|
if (sensitive.length > 0 && !options.includeSensitive)
|
|
148
153
|
return {
|
|
@@ -158,7 +163,10 @@ export function entriesCsv(
|
|
|
158
163
|
entry.state,
|
|
159
164
|
String(entry.spam),
|
|
160
165
|
...fields.map((field) =>
|
|
161
|
-
(
|
|
166
|
+
(() => {
|
|
167
|
+
const value = entry.values.find((candidate) => candidate.fieldAlias === field.alias)
|
|
168
|
+
return (value?.labels ?? value?.values ?? []).join('; ')
|
|
169
|
+
})(),
|
|
162
170
|
),
|
|
163
171
|
])
|
|
164
172
|
return {
|
package/src/index.ts
CHANGED
|
@@ -4,6 +4,7 @@ export * from './adapters/bundles.ts'
|
|
|
4
4
|
export * from './adapters/content.ts'
|
|
5
5
|
export * from './adapters/documents.ts'
|
|
6
6
|
export * from './adapters/elements.ts'
|
|
7
|
+
export * from './adapters/health.ts'
|
|
7
8
|
export * from './adapters/media.ts'
|
|
8
9
|
export * from './adapters/member-groups.ts'
|
|
9
10
|
export * from './adapters/members.ts'
|
|
@@ -12,10 +13,12 @@ export * from './adapters/redirects.ts'
|
|
|
12
13
|
export * from './adapters/references.ts'
|
|
13
14
|
export * from './adapters/template-files.ts'
|
|
14
15
|
export * from './backup.ts'
|
|
16
|
+
export * from './boot-failure.ts'
|
|
15
17
|
export * from './bundle-install.ts'
|
|
16
18
|
export * from './bundle-sections.ts'
|
|
17
19
|
export * from './bundle-tables.ts'
|
|
18
20
|
export * from './bundles.ts'
|
|
21
|
+
export * from './busy.ts'
|
|
19
22
|
export * from './components.ts'
|
|
20
23
|
export * from './config.ts'
|
|
21
24
|
export * from './content-type-udt.ts'
|
|
@@ -34,6 +37,7 @@ export * from './extensions-notice.ts'
|
|
|
34
37
|
export * from './file-intake.ts'
|
|
35
38
|
export * from './form-files.ts'
|
|
36
39
|
export * from './form-submit.ts'
|
|
40
|
+
export * from './form-types.ts'
|
|
37
41
|
export * from './form-workflows.ts'
|
|
38
42
|
export * from './forms.ts'
|
|
39
43
|
export * from './git.ts'
|
|
@@ -58,9 +62,16 @@ export * from './ports.ts'
|
|
|
58
62
|
export * from './redirects.ts'
|
|
59
63
|
export * from './schema.ts'
|
|
60
64
|
export * from './schema-store.ts'
|
|
65
|
+
export * from './secrets.ts'
|
|
61
66
|
export * from './seo.ts'
|
|
62
67
|
export * from './server.ts'
|
|
63
68
|
export * from './server-events.ts'
|
|
69
|
+
export * from './setup.ts'
|
|
64
70
|
export * from './status.ts'
|
|
71
|
+
export * from './transfer-dashboard.ts'
|
|
65
72
|
export * from './udt.ts'
|
|
73
|
+
export * from './upgrade-summary.ts'
|
|
74
|
+
export * from './url-settings.ts'
|
|
66
75
|
export * from './view-runtime.ts'
|
|
76
|
+
export * from './webhook-files.ts'
|
|
77
|
+
export * from './webhooks.ts'
|
package/src/jobs.ts
CHANGED
|
@@ -19,6 +19,7 @@ import { auditRetainedSince, recordAudit } from './adapters/audit.ts'
|
|
|
19
19
|
import type { WorkflowRunner, WorkflowRunReport } from './form-workflows.ts'
|
|
20
20
|
import { logger } from './logging.ts'
|
|
21
21
|
import type { MediaFileStore } from './media-files.ts'
|
|
22
|
+
import type { WebhookService } from './webhooks.ts'
|
|
22
23
|
|
|
23
24
|
export interface JobsOptions {
|
|
24
25
|
db: Db
|
|
@@ -41,6 +42,9 @@ export interface JobsOptions {
|
|
|
41
42
|
*/
|
|
42
43
|
formWorkflows?: WorkflowRunner
|
|
43
44
|
formWorkflowsMs?: number
|
|
45
|
+
/** Raises the publishes scheduled publishing makes, and drains the delivery queue. */
|
|
46
|
+
webhooks?: Pick<WebhookService, 'raise' | 'runDue'>
|
|
47
|
+
webhooksMs?: number
|
|
44
48
|
/** How often the search indexer looks for queued items; a write here also starts it at once. */
|
|
45
49
|
searchIndexingMs?: number
|
|
46
50
|
/** Days the audit log keeps an entry; 0 or absent keeps them forever. */
|
|
@@ -57,6 +61,8 @@ export interface BackgroundJobs {
|
|
|
57
61
|
runScheduledPublishing(now?: Date): Promise<ScheduledRun>
|
|
58
62
|
/** Runs the form workflows that are due; a no-op when none are configured. */
|
|
59
63
|
runFormWorkflows(now?: Date): Promise<WorkflowRunReport>
|
|
64
|
+
/** Delivers the webhook and bundle events that are due. */
|
|
65
|
+
runWebhooks(now?: Date): Promise<WorkflowRunReport>
|
|
60
66
|
/** Indexes every queued item, and returns how many. docs/search.md. */
|
|
61
67
|
runSearchIndexing(): Promise<number>
|
|
62
68
|
/** Something was queued here: index it shortly, rather than at the next tick. */
|
|
@@ -109,6 +115,10 @@ export function createBackgroundJobs(options: JobsOptions): BackgroundJobs {
|
|
|
109
115
|
}
|
|
110
116
|
}
|
|
111
117
|
if (run.published.length + run.unpublished.length > 0) options.cache.invalidate()
|
|
118
|
+
for (const key of run.published)
|
|
119
|
+
await options.webhooks?.raise({ alias: 'Umbraco.ContentPublish', key })
|
|
120
|
+
for (const key of run.unpublished)
|
|
121
|
+
await options.webhooks?.raise({ alias: 'Umbraco.ContentUnpublish', key })
|
|
112
122
|
return run
|
|
113
123
|
}
|
|
114
124
|
|
|
@@ -121,6 +131,10 @@ export function createBackgroundJobs(options: JobsOptions): BackgroundJobs {
|
|
|
121
131
|
return documents.cleanupVersions(now, options.versionCleanup)
|
|
122
132
|
}
|
|
123
133
|
|
|
134
|
+
async function runWebhooks(now = new Date()): Promise<WorkflowRunReport> {
|
|
135
|
+
return (await options.webhooks?.runDue(now)) ?? { claimed: 0, done: 0, retrying: 0, failed: 0 }
|
|
136
|
+
}
|
|
137
|
+
|
|
124
138
|
async function runFormWorkflows(now = new Date()): Promise<WorkflowRunReport> {
|
|
125
139
|
return (
|
|
126
140
|
(await options.formWorkflows?.runDue(now)) ?? {
|
|
@@ -169,6 +183,7 @@ export function createBackgroundJobs(options: JobsOptions): BackgroundJobs {
|
|
|
169
183
|
runVersionCleanup,
|
|
170
184
|
runAuditRetention,
|
|
171
185
|
runFormWorkflows,
|
|
186
|
+
runWebhooks,
|
|
172
187
|
runSearchIndexing,
|
|
173
188
|
kickSearchIndexing() {
|
|
174
189
|
if (timers.length === 0 || kicked) return
|
|
@@ -187,6 +202,8 @@ export function createBackgroundJobs(options: JobsOptions): BackgroundJobs {
|
|
|
187
202
|
// More often than the other jobs: somebody waiting on a form notification
|
|
188
203
|
// notices a minute, and a queue row is cheap to look for.
|
|
189
204
|
if (options.formWorkflows) every(options.formWorkflowsMs ?? 15_000, runFormWorkflows)
|
|
205
|
+
// As often, for the same reason: a receiver that is waiting notices a minute.
|
|
206
|
+
if (options.webhooks) every(options.webhooksMs ?? 15_000, runWebhooks)
|
|
190
207
|
if (options.versionCleanup.enabled) every(60 * 60_000, runVersionCleanup)
|
|
191
208
|
if (options.auditRetentionDays) every(60 * 60_000, runAuditRetention)
|
|
192
209
|
const files = options.mediaFiles
|
package/src/marketplace.ts
CHANGED
|
@@ -88,6 +88,11 @@ export interface MarketplaceOptions {
|
|
|
88
88
|
applyContent?: (packageName: string) => Promise<ContentOutcome>
|
|
89
89
|
/** The site's `applyBundleContentOnInstall`. */
|
|
90
90
|
applyContentOnInstall?: boolean
|
|
91
|
+
/**
|
|
92
|
+
* False refuses installing and removing here: the dependency belongs in
|
|
93
|
+
* `package.json` and a deploy, so every node has it and it outlives a restart.
|
|
94
|
+
*/
|
|
95
|
+
installs?: boolean
|
|
91
96
|
}
|
|
92
97
|
|
|
93
98
|
export interface InstallOutcome {
|
|
@@ -308,6 +313,7 @@ export function createMarketplace(options: MarketplaceOptions): Marketplace {
|
|
|
308
313
|
},
|
|
309
314
|
|
|
310
315
|
async install(name, version, request = {}) {
|
|
316
|
+
if (options.installs === false) return refusedHere(name, 'add')
|
|
311
317
|
if (!isValidPackageName(name))
|
|
312
318
|
return { ok: false, message: `'${name}' is not a package name npm would accept.` }
|
|
313
319
|
if (version !== undefined && !isValidVersion(version))
|
|
@@ -365,6 +371,7 @@ export function createMarketplace(options: MarketplaceOptions): Marketplace {
|
|
|
365
371
|
},
|
|
366
372
|
|
|
367
373
|
async uninstall(name) {
|
|
374
|
+
if (options.installs === false) return refusedHere(name, 'remove')
|
|
368
375
|
if (!isValidPackageName(name))
|
|
369
376
|
return { ok: false, message: `'${name}' is not a package name npm would accept.` }
|
|
370
377
|
const result = await install('remove', name, options.siteDir)
|
|
@@ -379,4 +386,15 @@ export function createMarketplace(options: MarketplaceOptions): Marketplace {
|
|
|
379
386
|
}
|
|
380
387
|
}
|
|
381
388
|
|
|
389
|
+
/** What a site that does not install at runtime says instead, with what to do. */
|
|
390
|
+
function refusedHere(name: string, verb: 'add' | 'remove'): InstallOutcome {
|
|
391
|
+
return {
|
|
392
|
+
ok: false,
|
|
393
|
+
message:
|
|
394
|
+
verb === 'add'
|
|
395
|
+
? `Packages are not installed from the backoffice on this site. Add ${name} to the site's package.json (bun add ${name}) and deploy, so every server has it.`
|
|
396
|
+
: `Packages are not removed from the backoffice on this site. Remove ${name} from the site's package.json (bun remove ${name}) and deploy.`,
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
382
400
|
export { EXTENSION_KEYWORD }
|
package/src/media-files.ts
CHANGED
|
@@ -262,12 +262,14 @@ export async function readBlobs(
|
|
|
262
262
|
export async function placeBlobs(
|
|
263
263
|
store: MediaStore,
|
|
264
264
|
blobs: ReadonlyMap<string, string>,
|
|
265
|
+
/** Turns a carried file's bytes into the media file's: opens a sealed one. */
|
|
266
|
+
open: (key: string, bytes: Uint8Array) => Uint8Array = (_key, bytes) => bytes,
|
|
265
267
|
): Promise<{ placed: number; bytes: number }> {
|
|
266
268
|
let placed = 0
|
|
267
269
|
let bytes = 0
|
|
268
270
|
for (const [key, path] of blobs) {
|
|
269
271
|
const file = Bun.file(path)
|
|
270
|
-
const content = new Uint8Array(await file.arrayBuffer())
|
|
272
|
+
const content = open(key, new Uint8Array(await file.arrayBuffer()))
|
|
271
273
|
await store.put(key, content, { contentType: contentTypeFor(key) })
|
|
272
274
|
placed += 1
|
|
273
275
|
bytes += content.length
|
package/src/member-auth.ts
CHANGED
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
import {
|
|
23
23
|
expireCookie,
|
|
24
24
|
hashPassword,
|
|
25
|
+
isNativeHash,
|
|
25
26
|
memberCookieName,
|
|
26
27
|
passwordConfigJson,
|
|
27
28
|
readCookie,
|
|
@@ -69,6 +70,8 @@ export interface MemberAuth {
|
|
|
69
70
|
decide(content: PublishedContent, member: RequestMember | undefined): Promise<AccessDecision>
|
|
70
71
|
/** Handles the member endpoints, or undefined when the path is not one. */
|
|
71
72
|
handle(request: Request, pathname: string): Promise<Response | undefined>
|
|
73
|
+
/** The `Set-Cookie` value that ends a member's site session. */
|
|
74
|
+
signOutCookie(): string
|
|
72
75
|
}
|
|
73
76
|
|
|
74
77
|
const isFormPost = (request: Request) =>
|
|
@@ -249,10 +252,22 @@ export function createMemberAuth(
|
|
|
249
252
|
await verifyPassword(input.password ?? '', await hashPassword(crypto.randomUUID()))
|
|
250
253
|
return failed()
|
|
251
254
|
}
|
|
252
|
-
if (
|
|
255
|
+
if (
|
|
256
|
+
!(await verifyPassword(input.password ?? '', member.passwordHash, {
|
|
257
|
+
passwordConfig: member.passwordConfig,
|
|
258
|
+
forMember: true,
|
|
259
|
+
}))
|
|
260
|
+
) {
|
|
253
261
|
await members.recordFailedLogin(member.key, config.maxFailedPasswordAttempts)
|
|
254
262
|
return failed()
|
|
255
263
|
}
|
|
264
|
+
// An imported Umbraco hash is replaced now the password is known to match it.
|
|
265
|
+
if (!isNativeHash(member.passwordHash))
|
|
266
|
+
await members.rehashPassword(
|
|
267
|
+
member.key,
|
|
268
|
+
await hashPassword(input.password ?? ''),
|
|
269
|
+
passwordConfigJson(),
|
|
270
|
+
)
|
|
256
271
|
await members.recordLogin(member.key)
|
|
257
272
|
return answer(
|
|
258
273
|
request,
|
|
@@ -359,6 +374,7 @@ export function createMemberAuth(
|
|
|
359
374
|
return {
|
|
360
375
|
resolve,
|
|
361
376
|
decide,
|
|
377
|
+
signOutCookie: () => expireCookie(cookie, config.secureCookies),
|
|
362
378
|
async handle(request, pathname) {
|
|
363
379
|
if (!pathname.startsWith(`${MEMBER_ROUTE_PREFIX}/`)) return undefined
|
|
364
380
|
const action = pathname.slice(MEMBER_ROUTE_PREFIX.length + 1)
|