@nuxt-customer-portal/core 0.3.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.
Files changed (66) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/LICENSE +21 -0
  3. package/README.md +35 -0
  4. package/app/composables/useAutoPagination.ts +30 -0
  5. package/app/composables/useOrganization.ts +117 -0
  6. package/app/composables/usePaginatedResource.ts +167 -0
  7. package/app/composables/usePortalFeatures.ts +74 -0
  8. package/app/composables/usePortalSession.ts +20 -0
  9. package/app/composables/useUserStore.ts +460 -0
  10. package/app/middleware/auth.global.ts +32 -0
  11. package/app/plugins/auth.ts +28 -0
  12. package/app/plugins/portal-core-feature.ts +3 -0
  13. package/app/types/index.d.ts +75 -0
  14. package/app/utils/auth-client.ts +31 -0
  15. package/app/utils/permission-utils.ts +21 -0
  16. package/app/utils/random.ts +9 -0
  17. package/i18n/locales/en.json +81 -0
  18. package/i18n/locales/nl.json +81 -0
  19. package/migrations/0000_baseline.sql +119 -0
  20. package/migrations/0001_organization_types.sql +12 -0
  21. package/migrations/0002_invitation_created_at.sql +1 -0
  22. package/migrations/0003_system_admin_role.sql +5 -0
  23. package/migrations/0004_provider_organization_type.sql +16 -0
  24. package/migrations/0005_portal_email_settings.sql +15 -0
  25. package/migrations/meta/0000_snapshot.json +880 -0
  26. package/migrations/meta/_journal.json +13 -0
  27. package/nuxt.config.ts +55 -0
  28. package/package.json +66 -0
  29. package/portal.manifest.mjs +8 -0
  30. package/server/api/auth/[...].ts +18 -0
  31. package/server/api/auth/permissions.get.ts +64 -0
  32. package/server/api/notifications.ts +294 -0
  33. package/server/db/core-schema.ts +1 -0
  34. package/server/db/schema/auth-schema.ts +198 -0
  35. package/server/db/schema/index.ts +1 -0
  36. package/server/middleware/api-docs-auth.ts +26 -0
  37. package/server/middleware/auth.ts +61 -0
  38. package/server/portal.ts +212 -0
  39. package/server/routes/api-docs/openapi.json.get.ts +22 -0
  40. package/server/utils/admin-email-allowlist.ts +12 -0
  41. package/server/utils/auth.ts +326 -0
  42. package/server/utils/business-hooks.ts +28 -0
  43. package/server/utils/db.ts +18 -0
  44. package/server/utils/email-template.html +105 -0
  45. package/server/utils/email-texts.ts +110 -0
  46. package/server/utils/email.ts +37 -0
  47. package/server/utils/invitation-management.ts +29 -0
  48. package/server/utils/openapi-auth.ts +67 -0
  49. package/server/utils/openapi-contracts.ts +129 -0
  50. package/server/utils/openapi-order.ts +136 -0
  51. package/server/utils/permissions.ts +179 -0
  52. package/server/utils/portal-email.ts +424 -0
  53. package/server/utils/query-builder.ts +270 -0
  54. package/server/utils/route-meta.ts +28 -0
  55. package/shared/core-feature.ts +152 -0
  56. package/shared/feature-registry.ts +62 -0
  57. package/shared/invitation-validation.ts +8 -0
  58. package/shared/organization-avatar.ts +11 -0
  59. package/shared/permissions.ts +26 -0
  60. package/shared/portal-session.ts +17 -0
  61. package/shared/types/feature.ts +130 -0
  62. package/shared/types/index.ts +40 -0
  63. package/shared/types/sections/api.ts +134 -0
  64. package/shared/types/sections/auth.ts +71 -0
  65. package/shared/types/sections/database.ts +86 -0
  66. package/shared/types/sections/queryBuilder.ts +31 -0
