@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
package/src/models-builder.ts
CHANGED
|
@@ -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 })
|
package/src/ports.ts
CHANGED
|
@@ -1,16 +1,19 @@
|
|
|
1
1
|
/** Implementations of the Management API ports. */
|
|
2
|
+
|
|
3
|
+
import { resolveDeliveryApiSettings } from '@crombolo/api-delivery'
|
|
2
4
|
import type {
|
|
3
5
|
CurrentUserPort,
|
|
4
6
|
LocalizationPort,
|
|
5
7
|
ManagementApiDeps,
|
|
6
8
|
ManifestPort,
|
|
7
9
|
NodeLookup,
|
|
10
|
+
PreviewPort,
|
|
8
11
|
Principal,
|
|
9
12
|
ServerPort,
|
|
10
13
|
TemporaryFilePort,
|
|
11
14
|
UserItemPort,
|
|
12
15
|
} from '@crombolo/api-management'
|
|
13
|
-
import { AuthStore } from '@crombolo/auth'
|
|
16
|
+
import { AuthStore, expireCookie, serializeCookie } from '@crombolo/auth'
|
|
14
17
|
import {
|
|
15
18
|
ASSISTANT_MANIFEST_ID,
|
|
16
19
|
type BackOfficePaths,
|
|
@@ -28,6 +31,7 @@ import {
|
|
|
28
31
|
import {
|
|
29
32
|
ContentTypeRepository,
|
|
30
33
|
type Db,
|
|
34
|
+
DynamicRootRepository,
|
|
31
35
|
LanguageRepository,
|
|
32
36
|
RedirectRepository,
|
|
33
37
|
TagRepository,
|
|
@@ -48,12 +52,14 @@ import { createDictionaryPort } from './adapters/dictionary.ts'
|
|
|
48
52
|
import { createDocumentPort } from './adapters/documents.ts'
|
|
49
53
|
import { createElementPort } from './adapters/elements.ts'
|
|
50
54
|
import { createFileSystemPort } from './adapters/file-system.ts'
|
|
55
|
+
import { createDiagnosticsPort } from './adapters/health.ts'
|
|
51
56
|
import { createMediaPort } from './adapters/media.ts'
|
|
52
57
|
import { createMemberGroupPort } from './adapters/member-groups.ts'
|
|
53
58
|
import { createMemberPort } from './adapters/members.ts'
|
|
54
59
|
import { createPublicAccessPort } from './adapters/public-access.ts'
|
|
55
60
|
import { createRedirectPort } from './adapters/redirects.ts'
|
|
56
61
|
import { createReferencePort } from './adapters/references.ts'
|
|
62
|
+
import { createRelationPort } from './adapters/relations.ts'
|
|
57
63
|
import {
|
|
58
64
|
createSchemaFileWriter,
|
|
59
65
|
type SchemaFiles,
|
|
@@ -63,10 +69,13 @@ import { createSearchIndexPort } from './adapters/search-index.ts'
|
|
|
63
69
|
import { createTemplateFileStore } from './adapters/template-files.ts'
|
|
64
70
|
import { createTemplateQueryPort } from './adapters/template-query.ts'
|
|
65
71
|
import { avatarUrls, createUserPorts } from './adapters/users.ts'
|
|
72
|
+
import { createWebhookPort } from './adapters/webhooks.ts'
|
|
73
|
+
import { createApprovals } from './approvals.ts'
|
|
66
74
|
import { COMPONENT_SNIPPETS } from './component-snippets.ts'
|
|
67
75
|
import { type CromboloConfig, type UserLinkSender, VERSION } from './config.ts'
|
|
68
76
|
import { type EmailPort, resolveEmailPort } from './email.ts'
|
|
69
77
|
import { createFileIntake } from './file-intake.ts'
|
|
78
|
+
import { formTypesFor } from './form-types.ts'
|
|
70
79
|
import { createLogViewerPort } from './log-viewer.ts'
|
|
71
80
|
import { logger } from './logging.ts'
|
|
72
81
|
import { MediaFileStore } from './media-files.ts'
|
|
@@ -75,6 +84,7 @@ import { createOEmbedService } from './oembed.ts'
|
|
|
75
84
|
import { createRedirectTracker } from './redirects.ts'
|
|
76
85
|
import { componentAliasesIn, type SchemaBoot } from './schema.ts'
|
|
77
86
|
import { publishSchema } from './schema-store.ts'
|
|
87
|
+
import { createWebhookService, type WebhookService } from './webhooks.ts'
|
|
78
88
|
|
|
79
89
|
/** Re-exported for tests; the canonical list lives in @crombolo/core. */
|
|
80
90
|
export const CORE_SECTIONS = CORE_SECTION_ALIASES
|
|
@@ -111,6 +121,18 @@ function createServerPort(config: CromboloConfig, canSendUserLinks: boolean): Se
|
|
|
111
121
|
}
|
|
112
122
|
}
|
|
113
123
|
|
|
124
|
+
/** Umbraco's preview cookie; with a signed-in editor it turns on draft rendering. */
|
|
125
|
+
export const PREVIEW_COOKIE = 'UMB_PREVIEW'
|
|
126
|
+
|
|
127
|
+
function createPreviewPort(config: CromboloConfig): PreviewPort {
|
|
128
|
+
return {
|
|
129
|
+
enter: () => [
|
|
130
|
+
serializeCookie({ name: PREVIEW_COOKIE, value: 'preview', secure: config.secureCookies }),
|
|
131
|
+
],
|
|
132
|
+
exit: () => [expireCookie(PREVIEW_COOKIE, config.secureCookies)],
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
114
136
|
function createManifestPort(
|
|
115
137
|
paths: BackOfficePaths,
|
|
116
138
|
extensions: ExtensionRegistry,
|
|
@@ -326,12 +348,13 @@ export function resolveUserLinkSender(
|
|
|
326
348
|
}
|
|
327
349
|
|
|
328
350
|
/** Where the backoffice writes schema files, and whether it may. */
|
|
329
|
-
function schemaFiles(config: CromboloConfig): SchemaFiles {
|
|
351
|
+
export function schemaFiles(config: CromboloConfig): SchemaFiles {
|
|
330
352
|
const store = config.schemaStore
|
|
331
353
|
return {
|
|
332
354
|
schemaDir: config.schemaDir,
|
|
333
355
|
writable: config.schemaWritable,
|
|
334
356
|
componentAliases: () => componentAliasesIn(config.componentsDir),
|
|
357
|
+
formTypes: formTypesFor(config),
|
|
335
358
|
// Only when a store is configured: a schema directory in a repository is
|
|
336
359
|
// already where the files durably live.
|
|
337
360
|
onChanged: store
|
|
@@ -359,6 +382,8 @@ export interface DepsOptions {
|
|
|
359
382
|
extensions: ExtensionRegistry
|
|
360
383
|
/** Omitted only by tests that exercise the contract without a database. */
|
|
361
384
|
db?: Db
|
|
385
|
+
/** The site's webhooks, shared with the delivery job and the event source. */
|
|
386
|
+
webhooks?: WebhookService
|
|
362
387
|
/** Required alongside `db` to serve documents, which invalidate the cache. */
|
|
363
388
|
cache?: PublishedCache
|
|
364
389
|
schema?: SchemaBoot
|
|
@@ -406,6 +431,7 @@ export function createDeps(options: DepsOptions): ManagementApiDeps {
|
|
|
406
431
|
const sendUserLink = resolveUserLinkSender(options.config, email)
|
|
407
432
|
const deps: ManagementApiDeps = {
|
|
408
433
|
server: createServerPort(options.config, Boolean(sendUserLink)),
|
|
434
|
+
preview: createPreviewPort(options.config),
|
|
409
435
|
// An unconfigured assistant contributes no extension, so the backoffice has
|
|
410
436
|
// no drawer, no button and nothing to load.
|
|
411
437
|
manifests: createManifestPort(
|
|
@@ -441,6 +467,16 @@ export function createDeps(options: DepsOptions): ManagementApiDeps {
|
|
|
441
467
|
deps.stylesheets = createFileSystemPort(options.config.stylesheetsDir, { extension: '.css' })
|
|
442
468
|
deps.scripts = createFileSystemPort(options.config.scriptsDir, { extension: '.js' })
|
|
443
469
|
if (options.db) {
|
|
470
|
+
const dynamicRoots = new DynamicRootRepository(options.db)
|
|
471
|
+
deps.dynamicRoots = { resolve: (query) => dynamicRoots.resolve(query) }
|
|
472
|
+
deps.diagnostics = createDiagnosticsPort({
|
|
473
|
+
config: options.config,
|
|
474
|
+
db: options.db,
|
|
475
|
+
media: mediaFiles.store,
|
|
476
|
+
email,
|
|
477
|
+
nodeState: options.schema?.nodeState,
|
|
478
|
+
fetch: options.fetch,
|
|
479
|
+
})
|
|
444
480
|
const tags = new TagRepository(options.db)
|
|
445
481
|
deps.tags = { list: (filter) => tags.all(filter) }
|
|
446
482
|
deps.users = createUserItemPort(options.db)
|
|
@@ -493,6 +529,9 @@ export function createDeps(options: DepsOptions): ManagementApiDeps {
|
|
|
493
529
|
nodeState: options.schema?.nodeState,
|
|
494
530
|
nodeId: options.schema?.nodeId,
|
|
495
531
|
valueIntake,
|
|
532
|
+
apiMemberUserNames: resolveDeliveryApiSettings(
|
|
533
|
+
options.config.deliveryApi,
|
|
534
|
+
).memberAuthorization.clientCredentialsFlow.associatedMembers.map((m) => m.userName),
|
|
496
535
|
})
|
|
497
536
|
deps.publicAccess = createPublicAccessPort(options.db, {
|
|
498
537
|
onChange: () => options.cache?.invalidate(),
|
|
@@ -504,6 +543,17 @@ export function createDeps(options: DepsOptions): ManagementApiDeps {
|
|
|
504
543
|
onChange: () => options.cache?.invalidate(),
|
|
505
544
|
})
|
|
506
545
|
deps.references = createReferencePort(options.db)
|
|
546
|
+
deps.relations = createRelationPort(options.db)
|
|
547
|
+
deps.webhooks = createWebhookPort(
|
|
548
|
+
options.webhooks ??
|
|
549
|
+
createWebhookService({
|
|
550
|
+
db: options.db,
|
|
551
|
+
schemaDir: options.config.schemaDir,
|
|
552
|
+
filesWritable: options.config.schemaWritable,
|
|
553
|
+
secretKey: options.config.secretKey,
|
|
554
|
+
bundles: options.config.bundles,
|
|
555
|
+
}),
|
|
556
|
+
)
|
|
507
557
|
deps.redirects = createRedirectPort(options.db, options.cache, {
|
|
508
558
|
isTracking: options.config.trackRedirects,
|
|
509
559
|
onChange: () => options.cache?.invalidate(),
|
|
@@ -554,6 +604,20 @@ export function createDeps(options: DepsOptions): ManagementApiDeps {
|
|
|
554
604
|
}),
|
|
555
605
|
options.config.siteDir,
|
|
556
606
|
)
|
|
607
|
+
if (deps.documents) {
|
|
608
|
+
const documents = deps.documents
|
|
609
|
+
deps.approvals = createApprovals({
|
|
610
|
+
db: options.db,
|
|
611
|
+
nodeState: options.schema?.nodeState,
|
|
612
|
+
email,
|
|
613
|
+
// Through the same port an editor's publish takes, so what approval
|
|
614
|
+
// publishes is cached, tracked and announced as any publish is.
|
|
615
|
+
publisher: {
|
|
616
|
+
publish: (key, cultures) => documents.publish(key, cultures),
|
|
617
|
+
unpublish: (key, cultures) => documents.unpublish(key, cultures),
|
|
618
|
+
},
|
|
619
|
+
})
|
|
620
|
+
}
|
|
557
621
|
}
|
|
558
622
|
return deps
|
|
559
623
|
}
|
package/src/schema.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
import { existsSync, watch } from 'node:fs'
|
|
8
8
|
import {
|
|
9
|
+
CACHE_INSTRUCTION_CHANNEL,
|
|
9
10
|
type CacheInstructionKind,
|
|
10
11
|
cacheInstructionsAfter,
|
|
11
12
|
compareStates,
|
|
@@ -39,7 +40,8 @@ import {
|
|
|
39
40
|
writeKeysBack,
|
|
40
41
|
} from '@crombolo/schema'
|
|
41
42
|
import { listComponents } from './components.ts'
|
|
42
|
-
import type
|
|
43
|
+
import { type CromboloConfig, VERSION } from './config.ts'
|
|
44
|
+
import { formTypesFor } from './form-types.ts'
|
|
43
45
|
import { logger } from './logging.ts'
|
|
44
46
|
import { materialiseSchema } from './schema-store.ts'
|
|
45
47
|
|
|
@@ -72,7 +74,7 @@ export async function checkSchemaDirectory(
|
|
|
72
74
|
node: { nodeId: string; revision: string },
|
|
73
75
|
options: { set?: Record<string, unknown> } = {},
|
|
74
76
|
): Promise<CheckReport> {
|
|
75
|
-
const loaded = loadSchemaDirectory(config.schemaDir)
|
|
77
|
+
const loaded = loadSchemaDirectory(config.schemaDir, { formTypes: formTypesFor(config) })
|
|
76
78
|
return runCheck(
|
|
77
79
|
db,
|
|
78
80
|
loaded,
|
|
@@ -108,6 +110,7 @@ export function syncOptionsFor(config: CromboloConfig): SyncOptions {
|
|
|
108
110
|
return {
|
|
109
111
|
mode: config.development ? 'development' : 'production',
|
|
110
112
|
componentAliases: componentAliasesIn(config.componentsDir),
|
|
113
|
+
formTypes: formTypesFor(config),
|
|
111
114
|
}
|
|
112
115
|
}
|
|
113
116
|
|
|
@@ -121,7 +124,7 @@ export async function syncSchemaDirectory(
|
|
|
121
124
|
// The files are read under the lock the backoffice's schema writes hold, so a
|
|
122
125
|
// sync never applies a snapshot that a later backoffice change has overtaken.
|
|
123
126
|
return db.locks.withLock(Locks.Schema, async () => {
|
|
124
|
-
const loaded = loadSchemaDirectory(config.schemaDir)
|
|
127
|
+
const loaded = loadSchemaDirectory(config.schemaDir, { formTypes: formTypesFor(config) })
|
|
125
128
|
const report = await syncSchema(
|
|
126
129
|
db,
|
|
127
130
|
loaded,
|
|
@@ -145,15 +148,15 @@ export async function bootSchema(db: Db, config: CromboloConfig): Promise<Schema
|
|
|
145
148
|
const nodeId = config.nodeId
|
|
146
149
|
if (!existsSync(config.schemaDir)) {
|
|
147
150
|
const nodeState = { version: '0', revision: config.schemaRevision }
|
|
148
|
-
await touchServer(db, { nodeId, ...nodeState, role: config.role })
|
|
151
|
+
await touchServer(db, { nodeId, ...nodeState, role: config.role, cromboloVersion: VERSION })
|
|
149
152
|
await ensureSystemMediaTypes(db, { linkFolderChildren: true })
|
|
150
153
|
await ensureSystemMemberTypes(db)
|
|
151
154
|
await ensureSystemContentTypes(db)
|
|
152
155
|
return { nodeState, nodeId, report: undefined, compatibilityMode: false }
|
|
153
156
|
}
|
|
154
|
-
const loaded = loadSchemaDirectory(config.schemaDir)
|
|
157
|
+
const loaded = loadSchemaDirectory(config.schemaDir, { formTypes: formTypesFor(config) })
|
|
155
158
|
const nodeState = { version: loaded.set.version, revision: config.schemaRevision }
|
|
156
|
-
await touchServer(db, { nodeId, ...nodeState, role: config.role })
|
|
159
|
+
await touchServer(db, { nodeId, ...nodeState, role: config.role, cromboloVersion: VERSION })
|
|
157
160
|
if (!config.syncSchemaAtBoot)
|
|
158
161
|
return { nodeState, nodeId, report: undefined, compatibilityMode: false }
|
|
159
162
|
|
|
@@ -246,11 +249,18 @@ export interface CachePollerOptions {
|
|
|
246
249
|
onInstruction(kind: CacheInstructionKind, payload: Record<string, unknown>): void
|
|
247
250
|
}
|
|
248
251
|
|
|
249
|
-
/**
|
|
252
|
+
/**
|
|
253
|
+
* Polls `cache_instruction` from where this node came in; own instructions are
|
|
254
|
+
* skipped. Where the database can notify, a write wakes it at once and the
|
|
255
|
+
* timer is only the fallback.
|
|
256
|
+
*/
|
|
250
257
|
export class CacheInstructionPoller {
|
|
251
258
|
#options: CachePollerOptions
|
|
252
259
|
#cursor = 0
|
|
253
260
|
#timer: ReturnType<typeof setInterval> | undefined
|
|
261
|
+
#unlisten: (() => Promise<void>) | undefined
|
|
262
|
+
#running: Promise<number> | undefined
|
|
263
|
+
#queued: Promise<number> | undefined
|
|
254
264
|
#behind = false
|
|
255
265
|
#bootLedgerId = 0
|
|
256
266
|
#readsUnsafe = false
|
|
@@ -287,11 +297,39 @@ export class CacheInstructionPoller {
|
|
|
287
297
|
if (intervalMs > 0) {
|
|
288
298
|
this.#timer = setInterval(() => void this.poll().catch(() => {}), intervalMs)
|
|
289
299
|
this.#timer.unref()
|
|
300
|
+
// Without it the timer still delivers everything, only later.
|
|
301
|
+
this.#unlisten = await this.#options.db
|
|
302
|
+
.listen?.(CACHE_INSTRUCTION_CHANNEL, () => void this.poll().catch(() => {}))
|
|
303
|
+
.catch(() => undefined)
|
|
290
304
|
}
|
|
291
305
|
}
|
|
292
306
|
|
|
293
|
-
/**
|
|
294
|
-
|
|
307
|
+
/**
|
|
308
|
+
* Returns how many foreign instructions were applied. One poll at a time: a
|
|
309
|
+
* call while one runs waits for a single follow-up, so a burst of wake-ups
|
|
310
|
+
* reads the table twice, not once each.
|
|
311
|
+
*/
|
|
312
|
+
poll(): Promise<number> {
|
|
313
|
+
if (this.#queued) return this.#queued
|
|
314
|
+
if (!this.#running) return this.#start()
|
|
315
|
+
this.#queued = this.#running
|
|
316
|
+
.catch(() => 0)
|
|
317
|
+
.then(() => {
|
|
318
|
+
this.#queued = undefined
|
|
319
|
+
return this.#start()
|
|
320
|
+
})
|
|
321
|
+
return this.#queued
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
#start(): Promise<number> {
|
|
325
|
+
const run = this.#pollOnce().finally(() => {
|
|
326
|
+
if (this.#running === run) this.#running = undefined
|
|
327
|
+
})
|
|
328
|
+
this.#running = run
|
|
329
|
+
return run
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
async #pollOnce(): Promise<number> {
|
|
295
333
|
const { db, nodeId, nodeState, onInstruction } = this.#options
|
|
296
334
|
const instructions = await cacheInstructionsAfter(db, this.#cursor)
|
|
297
335
|
let applied = 0
|
|
@@ -301,7 +339,12 @@ export class CacheInstructionPoller {
|
|
|
301
339
|
onInstruction(instruction.kind, instruction.payload)
|
|
302
340
|
applied += 1
|
|
303
341
|
}
|
|
304
|
-
await touchServer(db, {
|
|
342
|
+
await touchServer(db, {
|
|
343
|
+
nodeId,
|
|
344
|
+
...nodeState,
|
|
345
|
+
role: this.#options.role,
|
|
346
|
+
cromboloVersion: VERSION,
|
|
347
|
+
})
|
|
305
348
|
const current = await currentSchemaState(db)
|
|
306
349
|
const behind = compareStates(nodeState, current) < 0
|
|
307
350
|
if (behind && !this.#behind) onInstruction('schema', { stateId: current.id })
|
|
@@ -317,6 +360,8 @@ export class CacheInstructionPoller {
|
|
|
317
360
|
stop(): void {
|
|
318
361
|
if (this.#timer) clearInterval(this.#timer)
|
|
319
362
|
this.#timer = undefined
|
|
363
|
+
void this.#unlisten?.().catch(() => {})
|
|
364
|
+
this.#unlisten = undefined
|
|
320
365
|
}
|
|
321
366
|
}
|
|
322
367
|
|
|
@@ -376,7 +421,9 @@ export async function importSchema(
|
|
|
376
421
|
|
|
377
422
|
const report = await syncSchemaDirectory(db, config, node)
|
|
378
423
|
if (report.action === 'applied') {
|
|
379
|
-
boot.nodeState.version = loadSchemaDirectory(config.schemaDir
|
|
424
|
+
boot.nodeState.version = loadSchemaDirectory(config.schemaDir, {
|
|
425
|
+
formTypes: formTypesFor(config),
|
|
426
|
+
}).set.version
|
|
380
427
|
boot.nodeState.revision = config.schemaRevision
|
|
381
428
|
}
|
|
382
429
|
return {
|
package/src/secrets.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Secrets kept in the database, sealed with `CROMBOLO_SECRET_KEY`: AES-256-GCM,
|
|
3
|
+
* so a value cannot be read or quietly altered without the key. The key lives
|
|
4
|
+
* only in the environment, so a database dump alone reveals nothing.
|
|
5
|
+
* docs/webhooks.md.
|
|
6
|
+
*/
|
|
7
|
+
import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto'
|
|
8
|
+
|
|
9
|
+
export const SECRET_KEY_VARIABLE = 'CROMBOLO_SECRET_KEY'
|
|
10
|
+
export const MIN_SECRET_KEY_LENGTH = 32
|
|
11
|
+
|
|
12
|
+
const VERSION = 'v1'
|
|
13
|
+
|
|
14
|
+
/** The configured key, or undefined when it is missing or too short to trust. */
|
|
15
|
+
export function usableSecretKey(value: string | undefined): string | undefined {
|
|
16
|
+
return value && value.length >= MIN_SECRET_KEY_LENGTH ? value : undefined
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const keyBytes = (key: string) => createHash('sha256').update(key, 'utf8').digest()
|
|
20
|
+
|
|
21
|
+
export function sealSecret(plain: string, key: string): string {
|
|
22
|
+
const iv = randomBytes(12)
|
|
23
|
+
const cipher = createCipheriv('aes-256-gcm', keyBytes(key), iv)
|
|
24
|
+
const body = Buffer.concat([cipher.update(plain, 'utf8'), cipher.final()])
|
|
25
|
+
return `${VERSION}:${Buffer.concat([iv, cipher.getAuthTag(), body]).toString('base64')}`
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Throws when the value was sealed with another key, or has been altered. */
|
|
29
|
+
export function openSecret(sealed: string, key: string): string {
|
|
30
|
+
const [version, encoded] = sealed.split(':', 2)
|
|
31
|
+
if (version !== VERSION || !encoded) throw new Error('Not a sealed secret.')
|
|
32
|
+
const raw = Buffer.from(encoded, 'base64')
|
|
33
|
+
const decipher = createDecipheriv('aes-256-gcm', keyBytes(key), raw.subarray(0, 12))
|
|
34
|
+
decipher.setAuthTag(raw.subarray(12, 28))
|
|
35
|
+
return Buffer.concat([decipher.update(raw.subarray(28)), decipher.final()]).toString('utf8')
|
|
36
|
+
}
|