@nuxt-customer-portal/core 0.10.2 → 0.11.0

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/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # @nuxt-customer-portal/core
2
2
 
3
+ ## 0.11.0
4
+
5
+ ### Patch Changes
6
+
7
+ - b604141: Return actionable encryption configuration errors when saving a portal email API key, display the explanation in email settings, and log server failures through Nitro with secret-safe structured diagnostics, including database migration and connectivity hints.
8
+
9
+ ## 0.10.3
10
+
3
11
  ## 0.10.2
4
12
 
5
13
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nuxt-customer-portal/core",
3
- "version": "0.10.2",
3
+ "version": "0.11.0",
4
4
  "license": "MIT",
5
5
  "homepage": "https://nuxt-customer-portal.com",
6
6
  "repository": {
@@ -1,6 +1,6 @@
1
1
  export default {
2
2
  id: 'core',
3
- version: '0.10.2',
3
+ version: '0.11.0',
4
4
  source: '@nuxt-customer-portal/core',
5
5
  dependsOn: [],
6
6
  schema: './server/db/schema/index.ts',
@@ -0,0 +1,7 @@
1
+ import { logPortalServerError } from '../utils/server-error-logging'
2
+
3
+ export default defineNitroPlugin((nitroApp) => {
4
+ nitroApp.hooks.hook('error', (error, { event }) => {
5
+ logPortalServerError(error, event)
6
+ })
7
+ })
@@ -16,7 +16,8 @@ import {
16
16
  decryptLegacyDotSecret,
17
17
  decryptPortalSecret,
18
18
  encryptPortalSecret,
19
- isPortalSecretCiphertext
19
+ isPortalSecretCiphertext,
20
+ PortalEncryptionConfigurationError
20
21
  } from './portal-encryption'
21
22
 
22
23
  export type PortalEmailTextOverrides = Record<string, Partial<PortalEmailText>>
@@ -159,7 +160,21 @@ const emailEncryption = {
159
160
  overrides: [{ env: 'PORTAL_EMAIL_ENCRYPTION_KEY', format: 'sha256' as const }]
160
161
  }
161
162
 
162
- export const encryptPortalEmailSecret = (value: string) => encryptPortalSecret(value, emailEncryption)
163
+ export const encryptPortalEmailSecret = (value: string) => {
164
+ try {
165
+ return encryptPortalSecret(value, emailEncryption)
166
+ } catch (error) {
167
+ if (error instanceof PortalEncryptionConfigurationError) {
168
+ throw createError({
169
+ statusCode: 503,
170
+ statusMessage: 'Email credential encryption is not configured correctly',
171
+ data: { code: 'EMAIL_ENCRYPTION_CONFIGURATION', message: error.message },
172
+ cause: error
173
+ })
174
+ }
175
+ throw error
176
+ }
177
+ }
163
178
 
164
179
  export const decryptPortalEmailSecret = (value: string) => {
165
180
  try {
@@ -13,14 +13,18 @@ export type PortalEncryptionOptions = {
13
13
  const VERSION = 'v2'
14
14
  const ROOT_KEY_ENV = 'PORTAL_ENCRYPTION_KEY'
15
15
 
16
+ export class PortalEncryptionConfigurationError extends Error {
17
+ override name = 'PortalEncryptionConfigurationError'
18
+ }
19
+
16
20
  const decodeBase64Key = (value: string, name: string) => {
17
21
  const normalized = value.trim()
18
22
  if (!/^[A-Za-z0-9+/_-]+={0,2}$/.test(normalized)) {
19
- throw new Error(`${name} must be a base64-encoded 32-byte key`)
23
+ throw new PortalEncryptionConfigurationError(`${name} must be a base64-encoded 32-byte key`)
20
24
  }
21
25
  const key = Buffer.from(normalized, normalized.includes('-') || normalized.includes('_') ? 'base64url' : 'base64')
22
26
  if (key.length !== 32) {
23
- throw new Error(`${name} must be a base64-encoded 32-byte key`)
27
+ throw new PortalEncryptionConfigurationError(`${name} must be a base64-encoded 32-byte key`)
24
28
  }
25
29
  return key
26
30
  }
@@ -58,7 +62,7 @@ const resolvePortalEncryptionMaterial = ({ purpose, overrides = [] }: PortalEncr
58
62
 
59
63
  const rootValue = process.env.PORTAL_ENCRYPTION_KEY
60
64
  if (!rootValue) {
61
- throw new Error(missingKeyMessage(overrides))
65
+ throw new PortalEncryptionConfigurationError(missingKeyMessage(overrides))
62
66
  }
63
67
  const rootKey = decodeBase64Key(rootValue, ROOT_KEY_ENV)
64
68
  return {
@@ -74,7 +78,7 @@ const materialForSource = (source: string, options: PortalEncryptionOptions) =>
74
78
  if (source === ROOT_KEY_ENV) {
75
79
  const rootValue = process.env.PORTAL_ENCRYPTION_KEY
76
80
  if (!rootValue) {
77
- throw new Error(`Configure ${ROOT_KEY_ENV} to decrypt this credential`)
81
+ throw new PortalEncryptionConfigurationError(`Configure ${ROOT_KEY_ENV} to decrypt this credential`)
78
82
  }
79
83
  const rootKey = decodeBase64Key(rootValue, ROOT_KEY_ENV)
80
84
  return Buffer.from(hkdfSync('sha256', rootKey, Buffer.alloc(0), `nuxt-customer-portal:${options.purpose}`, 32))
@@ -85,7 +89,7 @@ const materialForSource = (source: string, options: PortalEncryptionOptions) =>
85
89
  }
86
90
  const key = overrideKey(override)
87
91
  if (!key) {
88
- throw new Error(`Configure ${source} to decrypt this credential`)
92
+ throw new PortalEncryptionConfigurationError(`Configure ${source} to decrypt this credential`)
89
93
  }
90
94
  return key
91
95
  }
@@ -137,7 +141,9 @@ const requireLegacyOverrideKey = (overrides: PortalEncryptionOverride[]) => {
137
141
  if (material) {
138
142
  return material.key
139
143
  }
140
- throw new Error(`Retain ${overrides.map(({ env }) => env).join(' or ')} to decrypt legacy credentials`)
144
+ throw new PortalEncryptionConfigurationError(
145
+ `Retain ${overrides.map(({ env }) => env).join(' or ')} to decrypt legacy credentials`
146
+ )
141
147
  }
142
148
 
143
149
  export const decryptLegacyDotSecret = (value: string, overrides: PortalEncryptionOverride[]) => {
@@ -0,0 +1,53 @@
1
+ import type { H3Event } from 'h3'
2
+ import { PortalEncryptionConfigurationError } from './portal-encryption'
3
+
4
+ const diagnostics: Record<string, string> = {
5
+ '42P01': 'Database table is missing; check package migrations',
6
+ '42703': 'Database column is missing; check package migrations',
7
+ '23503': 'Database foreign-key constraint failed',
8
+ '23505': 'Database unique constraint failed',
9
+ '42501': 'Database permissions are insufficient',
10
+ '28P01': 'Database authentication failed',
11
+ ECONNREFUSED: 'A required service refused the connection',
12
+ ETIMEDOUT: 'A required service timed out',
13
+ ENOTFOUND: 'A required service hostname could not be resolved'
14
+ }
15
+
16
+ const record = (value: unknown): Record<string, unknown> =>
17
+ value && typeof value === 'object' ? (value as Record<string, unknown>) : {}
18
+
19
+ const diagnostic = (error: unknown) => {
20
+ let current = error
21
+ for (let depth = 0; depth < 6; depth++) {
22
+ if (current instanceof PortalEncryptionConfigurationError) {
23
+ return { code: 'PORTAL_ENCRYPTION_CONFIGURATION', message: current.message }
24
+ }
25
+ const { code, cause } = record(current)
26
+ if (typeof code === 'string' && Object.hasOwn(diagnostics, code)) {
27
+ return { code, message: diagnostics[code] }
28
+ }
29
+ current = cause
30
+ }
31
+ return { code: 'SERVER_ERROR', message: 'Unexpected server error' }
32
+ }
33
+
34
+ export const logPortalServerError = (
35
+ error: unknown,
36
+ event?: H3Event,
37
+ write: (message: string) => void = console.error
38
+ ) => {
39
+ const value = record(error).statusCode
40
+ const statusCode = typeof value === 'number' && value >= 400 && value <= 599 ? value : 500
41
+ if (statusCode < 500) {
42
+ return
43
+ }
44
+ write(
45
+ JSON.stringify({
46
+ event: 'portal.server_error',
47
+ method: event?.method ?? null,
48
+ path: event?.path.split('?')[0] ?? null,
49
+ statusCode,
50
+ ...diagnostic(error)
51
+ })
52
+ )
53
+ }