@peanut-admin/admin 0.1.0-alpha.11

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 (73) hide show
  1. package/LICENSE +202 -0
  2. package/admin-core/src/access/access.ts +27 -0
  3. package/admin-core/src/access/permission-policy.ts +37 -0
  4. package/admin-core/src/api/client.ts +200 -0
  5. package/admin-core/src/api/problem.ts +67 -0
  6. package/admin-core/src/api/refresh.ts +122 -0
  7. package/admin-core/src/auth/stores.ts +121 -0
  8. package/admin-core/src/auth/tenant-session.ts +32 -0
  9. package/admin-core/src/generated/api.d.ts +22106 -0
  10. package/admin-core/src/governance/audit.ts +56 -0
  11. package/admin-core/src/governance/catalog.ts +86 -0
  12. package/admin-core/src/governance/index.ts +26 -0
  13. package/admin-core/src/governance/menu.ts +63 -0
  14. package/admin-core/src/governance/roles.ts +144 -0
  15. package/admin-core/src/governance/types.ts +64 -0
  16. package/admin-core/src/index.ts +122 -0
  17. package/admin-core/src/lifecycle/tenant.ts +59 -0
  18. package/admin-core/src/module/contribution.ts +124 -0
  19. package/admin-core/src/module/plugin-contribution-policy.ts +51 -0
  20. package/admin-core/src/module/tenant-modules.ts +20 -0
  21. package/admin-core/src/runtime/config.ts +55 -0
  22. package/admin-core/src/runtime/errors.ts +81 -0
  23. package/admin-core/src/runtime/guard.ts +40 -0
  24. package/admin-core/src/runtime/navigation.ts +105 -0
  25. package/admin-core/src/runtime/overrides.ts +214 -0
  26. package/admin-core/src/targets/store.ts +153 -0
  27. package/admin-shell/src/config.ts +84 -0
  28. package/admin-shell/src/deployment-mode.ts +36 -0
  29. package/admin-shell/src/index.ts +44 -0
  30. package/admin-shell/src/layout.ts +332 -0
  31. package/admin-shell/src/overrides.ts +53 -0
  32. package/admin-shell/src/states.ts +93 -0
  33. package/admin-shell/src/tabs.ts +31 -0
  34. package/admin-shell/src/targets.ts +128 -0
  35. package/admin-shell/src/theme.ts +15 -0
  36. package/client-core/src/index.ts +415 -0
  37. package/client-nuxt/src/index.ts +48 -0
  38. package/client-uniapp/src/index.ts +50 -0
  39. package/file-media/src/FileAssetSelector.vue +117 -0
  40. package/file-media/src/FileMediaPage.vue +158 -0
  41. package/file-media/src/contracts.ts +220 -0
  42. package/file-media/src/index.ts +19 -0
  43. package/file-media/src/runtime.ts +210 -0
  44. package/import-export/src/ImportExportPage.vue +155 -0
  45. package/import-export/src/contracts.ts +96 -0
  46. package/import-export/src/index.ts +3 -0
  47. package/import-export/src/runtime.ts +128 -0
  48. package/integration-security/src/IntegrationSecurityPage.vue +402 -0
  49. package/integration-security/src/contracts.ts +171 -0
  50. package/integration-security/src/index.ts +3 -0
  51. package/integration-security/src/runtime.ts +180 -0
  52. package/notification-sms/src/NotificationInboxPage.vue +266 -0
  53. package/notification-sms/src/contracts.ts +195 -0
  54. package/notification-sms/src/index.ts +4 -0
  55. package/notification-sms/src/runtime.ts +143 -0
  56. package/ops-console/src/OpsConsolePage.vue +337 -0
  57. package/ops-console/src/contracts.ts +169 -0
  58. package/ops-console/src/index.ts +3 -0
  59. package/ops-console/src/runtime.ts +199 -0
  60. package/package.json +139 -0
  61. package/reference-codes/src/ReferenceCodesPage.vue +942 -0
  62. package/reference-codes/src/contracts.ts +484 -0
  63. package/reference-codes/src/index.ts +53 -0
  64. package/reference-codes/src/runtime.ts +855 -0
  65. package/settings/src/SettingsPage.vue +536 -0
  66. package/settings/src/contracts.ts +331 -0
  67. package/settings/src/index.ts +45 -0
  68. package/settings/src/runtime.ts +545 -0
  69. package/task-job/src/TaskJobPage.vue +120 -0
  70. package/task-job/src/contracts.ts +117 -0
  71. package/task-job/src/index.ts +2 -0
  72. package/task-job/src/runtime.ts +105 -0
  73. 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,51 @@
