@peanut-admin/admin 0.1.0-alpha.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/LICENSE +202 -0
- package/admin-core/src/access/access.ts +27 -0
- package/admin-core/src/api/client.ts +200 -0
- package/admin-core/src/api/problem.ts +67 -0
- package/admin-core/src/api/refresh.ts +122 -0
- package/admin-core/src/auth/stores.ts +121 -0
- package/admin-core/src/generated/api.d.ts +22106 -0
- package/admin-core/src/governance/audit.ts +56 -0
- package/admin-core/src/governance/catalog.ts +86 -0
- package/admin-core/src/governance/index.ts +26 -0
- package/admin-core/src/governance/menu.ts +63 -0
- package/admin-core/src/governance/roles.ts +144 -0
- package/admin-core/src/governance/types.ts +64 -0
- package/admin-core/src/index.ts +105 -0
- package/admin-core/src/lifecycle/tenant.ts +59 -0
- package/admin-core/src/module/contribution.ts +124 -0
- package/admin-core/src/runtime/config.ts +55 -0
- package/admin-core/src/runtime/errors.ts +81 -0
- package/admin-core/src/runtime/guard.ts +40 -0
- package/admin-core/src/runtime/navigation.ts +105 -0
- package/admin-core/src/runtime/overrides.ts +214 -0
- package/admin-core/src/targets/store.ts +153 -0
- package/admin-shell/src/config.ts +84 -0
- package/admin-shell/src/index.ts +40 -0
- package/admin-shell/src/layout.ts +332 -0
- package/admin-shell/src/overrides.ts +53 -0
- package/admin-shell/src/states.ts +93 -0
- package/admin-shell/src/targets.ts +128 -0
- package/admin-shell/src/theme.ts +15 -0
- package/client-core/src/index.ts +325 -0
- package/client-nuxt/src/index.ts +40 -0
- package/client-uniapp/src/index.ts +50 -0
- package/file-media/src/FileAssetSelector.vue +117 -0
- package/file-media/src/FileMediaPage.vue +158 -0
- package/file-media/src/contracts.ts +220 -0
- package/file-media/src/index.ts +19 -0
- package/file-media/src/runtime.ts +210 -0
- package/import-export/src/ImportExportPage.vue +155 -0
- package/import-export/src/contracts.ts +96 -0
- package/import-export/src/index.ts +3 -0
- package/import-export/src/runtime.ts +128 -0
- package/integration-security/src/IntegrationSecurityPage.vue +402 -0
- package/integration-security/src/contracts.ts +171 -0
- package/integration-security/src/index.ts +3 -0
- package/integration-security/src/runtime.ts +180 -0
- package/notification-sms/src/NotificationInboxPage.vue +266 -0
- package/notification-sms/src/contracts.ts +195 -0
- package/notification-sms/src/index.ts +4 -0
- package/notification-sms/src/runtime.ts +143 -0
- package/ops-console/src/OpsConsolePage.vue +337 -0
- package/ops-console/src/contracts.ts +169 -0
- package/ops-console/src/index.ts +3 -0
- package/ops-console/src/runtime.ts +199 -0
- package/package.json +108 -0
- package/reference-codes/src/ReferenceCodesPage.vue +942 -0
- package/reference-codes/src/contracts.ts +484 -0
- package/reference-codes/src/index.ts +53 -0
- package/reference-codes/src/runtime.ts +855 -0
- package/settings/src/SettingsPage.vue +536 -0
- package/settings/src/contracts.ts +331 -0
- package/settings/src/index.ts +45 -0
- package/settings/src/runtime.ts +545 -0
- package/task-job/src/TaskJobPage.vue +120 -0
- package/task-job/src/contracts.ts +117 -0
- package/task-job/src/index.ts +2 -0
- package/task-job/src/runtime.ts +105 -0
- package/testing/src/index.ts +141 -0
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import type { Component } from 'vue'
|
|
2
|
+
|
|
3
|
+
export interface AdminRouteAccess {
|
|
4
|
+
moduleKey: string
|
|
5
|
+
permissionKeys: readonly string[]
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface AdminModuleRoute {
|
|
9
|
+
name: string
|
|
10
|
+
path: string
|
|
11
|
+
component: () => Promise<{ default: Component }>
|
|
12
|
+
access: AdminRouteAccess
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface AdminModuleStoreContribution {
|
|
16
|
+
key: string
|
|
17
|
+
dispose: () => void | Promise<void>
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface AdminModuleLocaleContribution {
|
|
21
|
+
locale: string
|
|
22
|
+
load: () => Promise<Readonly<Record<string, string>>>
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type AdminModuleShellSlotName = 'header-context' | 'header-actions' | 'sidebar-footer' | 'page-tools'
|
|
26
|
+
|
|
27
|
+
export interface AdminModuleShellSlotContribution {
|
|
28
|
+
name: AdminModuleShellSlotName
|
|
29
|
+
component: () => Promise<{ default: Component }>
|
|
30
|
+
access?: AdminRouteAccess
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface AdminModuleContribution {
|
|
34
|
+
key: string
|
|
35
|
+
routes: readonly AdminModuleRoute[]
|
|
36
|
+
disposeOnTenantChange: boolean
|
|
37
|
+
stores?: readonly AdminModuleStoreContribution[]
|
|
38
|
+
locales?: readonly AdminModuleLocaleContribution[]
|
|
39
|
+
shellSlots?: readonly AdminModuleShellSlotContribution[]
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const moduleKeyPattern = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*(?:\.[a-z][a-z0-9]*(?:-[a-z0-9]+)*)*$/
|
|
43
|
+
const routeNamePattern = /^[a-z][a-z0-9]*(?:[.-][a-z][a-z0-9]*)+$/
|
|
44
|
+
|
|
45
|
+
export const defineAdminModule = <T extends AdminModuleContribution>(contribution: T): T => {
|
|
46
|
+
if (!moduleKeyPattern.test(contribution.key)) {
|
|
47
|
+
throw new Error(`ADMIN_MODULE_KEY_INVALID: ${contribution.key}`)
|
|
48
|
+
}
|
|
49
|
+
const names = new Set<string>()
|
|
50
|
+
const paths = new Set<string>()
|
|
51
|
+
for (const route of contribution.routes) {
|
|
52
|
+
if (!routeNamePattern.test(route.name) || names.has(route.name)) {
|
|
53
|
+
throw new Error(`ADMIN_MODULE_ROUTE_NAME_INVALID: ${route.name}`)
|
|
54
|
+
}
|
|
55
|
+
if (!route.path.startsWith('/app/') || paths.has(route.path)) {
|
|
56
|
+
throw new Error(`ADMIN_MODULE_ROUTE_PATH_INVALID: ${route.path}`)
|
|
57
|
+
}
|
|
58
|
+
if (route.access.moduleKey !== contribution.key || route.access.permissionKeys.length === 0) {
|
|
59
|
+
throw new Error(`ADMIN_MODULE_ROUTE_ACCESS_INVALID: ${route.name}`)
|
|
60
|
+
}
|
|
61
|
+
names.add(route.name)
|
|
62
|
+
paths.add(route.path)
|
|
63
|
+
}
|
|
64
|
+
const storeKeys = new Set<string>()
|
|
65
|
+
for (const store of contribution.stores ?? []) {
|
|
66
|
+
if (!store.key.startsWith(`${contribution.key}.`) || storeKeys.has(store.key)) {
|
|
67
|
+
throw new Error(`ADMIN_MODULE_STORE_INVALID: ${store.key}`)
|
|
68
|
+
}
|
|
69
|
+
storeKeys.add(store.key)
|
|
70
|
+
}
|
|
71
|
+
const locales = new Set<string>()
|
|
72
|
+
for (const locale of contribution.locales ?? []) {
|
|
73
|
+
if (!/^[a-z]{2}(?:-[A-Z]{2})?$/.test(locale.locale) || locales.has(locale.locale)) {
|
|
74
|
+
throw new Error(`ADMIN_MODULE_LOCALE_INVALID: ${locale.locale}`)
|
|
75
|
+
}
|
|
76
|
+
locales.add(locale.locale)
|
|
77
|
+
}
|
|
78
|
+
const slots = new Set<AdminModuleShellSlotName>()
|
|
79
|
+
for (const slot of contribution.shellSlots ?? []) {
|
|
80
|
+
if (slots.has(slot.name) || (slot.access !== undefined && slot.access.moduleKey !== contribution.key)) {
|
|
81
|
+
throw new Error(`ADMIN_MODULE_SHELL_SLOT_INVALID: ${slot.name}`)
|
|
82
|
+
}
|
|
83
|
+
slots.add(slot.name)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return contribution
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface MenuRouteRegistry {
|
|
90
|
+
resolve: (routeName: string) => AdminModuleRoute | null
|
|
91
|
+
diagnostics: () => readonly string[]
|
|
92
|
+
routes: () => readonly AdminModuleRoute[]
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export const createMenuRouteRegistry = (
|
|
96
|
+
contributions: readonly AdminModuleContribution[],
|
|
97
|
+
): MenuRouteRegistry => {
|
|
98
|
+
const routeMap = new Map<string, AdminModuleRoute>()
|
|
99
|
+
const paths = new Set<string>()
|
|
100
|
+
for (const rawContribution of contributions) {
|
|
101
|
+
const contribution = defineAdminModule(rawContribution)
|
|
102
|
+
for (const route of contribution.routes) {
|
|
103
|
+
if (routeMap.has(route.name) || paths.has(route.path)) {
|
|
104
|
+
throw new Error(`ADMIN_ROUTE_REGISTRY_CONFLICT: ${route.name}`)
|
|
105
|
+
}
|
|
106
|
+
routeMap.set(route.name, route)
|
|
107
|
+
paths.add(route.path)
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
const unknownRoutes = new Set<string>()
|
|
111
|
+
|
|
112
|
+
return {
|
|
113
|
+
resolve(routeName) {
|
|
114
|
+
const route = routeMap.get(routeName)
|
|
115
|
+
if (route === undefined) {
|
|
116
|
+
unknownRoutes.add(routeName)
|
|
117
|
+
return null
|
|
118
|
+
}
|
|
119
|
+
return route
|
|
120
|
+
},
|
|
121
|
+
diagnostics: () => [...unknownRoutes],
|
|
122
|
+
routes: () => [...routeMap.values()],
|
|
123
|
+
}
|
|
124
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
export interface AdminAudienceHostConfig {
|
|
2
|
+
baseUrl: string
|
|
3
|
+
allowedOrigin: string
|
|
4
|
+
clientKey: string
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export interface AdminHostConfig {
|
|
8
|
+
tenant: AdminAudienceHostConfig
|
|
9
|
+
platform: AdminAudienceHostConfig
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const clientKeyPattern = /^[a-z][a-z0-9-]{0,63}$/
|
|
13
|
+
|
|
14
|
+
const parseAuthority = (value: string): URL => {
|
|
15
|
+
let url: URL
|
|
16
|
+
try {
|
|
17
|
+
url = new URL(value)
|
|
18
|
+
} catch {
|
|
19
|
+
throw new Error('ADMIN_HOST_ORIGIN_INVALID')
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (!['http:', 'https:'].includes(url.protocol)
|
|
23
|
+
|| url.origin === 'null'
|
|
24
|
+
|| url.username !== ''
|
|
25
|
+
|| url.password !== ''
|
|
26
|
+
|| url.pathname !== '/'
|
|
27
|
+
|| url.search !== ''
|
|
28
|
+
|| url.hash !== '') {
|
|
29
|
+
throw new Error('ADMIN_HOST_ORIGIN_INVALID')
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return url
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const defineAudienceConfig = (config: AdminAudienceHostConfig): AdminAudienceHostConfig => {
|
|
36
|
+
const baseUrl = parseAuthority(config.baseUrl)
|
|
37
|
+
const allowedOrigin = parseAuthority(config.allowedOrigin)
|
|
38
|
+
if (baseUrl.origin !== allowedOrigin.origin) {
|
|
39
|
+
throw new Error('ADMIN_HOST_ORIGIN_MISMATCH')
|
|
40
|
+
}
|
|
41
|
+
if (!clientKeyPattern.test(config.clientKey)) {
|
|
42
|
+
throw new Error('ADMIN_HOST_CLIENT_KEY_INVALID')
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
baseUrl: baseUrl.origin,
|
|
47
|
+
allowedOrigin: allowedOrigin.origin,
|
|
48
|
+
clientKey: config.clientKey,
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export const defineAdminHostConfig = (config: AdminHostConfig): AdminHostConfig => ({
|
|
53
|
+
tenant: defineAudienceConfig(config.tenant),
|
|
54
|
+
platform: defineAudienceConfig(config.platform),
|
|
55
|
+
})
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import type { ApiAudience } from '../api/client'
|
|
2
|
+
import type { ProblemDetails } from '../api/problem'
|
|
3
|
+
|
|
4
|
+
export type AdminRuntimeErrorKind =
|
|
5
|
+
| 'login'
|
|
6
|
+
| 'forbidden'
|
|
7
|
+
| 'not-found'
|
|
8
|
+
| 'conflict'
|
|
9
|
+
| 'rate-limited'
|
|
10
|
+
| 'unavailable'
|
|
11
|
+
| 'configuration'
|
|
12
|
+
|
|
13
|
+
export interface AdminRuntimeErrorState {
|
|
14
|
+
kind: AdminRuntimeErrorKind
|
|
15
|
+
audience: ApiAudience
|
|
16
|
+
code: string
|
|
17
|
+
requestId: string | null
|
|
18
|
+
retryAfter: string | null
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface ProblemErrorSource {
|
|
22
|
+
problem: ProblemDetails
|
|
23
|
+
retryAfter?: string | null
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const isRecord = (value: unknown): value is Record<string, unknown> => (
|
|
27
|
+
typeof value === 'object' && value !== null
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
const problemSource = (value: unknown): ProblemErrorSource | null => {
|
|
31
|
+
if (!isRecord(value) || !isRecord(value.problem)) return null
|
|
32
|
+
const problem = value.problem
|
|
33
|
+
if (typeof problem.status !== 'number'
|
|
34
|
+
|| typeof problem.code !== 'string'
|
|
35
|
+
|| typeof problem.request_id !== 'string') return null
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
problem: problem as unknown as ProblemDetails,
|
|
39
|
+
retryAfter: typeof value.retryAfter === 'string' ? value.retryAfter : null,
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const kindForStatus = (status: number): AdminRuntimeErrorKind => {
|
|
44
|
+
if (status === 401) return 'login'
|
|
45
|
+
if (status === 403) return 'forbidden'
|
|
46
|
+
if (status === 404) return 'not-found'
|
|
47
|
+
if (status === 409 || status === 412) return 'conflict'
|
|
48
|
+
if (status === 429) return 'rate-limited'
|
|
49
|
+
return 'unavailable'
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export const mapAdminRuntimeError = (
|
|
53
|
+
error: unknown,
|
|
54
|
+
audience: ApiAudience,
|
|
55
|
+
): AdminRuntimeErrorState => {
|
|
56
|
+
const source = problemSource(error)
|
|
57
|
+
if (source !== null) {
|
|
58
|
+
return {
|
|
59
|
+
kind: kindForStatus(source.problem.status),
|
|
60
|
+
audience,
|
|
61
|
+
code: source.problem.code,
|
|
62
|
+
requestId: source.problem.request_id,
|
|
63
|
+
retryAfter: source.retryAfter ?? null,
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const code = error instanceof Error ? error.message : 'CLIENT_BOOT_FAILED'
|
|
68
|
+
if (code === 'API_ORIGIN_INVALID'
|
|
69
|
+
|| code === 'API_ORIGIN_MISMATCH'
|
|
70
|
+
|| code.startsWith('API_AUDIENCE_MISMATCH')) {
|
|
71
|
+
return { kind: 'configuration', audience, code, requestId: null, retryAfter: null }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
kind: 'unavailable',
|
|
76
|
+
audience,
|
|
77
|
+
code: code === '' ? 'CLIENT_BOOT_FAILED' : code,
|
|
78
|
+
requestId: null,
|
|
79
|
+
retryAfter: null,
|
|
80
|
+
}
|
|
81
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { ApiAudience } from '../api/client'
|
|
2
|
+
|
|
3
|
+
export interface AdminRouteGuardInput {
|
|
4
|
+
audience: ApiAudience
|
|
5
|
+
moduleKey?: string
|
|
6
|
+
permissionKeys?: readonly string[]
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface AdminRouteGuardDependencies {
|
|
10
|
+
enterAudience: (audience: ApiAudience) => Promise<void>
|
|
11
|
+
ensureContext: (audience: ApiAudience) => Promise<void>
|
|
12
|
+
loadNavigation: (audience: ApiAudience) => Promise<void>
|
|
13
|
+
hasModule: (moduleKey: string) => boolean
|
|
14
|
+
hasPermissions: (permissionKeys: readonly string[]) => boolean
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type AdminRouteGuardResult =
|
|
18
|
+
| { status: 'allowed' }
|
|
19
|
+
| { status: 'module-unavailable', code: 'MODULE_TENANT_DISABLED' }
|
|
20
|
+
| { status: 'forbidden', code: 'AUTHZ_FUNCTIONAL_DENIED' }
|
|
21
|
+
|
|
22
|
+
export const runAdminRouteGuard = async (
|
|
23
|
+
route: AdminRouteGuardInput,
|
|
24
|
+
dependencies: AdminRouteGuardDependencies,
|
|
25
|
+
): Promise<AdminRouteGuardResult> => {
|
|
26
|
+
await dependencies.enterAudience(route.audience)
|
|
27
|
+
await dependencies.ensureContext(route.audience)
|
|
28
|
+
await dependencies.loadNavigation(route.audience)
|
|
29
|
+
|
|
30
|
+
if (route.moduleKey !== undefined && !dependencies.hasModule(route.moduleKey)) {
|
|
31
|
+
return { status: 'module-unavailable', code: 'MODULE_TENANT_DISABLED' }
|
|
32
|
+
}
|
|
33
|
+
if (route.permissionKeys !== undefined
|
|
34
|
+
&& route.permissionKeys.length > 0
|
|
35
|
+
&& !dependencies.hasPermissions(route.permissionKeys)) {
|
|
36
|
+
return { status: 'forbidden', code: 'AUTHZ_FUNCTIONAL_DENIED' }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return { status: 'allowed' }
|
|
40
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import type { ApiAudience } from '../api/client'
|
|
2
|
+
import type { AdminModuleContribution } from '../module/contribution'
|
|
3
|
+
import { defineAdminModule } from '../module/contribution'
|
|
4
|
+
|
|
5
|
+
export interface AdminNavigationRoute {
|
|
6
|
+
name: string
|
|
7
|
+
path: string
|
|
8
|
+
audience: ApiAudience
|
|
9
|
+
permission?: string
|
|
10
|
+
permissionKeys?: readonly string[]
|
|
11
|
+
moduleKey?: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface AdminNavigationMenuInput {
|
|
15
|
+
route_name?: unknown
|
|
16
|
+
route_path?: unknown
|
|
17
|
+
component?: unknown
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface AdminNavigationRegistry {
|
|
21
|
+
resolveMenu: (menu: AdminNavigationMenuInput) => AdminNavigationRoute | null
|
|
22
|
+
diagnostics: () => readonly string[]
|
|
23
|
+
clearDiagnostics: () => void
|
|
24
|
+
routes: () => readonly AdminNavigationRoute[]
|
|
25
|
+
disposeTenantState: () => Promise<void>
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface AdminNavigationRegistryInput {
|
|
29
|
+
routes: readonly AdminNavigationRoute[]
|
|
30
|
+
modules: readonly AdminModuleContribution[]
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const throwRegistryConflict = (name: string): never => {
|
|
34
|
+
throw new Error(`ADMIN_ROUTE_REGISTRY_CONFLICT: ${name}`)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const matchesAudience = (route: AdminNavigationRoute): boolean => route.audience === 'tenant'
|
|
38
|
+
? route.path === '/app' || route.path.startsWith('/app/')
|
|
39
|
+
: route.path === '/platform' || route.path.startsWith('/platform/')
|
|
40
|
+
|
|
41
|
+
const runDisposers = async (disposers: readonly (() => void | Promise<void>)[]): Promise<void> => {
|
|
42
|
+
const results = await Promise.allSettled(disposers.map(async dispose => dispose()))
|
|
43
|
+
const failure = results.find((result): result is PromiseRejectedResult => result.status === 'rejected')
|
|
44
|
+
if (failure !== undefined) throw failure.reason
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export const createAdminNavigationRegistry = (
|
|
48
|
+
input: AdminNavigationRegistryInput,
|
|
49
|
+
): AdminNavigationRegistry => {
|
|
50
|
+
const routeMap = new Map<string, AdminNavigationRoute>()
|
|
51
|
+
const paths = new Set<string>()
|
|
52
|
+
const moduleKeys = new Set<string>()
|
|
53
|
+
const tenantDisposers: Array<() => void | Promise<void>> = []
|
|
54
|
+
|
|
55
|
+
const registerRoute = (route: AdminNavigationRoute): void => {
|
|
56
|
+
if (route.name === ''
|
|
57
|
+
|| !matchesAudience(route)
|
|
58
|
+
|| routeMap.has(route.name)
|
|
59
|
+
|| paths.has(route.path)) {
|
|
60
|
+
throwRegistryConflict(route.name)
|
|
61
|
+
}
|
|
62
|
+
routeMap.set(route.name, {
|
|
63
|
+
...route,
|
|
64
|
+
...(route.permissionKeys === undefined ? {} : { permissionKeys: [...route.permissionKeys] }),
|
|
65
|
+
})
|
|
66
|
+
paths.add(route.path)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
for (const route of input.routes) registerRoute(route)
|
|
70
|
+
for (const rawModule of input.modules) {
|
|
71
|
+
const module = defineAdminModule(rawModule)
|
|
72
|
+
if (moduleKeys.has(module.key)) throwRegistryConflict(module.key)
|
|
73
|
+
moduleKeys.add(module.key)
|
|
74
|
+
for (const route of module.routes) {
|
|
75
|
+
registerRoute({
|
|
76
|
+
name: route.name,
|
|
77
|
+
path: route.path,
|
|
78
|
+
audience: 'tenant',
|
|
79
|
+
moduleKey: module.key,
|
|
80
|
+
permissionKeys: route.access.permissionKeys,
|
|
81
|
+
})
|
|
82
|
+
}
|
|
83
|
+
if (module.disposeOnTenantChange) {
|
|
84
|
+
tenantDisposers.push(...(module.stores ?? []).map(store => store.dispose))
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const unknownRoutes = new Set<string>()
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
resolveMenu(menu) {
|
|
92
|
+
if (typeof menu.route_name !== 'string') return null
|
|
93
|
+
const route = routeMap.get(menu.route_name)
|
|
94
|
+
if (route === undefined) {
|
|
95
|
+
unknownRoutes.add(menu.route_name)
|
|
96
|
+
return null
|
|
97
|
+
}
|
|
98
|
+
return route
|
|
99
|
+
},
|
|
100
|
+
diagnostics: () => [...unknownRoutes],
|
|
101
|
+
clearDiagnostics: () => unknownRoutes.clear(),
|
|
102
|
+
routes: () => [...routeMap.values()],
|
|
103
|
+
disposeTenantState: () => runDisposers(tenantDisposers),
|
|
104
|
+
}
|
|
105
|
+
}
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
export type AdminOverrideKind = 'service' | 'component' | 'page' | 'route'
|
|
2
|
+
|
|
3
|
+
export type AdminOverrideSource = 'default' | 'application'
|
|
4
|
+
|
|
5
|
+
export interface AdminOverrideSlot<Value = unknown> {
|
|
6
|
+
readonly key: string
|
|
7
|
+
readonly kind: AdminOverrideKind
|
|
8
|
+
readonly contractVersion: string
|
|
9
|
+
readonly defaultValue: Value
|
|
10
|
+
readonly validate?: (value: unknown) => value is Value
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface AdminOverride<Value = unknown> {
|
|
14
|
+
readonly key: string
|
|
15
|
+
readonly kind: AdminOverrideKind
|
|
16
|
+
readonly contractVersion: string
|
|
17
|
+
readonly value: Value
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface AdminOverrideResolution<Value = unknown> {
|
|
21
|
+
readonly key: string
|
|
22
|
+
readonly kind: AdminOverrideKind
|
|
23
|
+
readonly contractVersion: string
|
|
24
|
+
readonly value: Value
|
|
25
|
+
readonly source: AdminOverrideSource
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type AdminOverrideResolutionMetadata = Omit<AdminOverrideResolution, 'value'>
|
|
29
|
+
|
|
30
|
+
export interface AdminOverrideRegistryInput<
|
|
31
|
+
Slots extends readonly AdminOverrideSlot[] = readonly AdminOverrideSlot[],
|
|
32
|
+
> {
|
|
33
|
+
readonly slots: Slots
|
|
34
|
+
readonly overrides?: readonly AdminOverride[]
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
type SlotValue<Slots extends readonly AdminOverrideSlot[], Key extends string> =
|
|
38
|
+
Extract<Slots[number], { readonly key: Key }> extends infer Slot
|
|
39
|
+
? Slot extends AdminOverrideSlot<infer Value> ? Value : unknown
|
|
40
|
+
: unknown
|
|
41
|
+
|
|
42
|
+
type SlotKey<Slots extends readonly AdminOverrideSlot[]> = Slots[number]['key'] & string
|
|
43
|
+
|
|
44
|
+
export interface AdminOverrideRegistry<
|
|
45
|
+
Slots extends readonly AdminOverrideSlot[] = readonly AdminOverrideSlot[],
|
|
46
|
+
> {
|
|
47
|
+
resolve<Key extends SlotKey<Slots>>(key: Key): AdminOverrideResolution<SlotValue<Slots, Key>>
|
|
48
|
+
get<Key extends SlotKey<Slots>>(key: Key): SlotValue<Slots, Key>
|
|
49
|
+
diagnostics(): readonly AdminOverrideResolutionMetadata[]
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const overrideKinds: readonly AdminOverrideKind[] = ['service', 'component', 'page', 'route']
|
|
53
|
+
const overrideKindSet = new Set<AdminOverrideKind>(overrideKinds)
|
|
54
|
+
const overrideKeyPattern = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*(?:\.[a-z][a-z0-9]*(?:-[a-z0-9]+)*){2,}$/
|
|
55
|
+
const contractVersionPattern = /^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$/
|
|
56
|
+
|
|
57
|
+
const isRecord = (value: unknown): value is Record<string, unknown> => (
|
|
58
|
+
typeof value === 'object' && value !== null
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
const hasOwn = (value: object, key: PropertyKey): boolean => (
|
|
62
|
+
Object.prototype.hasOwnProperty.call(value, key)
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
const fail = (code: string, detail?: string): never => {
|
|
66
|
+
throw new Error(detail === undefined ? code : `${code}: ${detail}`)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const validateKey = (value: unknown, kind: unknown): string => {
|
|
70
|
+
if (typeof value !== 'string' || !overrideKeyPattern.test(value)) {
|
|
71
|
+
return fail('ADMIN_OVERRIDE_SLOT_KEY_INVALID', typeof value === 'string' ? value : 'unknown')
|
|
72
|
+
}
|
|
73
|
+
if (typeof kind !== 'string' || !overrideKindSet.has(kind as AdminOverrideKind)) {
|
|
74
|
+
return fail('ADMIN_OVERRIDE_KIND_INVALID', String(kind))
|
|
75
|
+
}
|
|
76
|
+
if (!value.split('.').includes(kind)) {
|
|
77
|
+
return fail('ADMIN_OVERRIDE_SLOT_KEY_KIND_INVALID', value)
|
|
78
|
+
}
|
|
79
|
+
return value
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const validateVersion = (value: unknown): string => {
|
|
83
|
+
if (typeof value !== 'string' || !contractVersionPattern.test(value)) {
|
|
84
|
+
return fail('ADMIN_OVERRIDE_CONTRACT_VERSION_INVALID', typeof value === 'string' ? value : 'unknown')
|
|
85
|
+
}
|
|
86
|
+
return value
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const validatorAccepts = (validator: (value: unknown) => boolean, value: unknown): boolean => {
|
|
90
|
+
try {
|
|
91
|
+
return validator(value) === true
|
|
92
|
+
} catch {
|
|
93
|
+
return false
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const freezeMetadata = (
|
|
98
|
+
metadata: AdminOverrideResolutionMetadata,
|
|
99
|
+
): AdminOverrideResolutionMetadata => Object.freeze({ ...metadata })
|
|
100
|
+
|
|
101
|
+
const freezeResolution = <Value>(
|
|
102
|
+
resolution: AdminOverrideResolution<Value>,
|
|
103
|
+
): AdminOverrideResolution<Value> => Object.freeze({ ...resolution })
|
|
104
|
+
|
|
105
|
+
const copyMetadata = (
|
|
106
|
+
metadata: readonly AdminOverrideResolutionMetadata[],
|
|
107
|
+
): readonly AdminOverrideResolutionMetadata[] => Object.freeze(
|
|
108
|
+
metadata.map(entry => freezeMetadata(entry)),
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
export const defineAdminOverrideSlot = <Value, Slot extends AdminOverrideSlot<Value>>(
|
|
112
|
+
slot: Slot,
|
|
113
|
+
): Slot => slot
|
|
114
|
+
|
|
115
|
+
export const createAdminOverrideRegistry = <
|
|
116
|
+
Slots extends readonly AdminOverrideSlot[],
|
|
117
|
+
>(input: AdminOverrideRegistryInput<Slots>): AdminOverrideRegistry<Slots> => {
|
|
118
|
+
if (!isRecord(input) || !Array.isArray(input.slots)) {
|
|
119
|
+
fail('ADMIN_OVERRIDE_SLOTS_INVALID')
|
|
120
|
+
}
|
|
121
|
+
if (input.overrides !== undefined && !Array.isArray(input.overrides)) {
|
|
122
|
+
fail('ADMIN_OVERRIDE_OVERRIDES_INVALID')
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const slots = new Map<string, AdminOverrideSlot>()
|
|
126
|
+
for (const rawSlot of input.slots) {
|
|
127
|
+
if (!isRecord(rawSlot)) fail('ADMIN_OVERRIDE_SLOT_INVALID')
|
|
128
|
+
|
|
129
|
+
const key = validateKey(rawSlot.key, rawSlot.kind)
|
|
130
|
+
if (slots.has(key)) fail('ADMIN_OVERRIDE_SLOT_KEY_DUPLICATE', key)
|
|
131
|
+
|
|
132
|
+
if (!hasOwn(rawSlot, 'contractVersion')) {
|
|
133
|
+
fail('ADMIN_OVERRIDE_CONTRACT_VERSION_INVALID', key)
|
|
134
|
+
}
|
|
135
|
+
const contractVersion = validateVersion(rawSlot.contractVersion)
|
|
136
|
+
if (!hasOwn(rawSlot, 'defaultValue')) {
|
|
137
|
+
fail('ADMIN_OVERRIDE_DEFAULT_INVALID', key)
|
|
138
|
+
}
|
|
139
|
+
if (rawSlot.validate !== undefined && typeof rawSlot.validate !== 'function') {
|
|
140
|
+
fail('ADMIN_OVERRIDE_VALIDATOR_INVALID', key)
|
|
141
|
+
}
|
|
142
|
+
const slot = rawSlot as unknown as AdminOverrideSlot
|
|
143
|
+
if (slot.validate !== undefined && !validatorAccepts(slot.validate, slot.defaultValue)) {
|
|
144
|
+
fail('ADMIN_OVERRIDE_DEFAULT_INVALID', key)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
slots.set(key, Object.freeze({
|
|
148
|
+
key,
|
|
149
|
+
kind: rawSlot.kind as AdminOverrideKind,
|
|
150
|
+
contractVersion,
|
|
151
|
+
defaultValue: slot.defaultValue,
|
|
152
|
+
...(slot.validate === undefined ? {} : { validate: slot.validate }),
|
|
153
|
+
}))
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const values = new Map<string, AdminOverrideResolution>()
|
|
157
|
+
for (const [key, slot] of slots) {
|
|
158
|
+
values.set(key, freezeResolution({
|
|
159
|
+
key,
|
|
160
|
+
kind: slot.kind,
|
|
161
|
+
contractVersion: slot.contractVersion,
|
|
162
|
+
value: slot.defaultValue,
|
|
163
|
+
source: 'default',
|
|
164
|
+
}))
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const seenOverrides = new Set<string>()
|
|
168
|
+
for (const rawOverride of input.overrides ?? []) {
|
|
169
|
+
if (!isRecord(rawOverride) || typeof rawOverride.key !== 'string') {
|
|
170
|
+
fail('ADMIN_OVERRIDE_OVERRIDE_KEY_INVALID')
|
|
171
|
+
}
|
|
172
|
+
const key = rawOverride.key
|
|
173
|
+
if (seenOverrides.has(key)) fail('ADMIN_OVERRIDE_KEY_DUPLICATE', key)
|
|
174
|
+
seenOverrides.add(key)
|
|
175
|
+
|
|
176
|
+
const slot = slots.get(key) ?? fail('ADMIN_OVERRIDE_KEY_UNKNOWN', key)
|
|
177
|
+
if (rawOverride.kind !== slot.kind) {
|
|
178
|
+
fail('ADMIN_OVERRIDE_KIND_MISMATCH', key)
|
|
179
|
+
}
|
|
180
|
+
if (rawOverride.contractVersion !== slot.contractVersion) {
|
|
181
|
+
fail('ADMIN_OVERRIDE_CONTRACT_VERSION_MISMATCH', key)
|
|
182
|
+
}
|
|
183
|
+
if (!hasOwn(rawOverride, 'value')) fail('ADMIN_OVERRIDE_VALUE_INVALID', key)
|
|
184
|
+
|
|
185
|
+
const value = rawOverride.value
|
|
186
|
+
if (slot.validate !== undefined && !validatorAccepts(slot.validate, value)) {
|
|
187
|
+
fail('ADMIN_OVERRIDE_VALUE_INVALID', key)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
values.set(key, freezeResolution({
|
|
191
|
+
key,
|
|
192
|
+
kind: slot.kind,
|
|
193
|
+
contractVersion: slot.contractVersion,
|
|
194
|
+
value,
|
|
195
|
+
source: 'application',
|
|
196
|
+
}))
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const metadata = [...values.values()].map(({ key, kind, contractVersion, source }) => (
|
|
200
|
+
freezeMetadata({ key, kind, contractVersion, source })
|
|
201
|
+
))
|
|
202
|
+
|
|
203
|
+
const resolve = <Value>(key: string): AdminOverrideResolution<Value> => {
|
|
204
|
+
const resolution = values.get(key)
|
|
205
|
+
if (resolution === undefined) fail('ADMIN_OVERRIDE_SLOT_UNKNOWN', key)
|
|
206
|
+
return resolution as AdminOverrideResolution<Value>
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return {
|
|
210
|
+
resolve,
|
|
211
|
+
get: <Value>(key: string): Value => resolve<Value>(key).value,
|
|
212
|
+
diagnostics: () => copyMetadata(metadata),
|
|
213
|
+
} as AdminOverrideRegistry<Slots>
|
|
214
|
+
}
|