@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.
Files changed (46) hide show
  1. package/package.json +12 -11
  2. package/src/adapters/bundles.ts +2 -1
  3. package/src/adapters/content.ts +2 -0
  4. package/src/adapters/documents.ts +28 -25
  5. package/src/adapters/elements.ts +3 -0
  6. package/src/adapters/health.ts +333 -0
  7. package/src/adapters/media.ts +3 -0
  8. package/src/adapters/members.ts +10 -2
  9. package/src/adapters/references.ts +13 -0
  10. package/src/adapters/relations.ts +48 -0
  11. package/src/adapters/schema-files.ts +41 -11
  12. package/src/adapters/schema-proposals.ts +13 -6
  13. package/src/adapters/webhooks.ts +76 -0
  14. package/src/approvals-api.ts +189 -0
  15. package/src/approvals.ts +823 -0
  16. package/src/boot-failure.ts +147 -0
  17. package/src/bundle-install.ts +148 -13
  18. package/src/bundles.ts +14 -0
  19. package/src/busy.ts +13 -0
  20. package/src/config.ts +54 -2
  21. package/src/database.ts +9 -7
  22. package/src/delivery-members.ts +387 -0
  23. package/src/editor-types.ts +7 -2
  24. package/src/extensions-notice.ts +3 -2
  25. package/src/form-files.ts +38 -3
  26. package/src/form-submit.ts +62 -5
  27. package/src/form-types.ts +187 -0
  28. package/src/form-workflows.ts +66 -6
  29. package/src/forms.ts +15 -7
  30. package/src/index.ts +11 -0
  31. package/src/jobs.ts +17 -0
  32. package/src/marketplace.ts +18 -0
  33. package/src/media-files.ts +3 -1
  34. package/src/member-auth.ts +17 -1
  35. package/src/models-builder.ts +12 -8
  36. package/src/ports.ts +66 -2
  37. package/src/schema.ts +58 -11
  38. package/src/secrets.ts +36 -0
  39. package/src/server.ts +388 -57
  40. package/src/setup.ts +288 -0
  41. package/src/status.ts +5 -2
  42. package/src/transfer-dashboard.ts +219 -0
  43. package/src/upgrade-summary.ts +43 -0
  44. package/src/url-settings.ts +91 -0
  45. package/src/webhook-files.ts +138 -0
  46. package/src/webhooks.ts +544 -0
@@ -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 { CromboloConfig } from './config.ts'
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
- /** Polls `cache_instruction` from where this node came in; own instructions are skipped. */
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
- /** Returns how many foreign instructions were applied. */
294
- async poll(): Promise<number> {
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, { nodeId, ...nodeState, role: this.#options.role })
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).set.version
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
+ }