@nuxt-customer-portal/core 0.3.0 → 0.3.2

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.3.2
4
+
5
+ ### Patch Changes
6
+
7
+ - f23f664: Add opt-in server restrictions for shared demo deployments, protecting accounts and access settings and preventing all outgoing email. The private example apps now support anonymous sample identities, shared historical business data, user switching, and transactional daily resets at midnight Europe/Amsterdam.
8
+
9
+ ## 0.3.1
10
+
3
11
  ## 0.3.0
4
12
 
5
13
  Declare the Nuxt UI dependency used by the package's public components and types,
package/nuxt.config.ts CHANGED
@@ -18,6 +18,7 @@ export default defineNuxtConfig({
18
18
  ]
19
19
  },
20
20
  runtimeConfig: {
21
+ portalDemo: { enabled: false },
21
22
  portalEmail: {
22
23
  templateStorage: 'assets:portal-core',
23
24
  brandingSource: 'runtime',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nuxt-customer-portal/core",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
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.3.0',
3
+ version: '0.3.2',
4
4
  source: '@nuxt-customer-portal/core',
5
5
  dependsOn: [],
6
6
  schema: './server/db/schema/index.ts',
@@ -0,0 +1,9 @@
1
+ import { defineEventHandler } from 'h3'
2
+ import { isPortalDemo, rejectDemoAction } from '../utils/demo'
3
+ import { isDemoRequestAllowed } from '../../shared/demo-policy'
4
+
5
+ export default defineEventHandler((event) => {
6
+ if (isPortalDemo() && !isDemoRequestAllowed(event.method, event.path)) {
7
+ rejectDemoAction(event)
8
+ }
9
+ })
@@ -1,3 +1,5 @@
1
+ import { isPortalDemo } from './demo'
2
+ import { demoMessage, isDemoRequestAllowed } from '../../shared/demo-policy'
1
3
  import { betterAuth } from 'better-auth'
2
4
  import { APIError, createAuthMiddleware, getSessionFromCtx } from 'better-auth/api'
3
5
  import { drizzleAdapter } from 'better-auth/adapters/drizzle'
@@ -35,7 +37,7 @@ const registrationMode = ['open', 'invitation-only', 'disabled'].includes(proces
35
37
  : portalAuthConfig.registrationMode
36
38
  const githubEnabled = envFlag(process.env.PORTAL_GITHUB_ENABLED, portalAuthConfig.githubEnabled)
37
39
  const googleEnabled = envFlag(process.env.PORTAL_GOOGLE_ENABLED, portalAuthConfig.googleEnabled)
38
- const systemAdminEmails = parseSystemAdminEmails(process.env.ADMIN_EMAILS)
40
+ const systemAdminEmails = isPortalDemo() ? new Set<string>() : parseSystemAdminEmails(process.env.ADMIN_EMAILS)
39
41
 
40
42
  export const auth = betterAuth({
41
43
  baseURL: process.env.BETTER_AUTH_URL || process.env.PUBLIC_URL || 'http://localhost:3051',
@@ -55,6 +57,15 @@ export const auth = betterAuth({
55
57
  }),
56
58
  hooks: {
57
59
  before: createAuthMiddleware(async (ctx) => {
60
+ if (
61
+ isPortalDemo() &&
62
+ !isDemoRequestAllowed(ctx.request?.method || (ctx.body ? 'POST' : 'GET'), `/api/auth${ctx.path}`)
63
+ ) {
64
+ throw new APIError('FORBIDDEN', {
65
+ code: 'DEMO_RESTRICTED',
66
+ message: demoMessage(ctx.headers?.get('accept-language') || '')
67
+ })
68
+ }
58
69
  if (ctx.path !== '/organization/list-members' && ctx.path !== '/organization/list-invitations') {
59
70
  return
60
71
  }
@@ -0,0 +1,13 @@
1
+ import { createError, getCookie, getHeader, type H3Event } from 'h3'
2
+ import { demoMessage } from '../../shared/demo-policy'
3
+
4
+ export const isPortalDemo = () => useRuntimeConfig().portalDemo?.enabled === true
5
+ export function rejectDemoAction(event?: H3Event): never {
6
+ const message = demoMessage(event ? getCookie(event, 'i18n_redirected') || getHeader(event, 'accept-language') : '')
7
+ throw createError({
8
+ statusCode: 403,
9
+ statusMessage: 'Demo action unavailable',
10
+ message,
11
+ data: { code: 'DEMO_RESTRICTED', message }
12
+ })
13
+ }
@@ -1,3 +1,4 @@
1
+ import { isPortalDemo, rejectDemoAction } from './demo'
1
2
  import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto'
2
3
  import { eq } from 'drizzle-orm'
3
4
  import { createError } from 'h3'
@@ -302,6 +303,9 @@ const providerConfiguration = async () => {
302
303
  }
303
304
 
304
305
  export const getPortalEmailProviderStatus = async () => {
306
+ if (isPortalDemo()) {
307
+ rejectDemoAction()
308
+ }
305
309
  const settings = await getPortalEmailSettings()
306
310
  if (!settings.configured) {
307
311
  return { ...settings, verifiedDomains: [] as string[] }
@@ -389,6 +393,9 @@ export const sendPortalEmail = async (input: {
389
393
  attachments?: PortalEmailAttachment[]
390
394
  idempotencyKey?: string
391
395
  }) => {
396
+ if (isPortalDemo()) {
397
+ rejectDemoAction()
398
+ }
392
399
  const [rendered, provider] = await Promise.all([
393
400
  renderPortalEmail({ ...input, inlineBrandAssets: true }),
394
401
  providerConfiguration()
@@ -415,6 +422,9 @@ export const sendPortalEmail = async (input: {
415
422
  }
416
423
 
417
424
  export const retrievePortalEmail = async (providerMessageId: string) => {
425
+ if (isPortalDemo()) {
426
+ rejectDemoAction()
427
+ }
418
428
  const provider = await providerConfiguration()
419
429
  const { data, error } = await new Resend(provider.apiKey).emails.get(providerMessageId)
420
430
  if (error) {
@@ -0,0 +1,75 @@
1
+ export const demoMessages = {
2
+ en: 'This action is unavailable in the demo. No changes have been applied.',
3
+ nl: 'Deze actie is niet beschikbaar in de demo. Er zijn geen wijzigingen toegepast.'
4
+ } as const
5
+
6
+ export function demoMessage(locale = '') {
7
+ return demoMessages[locale.toLowerCase().startsWith('nl') ? 'nl' : 'en']
8
+ }
9
+
10
+ // Explicit allowlists keep newly introduced account endpoints closed in demos.
11
+ export function isDemoRequestAllowed(method: string, rawPath: string): boolean {
12
+ let path: string
13
+ try {
14
+ path = decodeURIComponent(rawPath.split('?')[0]!).replace(/\/+$/, '')
15
+ } catch {
16
+ return false
17
+ }
18
+ if (path.includes('\\') || path.split('/').some((part) => part === '..' || part === '.')) {
19
+ return false
20
+ }
21
+ const read = method === 'GET' || method === 'HEAD'
22
+ if (path.startsWith('/api/auth/')) {
23
+ const route = path.slice('/api/auth/'.length)
24
+ if (read) {
25
+ return [
26
+ 'get-session',
27
+ 'open-api/generate-schema',
28
+ 'permissions',
29
+ 'list-sessions',
30
+ 'list-accounts',
31
+ 'organization/list',
32
+ 'organization/get-full-organization',
33
+ 'organization/list-members',
34
+ 'organization/list-invitations',
35
+ 'organization/get-active-member',
36
+ 'organization/get-active-member-role',
37
+ 'organization/get-invitation',
38
+ 'admin/list-users',
39
+ 'admin/get-user',
40
+ 'admin/list-user-sessions'
41
+ ].includes(route)
42
+ }
43
+ return (
44
+ method === 'POST' &&
45
+ ['organization/set-active', 'organization/has-permission', 'admin/has-permission'].includes(route)
46
+ )
47
+ }
48
+ if (!path.startsWith('/api/')) {
49
+ return true
50
+ }
51
+ if (read) {
52
+ return true
53
+ }
54
+ if (path === '/api/demo/switch') {
55
+ return method === 'POST'
56
+ }
57
+ if (
58
+ /\/(members|invitations|reviewers|viewers|modules|internal-approvals|team|organization-capabilities|client-settings)(\/|$)/.test(
59
+ path
60
+ )
61
+ ) {
62
+ // Submitting an approval is business activity; configuring approvers changes access.
63
+ return method === 'POST' && /^\/api\/timesheets\/internal-approvals\/[^/]+$/.test(path)
64
+ }
65
+ if (/^\/api\/(profile|auth|admin|organizations)(\/|$)/.test(path)) {
66
+ return false
67
+ }
68
+ if (/^\/api\/timesheets\/admin\/(clients|settings)(\/|$)/.test(path) || path === '/api/invoices/admin/settings') {
69
+ return false
70
+ }
71
+ if (/\/(email|email-status|reminder)(\/|$)/.test(path)) {
72
+ return false
73
+ }
74
+ return /^\/api\/(clients|service-requests|timesheets|invoices|invoice-timesheets)(\/|$)/.test(path)
75
+ }