1
+ import type { PermissionEvaluator } from '../access/permission-policy'
2
+ import { evaluateRequiredPermissions } from '../access/permission-policy'
3
+
4
+ /**
5
+ * Minimal router contract shared by host applications without coupling to a
6
+ * particular Vue Router major version.
7
+ */
8
+ export interface PluginFrontendRoute {
9
+ path: string
10
+ name?: unknown
11
+ component?: unknown
12
+ meta?: {
13
+ requiredPermissions?: string | string[]
14
+ [key: string]: unknown
15
+ }
16
+ children?: PluginFrontendRoute[]
17
+ }
18
+
19
+ export interface PluginFrontendContribution<T extends PluginFrontendRoute = PluginFrontendRoute> {
20
+ moduleKey: string
21
+ routes: T[]
22
+ }
23
+
24
+ export const collectPluginContributions = (
25
+ modules: Record<string, { default?: PluginFrontendContribution }>,
26
+ ): PluginFrontendContribution[] => Object.keys(modules)
27
+ .sort()
28
+ .map(path => modules[path]?.default)
29
+ .filter((contribution): contribution is PluginFrontendContribution => (
30
+ typeof contribution?.moduleKey === 'string'
31
+ && contribution.moduleKey.length > 0
32
+ && Array.isArray(contribution.routes)
33
+ ))
34
+
35
+ /** Deployment presence alone never exposes a Tenant Module route. */
36
+ export const routesForTenantModules = <T extends PluginFrontendRoute>(
37
+ contributions: PluginFrontendContribution<T>[],
38
+ enabledModules: readonly string[],
39
+ grantedPermissions: readonly string[],
40
+ evaluator: PermissionEvaluator,
41
+ ): T[] => {
42
+ const enabled = new Set(enabledModules)
43
+ return contributions.flatMap((contribution) => {
44
+ if (!enabled.has(contribution.moduleKey)) return []
45
+ return contribution.routes.filter((route) => {
46
+ const required = route.meta?.requiredPermissions
47
+ return (typeof required === 'string' || Array.isArray(required))
48
+ && evaluateRequiredPermissions(required, grantedPermissions, evaluator)
49
+ })
50
+ })
51
+ }
@@ -0,0 +1,20 @@
1
+ /** Minimal route shape needed to derive Tenant module availability. */
2
+ export interface TenantModuleRoute {
3
+ meta?: {
4
+ tenantModuleKey?: unknown
5
+ }
6
+ children?: readonly TenantModuleRoute[]
7
+ }
8
+
9
+ /**
10
+ * Derive the distinct Tenant module keys exposed by a server-authorized route
11
+ * tree. The host owns fetching and route-to-component resolution.
12
+ */
13
+ export const enabledTenantModulesFromRoutes = (
14
+ routes: readonly TenantModuleRoute[],
15
+ ): string[] => Array.from(new Set(
16
+ routes
17
+ .flatMap(route => [route, ...(route.children ?? [])])
18
+ .map(route => route.meta?.tenantModuleKey)
19
+ .filter((key): key is string => typeof key === 'string'),
20
+ ))
@@ -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
+ }