@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.
- package/package.json +12 -11
- 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/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 +33 -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/media-files.ts +3 -1
- package/src/member-auth.ts +17 -1
- package/src/models-builder.ts +12 -8
- package/src/ports.ts +51 -2
- package/src/schema.ts +58 -11
- package/src/secrets.ts +36 -0
- package/src/server.ts +315 -27
- 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/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,12 @@ 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'
|
|
66
73
|
import { COMPONENT_SNIPPETS } from './component-snippets.ts'
|
|
67
74
|
import { type CromboloConfig, type UserLinkSender, VERSION } from './config.ts'
|
|
68
75
|
import { type EmailPort, resolveEmailPort } from './email.ts'
|
|
69
76
|
import { createFileIntake } from './file-intake.ts'
|
|
77
|
+
import { formTypesFor } from './form-types.ts'
|
|
70
78
|
import { createLogViewerPort } from './log-viewer.ts'
|
|
71
79
|
import { logger } from './logging.ts'
|
|
72
80
|
import { MediaFileStore } from './media-files.ts'
|
|
@@ -75,6 +83,7 @@ import { createOEmbedService } from './oembed.ts'
|
|
|
75
83
|
import { createRedirectTracker } from './redirects.ts'
|
|
76
84
|
import { componentAliasesIn, type SchemaBoot } from './schema.ts'
|
|
77
85
|
import { publishSchema } from './schema-store.ts'
|
|
86
|
+
import { createWebhookService, type WebhookService } from './webhooks.ts'
|
|
78
87
|
|
|
79
88
|
/** Re-exported for tests; the canonical list lives in @crombolo/core. */
|
|
80
89
|
export const CORE_SECTIONS = CORE_SECTION_ALIASES
|
|
@@ -111,6 +120,18 @@ function createServerPort(config: CromboloConfig, canSendUserLinks: boolean): Se
|
|
|
111
120
|
}
|
|
112
121
|
}
|
|
113
122
|
|
|
123
|
+
/** Umbraco's preview cookie; with a signed-in editor it turns on draft rendering. */
|
|
124
|
+
export const PREVIEW_COOKIE = 'UMB_PREVIEW'
|
|
125
|
+
|
|
126
|
+
function createPreviewPort(config: CromboloConfig): PreviewPort {
|
|
127
|
+
return {
|
|
128
|
+
enter: () => [
|
|
129
|
+
serializeCookie({ name: PREVIEW_COOKIE, value: 'preview', secure: config.secureCookies }),
|
|
130
|
+
],
|
|
131
|
+
exit: () => [expireCookie(PREVIEW_COOKIE, config.secureCookies)],
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
114
135
|
function createManifestPort(
|
|
115
136
|
paths: BackOfficePaths,
|
|
116
137
|
extensions: ExtensionRegistry,
|
|
@@ -326,12 +347,13 @@ export function resolveUserLinkSender(
|
|
|
326
347
|
}
|
|
327
348
|
|
|
328
349
|
/** Where the backoffice writes schema files, and whether it may. */
|
|
329
|
-
function schemaFiles(config: CromboloConfig): SchemaFiles {
|
|
350
|
+
export function schemaFiles(config: CromboloConfig): SchemaFiles {
|
|
330
351
|
const store = config.schemaStore
|
|
331
352
|
return {
|
|
332
353
|
schemaDir: config.schemaDir,
|
|
333
354
|
writable: config.schemaWritable,
|
|
334
355
|
componentAliases: () => componentAliasesIn(config.componentsDir),
|
|
356
|
+
formTypes: formTypesFor(config),
|
|
335
357
|
// Only when a store is configured: a schema directory in a repository is
|
|
336
358
|
// already where the files durably live.
|
|
337
359
|
onChanged: store
|
|
@@ -359,6 +381,8 @@ export interface DepsOptions {
|
|
|
359
381
|
extensions: ExtensionRegistry
|
|
360
382
|
/** Omitted only by tests that exercise the contract without a database. */
|
|
361
383
|
db?: Db
|
|
384
|
+
/** The site's webhooks, shared with the delivery job and the event source. */
|
|
385
|
+
webhooks?: WebhookService
|
|
362
386
|
/** Required alongside `db` to serve documents, which invalidate the cache. */
|
|
363
387
|
cache?: PublishedCache
|
|
364
388
|
schema?: SchemaBoot
|
|
@@ -406,6 +430,7 @@ export function createDeps(options: DepsOptions): ManagementApiDeps {
|
|
|
406
430
|
const sendUserLink = resolveUserLinkSender(options.config, email)
|
|
407
431
|
const deps: ManagementApiDeps = {
|
|
408
432
|
server: createServerPort(options.config, Boolean(sendUserLink)),
|
|
433
|
+
preview: createPreviewPort(options.config),
|
|
409
434
|
// An unconfigured assistant contributes no extension, so the backoffice has
|
|
410
435
|
// no drawer, no button and nothing to load.
|
|
411
436
|
manifests: createManifestPort(
|
|
@@ -441,6 +466,16 @@ export function createDeps(options: DepsOptions): ManagementApiDeps {
|
|
|
441
466
|
deps.stylesheets = createFileSystemPort(options.config.stylesheetsDir, { extension: '.css' })
|
|
442
467
|
deps.scripts = createFileSystemPort(options.config.scriptsDir, { extension: '.js' })
|
|
443
468
|
if (options.db) {
|
|
469
|
+
const dynamicRoots = new DynamicRootRepository(options.db)
|
|
470
|
+
deps.dynamicRoots = { resolve: (query) => dynamicRoots.resolve(query) }
|
|
471
|
+
deps.diagnostics = createDiagnosticsPort({
|
|
472
|
+
config: options.config,
|
|
473
|
+
db: options.db,
|
|
474
|
+
media: mediaFiles.store,
|
|
475
|
+
email,
|
|
476
|
+
nodeState: options.schema?.nodeState,
|
|
477
|
+
fetch: options.fetch,
|
|
478
|
+
})
|
|
444
479
|
const tags = new TagRepository(options.db)
|
|
445
480
|
deps.tags = { list: (filter) => tags.all(filter) }
|
|
446
481
|
deps.users = createUserItemPort(options.db)
|
|
@@ -493,6 +528,9 @@ export function createDeps(options: DepsOptions): ManagementApiDeps {
|
|
|
493
528
|
nodeState: options.schema?.nodeState,
|
|
494
529
|
nodeId: options.schema?.nodeId,
|
|
495
530
|
valueIntake,
|
|
531
|
+
apiMemberUserNames: resolveDeliveryApiSettings(
|
|
532
|
+
options.config.deliveryApi,
|
|
533
|
+
).memberAuthorization.clientCredentialsFlow.associatedMembers.map((m) => m.userName),
|
|
496
534
|
})
|
|
497
535
|
deps.publicAccess = createPublicAccessPort(options.db, {
|
|
498
536
|
onChange: () => options.cache?.invalidate(),
|
|
@@ -504,6 +542,17 @@ export function createDeps(options: DepsOptions): ManagementApiDeps {
|
|
|
504
542
|
onChange: () => options.cache?.invalidate(),
|
|
505
543
|
})
|
|
506
544
|
deps.references = createReferencePort(options.db)
|
|
545
|
+
deps.relations = createRelationPort(options.db)
|
|
546
|
+
deps.webhooks = createWebhookPort(
|
|
547
|
+
options.webhooks ??
|
|
548
|
+
createWebhookService({
|
|
549
|
+
db: options.db,
|
|
550
|
+
schemaDir: options.config.schemaDir,
|
|
551
|
+
filesWritable: options.config.schemaWritable,
|
|
552
|
+
secretKey: options.config.secretKey,
|
|
553
|
+
bundles: options.config.bundles,
|
|
554
|
+
}),
|
|
555
|
+
)
|
|
507
556
|
deps.redirects = createRedirectPort(options.db, options.cache, {
|
|
508
557
|
isTracking: options.config.trackRedirects,
|
|
509
558
|
onChange: () => options.cache?.invalidate(),
|
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
|
+
}
|