@crombolo/server 0.11.0 → 0.12.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.
@@ -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
+ }
@@ -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
- const firstValue = (values: readonly FormValueToStore[], alias: string): string =>
65
- values
66
- .find((value) => value.fieldAlias.toLowerCase() === alias.toLowerCase())
67
- ?.values.join(', ') ?? ''
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.values.join(', ') : whole
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.values.join(', ') : '(not answered)'
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
- (entry.values.find((value) => value.fieldAlias === field.alias)?.values ?? []).join('; '),
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
@@ -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
@@ -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 (!(await verifyPassword(input.password ?? '', member.passwordHash))) {
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)
@@ -7,6 +7,7 @@
7
7
  * "out of date" is answered by generating the types and comparing them with
8
8
  * what is on disk, which cannot drift from the question being asked.
9
9
  */
10
+
10
11
  import { existsSync } from 'node:fs'
11
12
  import { readFile, writeFile } from 'node:fs/promises'
12
13
  import { join, relative } from 'node:path'
@@ -15,9 +16,11 @@ import type {
15
16
  ModelsBuilderPort,
16
17
  OutOfDateStatus,
17
18
  } from '@crombolo/api-management'
19
+ import type { FormTypes } from '@crombolo/core'
18
20
  import { generateTypes, loadSchemaDirectory, validateSchemaSet } from '@crombolo/schema'
19
21
  import type { CromboloConfig } from './config.ts'
20
22
  import { VERSION } from './config.ts'
23
+ import { formTypesFor } from './form-types.ts'
21
24
  import { logger } from './logging.ts'
22
25
 
23
26
  /** The file `crombolo generate` writes, inside the schema directory. */
@@ -26,10 +29,10 @@ export const GENERATED_FILE = 'content-types.d.ts'
26
29
  export type Intended = { types: string } | { problems: string[] }
27
30
 
28
31
  /** What `crombolo generate` would write now, or the problems stopping it. */
29
- export function intendedTypes(schemaDir: string): Intended {
32
+ export function intendedTypes(schemaDir: string, formTypes?: FormTypes): Intended {
30
33
  if (!existsSync(schemaDir)) return { problems: [`No schema directory at ${schemaDir}`] }
31
- const loaded = loadSchemaDirectory(schemaDir)
32
- const problems = [...loaded.problems, ...validateSchemaSet(loaded.set)]
34
+ const loaded = loadSchemaDirectory(schemaDir, { formTypes })
35
+ const problems = [...loaded.problems, ...validateSchemaSet(loaded.set, { types: formTypes })]
33
36
  if (problems.length > 0)
34
37
  return {
35
38
  problems: problems.map((p) => `${p.file}${p.path ? ` (${p.path})` : ''}: ${p.message}`),
@@ -40,8 +43,9 @@ export function intendedTypes(schemaDir: string): Intended {
40
43
  /** Undefined when the schema does not generate, so staleness is unknowable. */
41
44
  export async function compareModels(
42
45
  schemaDir: string,
46
+ formTypes?: FormTypes,
43
47
  ): Promise<{ outOfDate: boolean } | undefined> {
44
- const intended = intendedTypes(schemaDir)
48
+ const intended = intendedTypes(schemaDir, formTypes)
45
49
  if ('problems' in intended) return undefined
46
50
  const target = join(schemaDir, GENERATED_FILE)
47
51
  if (!existsSync(target)) return { outOfDate: true }
@@ -50,7 +54,7 @@ export async function compareModels(
50
54
  }
51
55
 
52
56
  export function createModelsBuilderPort(
53
- config: Pick<CromboloConfig, 'schemaDir'>,
57
+ config: Pick<CromboloConfig, 'schemaDir'> & Partial<Pick<CromboloConfig, 'bundles'>>,
54
58
  ): ModelsBuilderPort {
55
59
  // Umbraco keeps the last generation error in memory and shows it on the
56
60
  // dashboard; a build that succeeds clears it.
@@ -58,7 +62,7 @@ export function createModelsBuilderPort(
58
62
  const log = logger('models-builder')
59
63
 
60
64
  const describe = async (): Promise<ModelsBuilderInfo> => {
61
- const compared = await compareModels(config.schemaDir)
65
+ const compared = await compareModels(config.schemaDir, formTypesFor(config))
62
66
  return {
63
67
  // The models are TypeScript source, written on request: Umbraco's mode for
64
68
  // exactly that. The client only explains its own four mode strings.
@@ -78,13 +82,13 @@ export function createModelsBuilderPort(
78
82
  info: describe,
79
83
 
80
84
  async status(): Promise<OutOfDateStatus> {
81
- const compared = await compareModels(config.schemaDir)
85
+ const compared = await compareModels(config.schemaDir, formTypesFor(config))
82
86
  if (!compared) return 'Unknown'
83
87
  return compared.outOfDate ? 'OutOfDate' : 'Current'
84
88
  },
85
89
 
86
90
  async build() {
87
- const intended = intendedTypes(config.schemaDir)
91
+ const intended = intendedTypes(config.schemaDir, formTypesFor(config))
88
92
  if ('problems' in intended) {
89
93
  lastError = intended.problems.join('\n')
90
94
  log.warn('Models were not generated: {problems}', { problems: intended.problems.length })