@@ -0,0 +1,198 @@
1
+ // Core authentication and organization schema owned by portal core.
2
+
3
+ import { sql } from 'drizzle-orm'
4
+ import { pgTable, text, timestamp, boolean, check, index, uniqueIndex, jsonb } from 'drizzle-orm/pg-core'
5
+
6
+ export const user = pgTable(
7
+ 'user',
8
+ {
9
+ id: text('id').primaryKey(),
10
+ name: text('name').notNull(),
11
+ email: text('email').notNull().unique(),
12
+ emailVerified: boolean('email_verified').default(false).notNull(),
13
+ image: text('image'),
14
+ createdAt: timestamp('created_at').defaultNow().notNull(),
15
+ updatedAt: timestamp('updated_at')
16
+ .defaultNow()
17
+ .$onUpdate(() => /* @__PURE__ */ new Date())
18
+ .notNull(),
19
+ role: text('role').default('user').notNull(),
20
+ banned: boolean('banned').default(false),
21
+ banReason: text('ban_reason'),
22
+ banExpires: timestamp('ban_expires')
23
+ },
24
+ (table) => [index('user_name_idx').on(table.name), uniqueIndex('user_email_idx').on(table.email)]
25
+ )
26
+
27
+ export const session = pgTable(
28
+ 'session',
29
+ {
30
+ id: text('id').primaryKey(),
31
+ expiresAt: timestamp('expires_at').notNull(),
32
+ token: text('token').notNull().unique(),
33
+ createdAt: timestamp('created_at').defaultNow().notNull(),
34
+ updatedAt: timestamp('updated_at')
35
+ .$onUpdate(() => /* @__PURE__ */ new Date())
36
+ .notNull(),
37
+ ipAddress: text('ip_address'),
38
+ userAgent: text('user_agent'),
39
+ userId: text('user_id')
40
+ .notNull()
41
+ .references(() => user.id, { onDelete: 'cascade' }),
42
+ activeOrganizationId: text('active_organization_id'),
43
+ impersonatedBy: text('impersonated_by')
44
+ },
45
+ (table) => [
46
+ index('session_user_id_idx').on(table.userId),
47
+ index('session_active_organization_id_idx').on(table.activeOrganizationId)
48
+ ]
49
+ )
50
+
51
+ export const account = pgTable(
52
+ 'account',
53
+ {
54
+ id: text('id').primaryKey(),
55
+ accountId: text('account_id').notNull(),
56
+ providerId: text('provider_id').notNull(),
57
+ userId: text('user_id')
58
+ .notNull()
59
+ .references(() => user.id, { onDelete: 'cascade' }),
60
+ accessToken: text('access_token'),
61
+ refreshToken: text('refresh_token'),
62
+ idToken: text('id_token'),
63
+ accessTokenExpiresAt: timestamp('access_token_expires_at'),
64
+ refreshTokenExpiresAt: timestamp('refresh_token_expires_at'),
65
+ scope: text('scope'),
66
+ password: text('password'),
67
+ createdAt: timestamp('created_at').defaultNow().notNull(),
68
+ updatedAt: timestamp('updated_at')
69
+ .$onUpdate(() => /* @__PURE__ */ new Date())
70
+ .notNull()
71
+ },
72
+ (table) => [
73
+ index('account_user_id_idx').on(table.userId),
74
+ index('account_account_id_idx').on(table.accountId),
75
+ index('account_provider_id_idx').on(table.providerId)
76
+ ]
77
+ )
78
+
79
+ export const verification = pgTable(
80
+ 'verification',
81
+ {
82
+ id: text('id').primaryKey(),
83
+ identifier: text('identifier').notNull(),
84
+ value: text('value').notNull(),
85
+ expiresAt: timestamp('expires_at').notNull(),
86
+ createdAt: timestamp('created_at').defaultNow().notNull(),
87
+ updatedAt: timestamp('updated_at')
88
+ .defaultNow()
89
+ .$onUpdate(() => /* @__PURE__ */ new Date())
90
+ .notNull()
91
+ },
92
+ (table) => [
93
+ index('verification_identifier_idx').on(table.identifier),
94
+ index('verification_value_idx').on(table.value)
95
+ ]
96
+ )
97
+
98
+ export const organization = pgTable(
99
+ 'organization',
100
+ {
101
+ id: text('id').primaryKey(),
102
+ name: text('name').notNull(),
103
+ slug: text('slug').notNull().unique(),
104
+ logo: text('logo'),
105
+ createdAt: timestamp('created_at').notNull(),
106
+ metadata: text('metadata'),
107
+ organizationType: text('organization_type').notNull()
108
+ },
109
+ (table) => [
110
+ index('organization_slug_idx').on(table.slug),
111
+ check('organization_type_check', sql`${table.organizationType} IN ('PROVIDER', 'CLIENT')`),
112
+ uniqueIndex('organization_single_provider_uidx')
113
+ .on(table.organizationType)
114
+ .where(sql`${table.organizationType} = 'PROVIDER'`)
115
+ ]
116
+ )
117
+
118
+ export const member = pgTable(
119
+ 'member',
120
+ {
121
+ id: text('id').primaryKey(),
122
+ organizationId: text('organization_id')
123
+ .notNull()
124
+ .references(() => organization.id, { onDelete: 'cascade' }),
125
+ userId: text('user_id')
126
+ .notNull()
127
+ .references(() => user.id, { onDelete: 'cascade' }),
128
+ role: text('role').default('member').notNull(),
129
+ phone: text('phone'),
130
+ jobTitle: text('job_title'),
131
+ createdAt: timestamp('created_at').notNull()
132
+ },
133
+ (table) => [
134
+ index('member_organization_id_idx').on(table.organizationId),
135
+ index('member_user_id_idx').on(table.userId)
136
+ ]
137
+ )
138
+
139
+ export const portalEmailSettings = pgTable('portal_email_settings', {
140
+ id: boolean('id').primaryKey().default(true),
141
+ provider: text('provider').default('RESEND').notNull(),
142
+ encryptedApiKey: text('encrypted_api_key'),
143
+ keyFingerprint: text('key_fingerprint'),
144
+ keyLastFour: text('key_last_four'),
145
+ fromName: text('from_name'),
146
+ fromEmail: text('from_email'),
147
+ defaultLocale: text('default_locale').default('en').notNull(),
148
+ htmlTemplate: text('html_template'),
149
+ textOverrides: jsonb('text_overrides').$type<Record<string, unknown>>().default({}).notNull(),
150
+ configuredById: text('configured_by_id').references(() => user.id, { onDelete: 'set null' }),
151
+ createdAt: timestamp('created_at').defaultNow().notNull(),
152
+ updatedAt: timestamp('updated_at')
153
+ .defaultNow()
154
+ .$onUpdate(() => /* @__PURE__ */ new Date())
155
+ .notNull()
156
+ })
157
+
158
+ // Retained for one compatibility release. Runtime email delivery no longer reads this table.
159
+ export const organizationEmailCredential = pgTable('organization_email_credential', {
160
+ organizationId: text('organization_id')
161
+ .primaryKey()
162
+ .references(() => organization.id, { onDelete: 'cascade' }),
163
+ provider: text('provider').default('RESEND').notNull(),
164
+ apiKey: text('api_key'),
165
+ keyFingerprint: text('key_fingerprint'),
166
+ keyLastFour: text('key_last_four'),
167
+ configuredById: text('configured_by_id').references(() => user.id, { onDelete: 'set null' }),
168
+ removedById: text('removed_by_id').references(() => user.id, { onDelete: 'set null' }),
169
+ removedAt: timestamp('removed_at'),
170
+ createdAt: timestamp('created_at').defaultNow().notNull(),
171
+ updatedAt: timestamp('updated_at')
172
+ .defaultNow()
173
+ .$onUpdate(() => new Date())
174
+ .notNull()
175
+ })
176
+
177
+ export const invitation = pgTable(
178
+ 'invitation',
179
+ {
180
+ id: text('id').primaryKey(),
181
+ organizationId: text('organization_id')
182
+ .notNull()
183
+ .references(() => organization.id, { onDelete: 'cascade' }),
184
+ email: text('email').notNull(),
185
+ role: text('role'),
186
+ status: text('status').default('pending').notNull(),
187
+ expiresAt: timestamp('expires_at').notNull(),
188
+ inviterId: text('inviter_id')
189
+ .notNull()
190
+ .references(() => user.id, { onDelete: 'cascade' }),
191
+ createdAt: timestamp('created_at').defaultNow().notNull()
192
+ },
193
+ (table) => [
194
+ index('invitation_organization_id_idx').on(table.organizationId),
195
+ index('invitation_inviter_id_idx').on(table.inviterId),
196
+ index('invitation_email_idx').on(table.email)
197
+ ]
198
+ )
@@ -0,0 +1 @@
1
+ export * from './auth-schema'
@@ -0,0 +1,26 @@
1
+ import { createError, defineEventHandler, setResponseHeader } from 'h3'
2
+ import { auth } from '../utils/auth'
3
+
4
+ const API_DOCS_PATH = '/api-docs'
5
+
6
+ const isApiDocsRequest = (path: string): boolean => path === API_DOCS_PATH || path.startsWith(`${API_DOCS_PATH}/`)
7
+
8
+ export default defineEventHandler(async (event) => {
9
+ if (!isApiDocsRequest(event.path)) {
10
+ return
11
+ }
12
+
13
+ setResponseHeader(event, 'Cache-Control', 'private, no-store')
14
+
15
+ const session = await auth.api.getSession({
16
+ headers: event.headers
17
+ })
18
+
19
+ if (!session?.user) {
20
+ throw createError({
21
+ statusCode: 401,
22
+ statusMessage: 'Unauthorized',
23
+ message: 'Authentication is required to access the API documentation'
24
+ })
25
+ }
26
+ })
@@ -0,0 +1,61 @@
1
+ import { defineEventHandler, type H3Event, createError } from 'h3'
2
+ import { auth } from '../utils/auth'
3
+
4
+ async function isAuthenticated(event: H3Event): Promise<boolean> {
5
+ const session = await auth.api.getSession({
6
+ headers: event.headers
7
+ })
8
+ if (!session) {
9
+ return false
10
+ }
11
+ return !!session.user
12
+ }
13
+
14
+ export default defineEventHandler(async (event) => {
15
+ const url = event.node.req.url
16
+
17
+ if (!url || !url.startsWith('/api/')) {
18
+ return
19
+ }
20
+ const unprotectedPaths = [
21
+ '/api/auth/',
22
+ '/api/_nuxt_icon',
23
+ '/api/health',
24
+ '/api/portal/bootstrap',
25
+ '/api/portal/public'
26
+ ]
27
+
28
+ const isUnprotected = unprotectedPaths.some((path) => url.startsWith(path))
29
+
30
+ if (isUnprotected) {
31
+ return
32
+ }
33
+
34
+ // Allow specific GET requests for single questionnaires
35
+ if (event.method === 'GET' && url.match(/^\/api\/questionnaires\/[^/]+$/)) {
36
+ return
37
+ }
38
+
39
+ // Allow POST (index), PUT (specific response), and GET (specific response) to questionnaire-responses for anonymous users
40
+ const isQuestionnaireResponseIndex = url === '/api/questionnaire-responses'
41
+ const isSpecificQuestionnaireResponse = url.startsWith('/api/questionnaire-responses/')
42
+
43
+ if (
44
+ (isQuestionnaireResponseIndex && event.method === 'POST') ||
45
+ (isSpecificQuestionnaireResponse && (event.method === 'PUT' || event.method === 'GET'))
46
+ ) {
47
+ return
48
+ }
49
+
50
+ const authenticated = await isAuthenticated(event)
51
+ if (authenticated) {
52
+ return
53
+ }
54
+
55
+ console.warn(`Unauthorized access attempt to: ${url}`)
56
+ throw createError({
57
+ statusCode: 401,
58
+ statusMessage: 'Unauthorized',
59
+ message: 'Authentication required'
60
+ })
61
+ })
@@ -0,0 +1,212 @@
1
+ import type { H3Event } from 'h3'
2
+ import { and, asc, eq, ilike, inArray, ne } from 'drizzle-orm'
3
+ import { nanoid } from 'nanoid'
4
+ import type {
5
+ PortalFeaturePolicy,
6
+ PortalOrganizationRole,
7
+ PortalOrganizationType
8
+ } from '@nuxt-customer-portal/core/shared/types/feature'
9
+ import { isPortalActionAllowed } from '@nuxt-customer-portal/core/shared/feature-registry'
10
+ import { getActiveOrganizationId, type PortalSession } from '@nuxt-customer-portal/core/shared/portal-session'
11
+ import { member, organization, user } from './db/schema/auth-schema'
12
+ import { auth } from '@nuxt-customer-portal/core/server/utils/auth'
13
+ import { db } from '@nuxt-customer-portal/core/server/utils/db'
14
+ import { getUserOrganizationRole } from '@nuxt-customer-portal/core/server/utils/permissions'
15
+
16
+ export { db }
17
+ export type { PortalSession } from '@nuxt-customer-portal/core/shared/portal-session'
18
+
19
+ export const getSession = async (event: H3Event): Promise<PortalSession | null> => {
20
+ const session = await auth.api.getSession({ headers: event.headers })
21
+ return session as PortalSession | null
22
+ }
23
+
24
+ export const requireSession = async (event: H3Event): Promise<PortalSession> => {
25
+ const session = await getSession(event)
26
+ if (!session?.user) {
27
+ throw createError({ statusCode: 401, message: 'Unauthorized' })
28
+ }
29
+ return session
30
+ }
31
+
32
+ export const requireActiveOrganization = (session: PortalSession): string => {
33
+ // Better Auth has returned both shapes across versions/configurations.
34
+ // Keep the layer adapter compatible with either so feature APIs do not
35
+ // lose their tenant context during a layer or auth upgrade.
36
+ const organizationId = getActiveOrganizationId(session)
37
+ if (!organizationId) {
38
+ throw createError({ statusCode: 400, message: 'No active organization' })
39
+ }
40
+ return organizationId
41
+ }
42
+
43
+ export const getPortalOrganization = async (organizationId: string) => {
44
+ const [selected] = await db.select().from(organization).where(eq(organization.id, organizationId)).limit(1)
45
+ return selected ?? null
46
+ }
47
+
48
+ export const requireOrganizationContext = async (session: PortalSession) => {
49
+ const organizationId = requireActiveOrganization(session)
50
+ const [selected, role] = await Promise.all([
51
+ getPortalOrganization(organizationId),
52
+ getUserOrganizationRole(session.user.id, organizationId) as Promise<PortalOrganizationRole | null>
53
+ ])
54
+ if (!selected || !role) {
55
+ throw createError({ statusCode: 403, message: 'Organization membership required' })
56
+ }
57
+ return {
58
+ organizationId,
59
+ organizationType: selected.organizationType as PortalOrganizationType,
60
+ organization: selected,
61
+ role
62
+ }
63
+ }
64
+
65
+ export const authorize = async <Action extends string>(
66
+ session: PortalSession,
67
+ organizationId: string,
68
+ policy: PortalFeaturePolicy<Action>,
69
+ action: Action
70
+ ): Promise<void> => {
71
+ const [role, selected] = await Promise.all([
72
+ getUserOrganizationRole(session.user.id, organizationId) as Promise<PortalOrganizationRole | null>,
73
+ getPortalOrganization(organizationId)
74
+ ])
75
+ if (!selected || !isPortalActionAllowed(policy, role, action, selected.organizationType as PortalOrganizationType)) {
76
+ throw createError({ statusCode: 403, message: 'Access denied' })
77
+ }
78
+ }
79
+
80
+ export const hasFeatureAccess = async <Action extends string>(
81
+ session: PortalSession,
82
+ organizationId: string,
83
+ policy: PortalFeaturePolicy<Action>,
84
+ action: Action
85
+ ): Promise<boolean> => {
86
+ const [role, selected] = await Promise.all([
87
+ getUserOrganizationRole(session.user.id, organizationId) as Promise<PortalOrganizationRole | null>,
88
+ getPortalOrganization(organizationId)
89
+ ])
90
+ return Boolean(
91
+ selected && isPortalActionAllowed(policy, role, action, selected.organizationType as PortalOrganizationType)
92
+ )
93
+ }
94
+
95
+ export const requireFeatureAccess = async <Action extends string>(
96
+ event: H3Event,
97
+ policy: PortalFeaturePolicy<Action>,
98
+ action: Action
99
+ ) => {
100
+ const session = await requireSession(event)
101
+ const organizationId = requireActiveOrganization(session)
102
+ const [selected, role] = await Promise.all([
103
+ getPortalOrganization(organizationId),
104
+ getUserOrganizationRole(session.user.id, organizationId) as Promise<PortalOrganizationRole | null>
105
+ ])
106
+ if (!selected || !isPortalActionAllowed(policy, role, action, selected.organizationType as PortalOrganizationType)) {
107
+ throw createError({ statusCode: 403, message: 'Access denied' })
108
+ }
109
+ return { session, organizationId, organizationType: selected.organizationType as PortalOrganizationType, role }
110
+ }
111
+
112
+ export const requireActiveOrganizationRole = async (event: H3Event) => {
113
+ const session = await requireSession(event)
114
+ return { session, ...(await requireOrganizationContext(session)) }
115
+ }
116
+
117
+ /**
118
+ * Stable portal-core projections used by feature layers. Keeping these here
119
+ * prevents features from importing host authentication tables at runtime.
120
+ */
121
+ export const listPortalOrganizationMembers = async (organizationId: string) =>
122
+ db
123
+ .select({
124
+ id: user.id,
125
+ name: user.name,
126
+ email: user.email,
127
+ image: user.image,
128
+ organizationRole: member.role,
129
+ phone: member.phone,
130
+ jobTitle: member.jobTitle
131
+ })
132
+ .from(member)
133
+ .innerJoin(user, eq(user.id, member.userId))
134
+ .where(eq(member.organizationId, organizationId))
135
+ .orderBy(asc(user.name), asc(user.email))
136
+
137
+ export const searchPortalOrganizations = async (workspaceOrganizationId: string, search?: string) =>
138
+ db
139
+ .select({
140
+ id: organization.id,
141
+ name: organization.name,
142
+ slug: organization.slug,
143
+ logo: organization.logo,
144
+ metadata: organization.metadata,
145
+ organizationType: organization.organizationType
146
+ })
147
+ .from(organization)
148
+ .where(
149
+ and(ne(organization.id, workspaceOrganizationId), search ? ilike(organization.name, `%${search}%`) : undefined)
150
+ )
151
+ .orderBy(asc(organization.name))
152
+ .limit(50)
153
+
154
+ export const listPortalOrganizationsForUser = async (userId: string) => {
155
+ const selection = {
156
+ id: organization.id,
157
+ name: organization.name,
158
+ slug: organization.slug,
159
+ logo: organization.logo,
160
+ organizationType: organization.organizationType
161
+ }
162
+ return db
163
+ .select(selection)
164
+ .from(member)
165
+ .innerJoin(organization, eq(organization.id, member.organizationId))
166
+ .where(eq(member.userId, userId))
167
+ .orderBy(asc(organization.name))
168
+ }
169
+
170
+ export const getPortalOrganizationsByIds = async (organizationIds: string[]) => {
171
+ if (!organizationIds.length) {
172
+ return []
173
+ }
174
+ return db
175
+ .select({
176
+ id: organization.id,
177
+ name: organization.name,
178
+ slug: organization.slug,
179
+ logo: organization.logo,
180
+ metadata: organization.metadata,
181
+ organizationType: organization.organizationType
182
+ })
183
+ .from(organization)
184
+ .where(inArray(organization.id, organizationIds))
185
+ .orderBy(asc(organization.name))
186
+ }
187
+
188
+ export const createPortalOrganizationRecord = async (input: {
189
+ name: string
190
+ slug: string
191
+ logo?: string | null
192
+ organizationType?: PortalOrganizationType
193
+ }) => {
194
+ const [created] = await db
195
+ .insert(organization)
196
+ .values({
197
+ id: nanoid(),
198
+ name: input.name,
199
+ slug: input.slug,
200
+ logo: input.logo ?? null,
201
+ organizationType: input.organizationType ?? 'CLIENT',
202
+ createdAt: new Date()
203
+ })
204
+ .returning({
205
+ id: organization.id,
206
+ name: organization.name,
207
+ slug: organization.slug,
208
+ logo: organization.logo,
209
+ organizationType: organization.organizationType
210
+ })
211
+ return created
212
+ }
@@ -0,0 +1,22 @@
1
+ import { fetchWithEvent, getRequestURL } from 'h3'
2
+ import { auth } from '../../utils/auth'
3
+ import { mergeBetterAuthOpenApi } from '../../utils/openapi-auth'
4
+ import { enrichOpenApiContracts } from '../../utils/openapi-contracts'
5
+ import { orderOpenApiDocument, type OpenApiDocument } from '../../utils/openapi-order'
6
+
7
+ defineRouteMeta({
8
+ openAPI: {
9
+ tags: ['Internal'],
10
+ summary: 'Get the ordered OpenAPI document',
11
+ description:
12
+ 'Returns the generated OpenAPI document in a stable, domain-oriented order for the documentation interfaces.',
13
+ 'x-scalar-ignore': true
14
+ }
15
+ })
16
+
17
+ export default defineEventHandler(async (event) => {
18
+ const response = await fetchWithEvent(event, new URL('/api-docs/openapi.raw.json', getRequestURL(event)))
19
+ const document = (await response.json()) as OpenApiDocument
20
+ const authDocument = (await auth.api.generateOpenAPISchema()) as unknown as OpenApiDocument
21
+ return orderOpenApiDocument(enrichOpenApiContracts(mergeBetterAuthOpenApi(document, authDocument)))
22
+ })
@@ -0,0 +1,12 @@
1
+ export function parseSystemAdminEmails(value: string | undefined) {
2
+ return new Set(
3
+ (value || '')
4
+ .split(',')
5
+ .map((email) => email.trim().toLowerCase())
6
+ .filter(Boolean)
7
+ )
8
+ }
9
+
10
+ export function isSystemAdminEmail(email: string | null | undefined, allowlist: ReadonlySet<string>) {
11
+ return Boolean(email && allowlist.has(email.trim().toLowerCase()))
12
+ }