@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.
Files changed (67) hide show
  1. package/LICENSE +202 -0
  2. package/admin-core/src/access/access.ts +27 -0
  3. package/admin-core/src/api/client.ts +200 -0
  4. package/admin-core/src/api/problem.ts +67 -0
  5. package/admin-core/src/api/refresh.ts +122 -0
  6. package/admin-core/src/auth/stores.ts +121 -0
  7. package/admin-core/src/generated/api.d.ts +22106 -0
  8. package/admin-core/src/governance/audit.ts +56 -0
  9. package/admin-core/src/governance/catalog.ts +86 -0
  10. package/admin-core/src/governance/index.ts +26 -0
  11. package/admin-core/src/governance/menu.ts +63 -0
  12. package/admin-core/src/governance/roles.ts +144 -0
  13. package/admin-core/src/governance/types.ts +64 -0
  14. package/admin-core/src/index.ts +105 -0
  15. package/admin-core/src/lifecycle/tenant.ts +59 -0
  16. package/admin-core/src/module/contribution.ts +124 -0
  17. package/admin-core/src/runtime/config.ts +55 -0
  18. package/admin-core/src/runtime/errors.ts +81 -0
  19. package/admin-core/src/runtime/guard.ts +40 -0
  20. package/admin-core/src/runtime/navigation.ts +105 -0
  21. package/admin-core/src/runtime/overrides.ts +214 -0
  22. package/admin-core/src/targets/store.ts +153 -0
  23. package/admin-shell/src/config.ts +84 -0
  24. package/admin-shell/src/index.ts +40 -0
  25. package/admin-shell/src/layout.ts +332 -0
  26. package/admin-shell/src/overrides.ts +53 -0
  27. package/admin-shell/src/states.ts +93 -0
  28. package/admin-shell/src/targets.ts +128 -0
  29. package/admin-shell/src/theme.ts +15 -0
  30. package/client-core/src/index.ts +325 -0
  31. package/client-nuxt/src/index.ts +40 -0
  32. package/client-uniapp/src/index.ts +50 -0
  33. package/file-media/src/FileAssetSelector.vue +117 -0
  34. package/file-media/src/FileMediaPage.vue +158 -0
  35. package/file-media/src/contracts.ts +220 -0
  36. package/file-media/src/index.ts +19 -0
  37. package/file-media/src/runtime.ts +210 -0
  38. package/import-export/src/ImportExportPage.vue +155 -0
  39. package/import-export/src/contracts.ts +96 -0
  40. package/import-export/src/index.ts +3 -0
  41. package/import-export/src/runtime.ts +128 -0
  42. package/integration-security/src/IntegrationSecurityPage.vue +402 -0
  43. package/integration-security/src/contracts.ts +171 -0
  44. package/integration-security/src/index.ts +3 -0
  45. package/integration-security/src/runtime.ts +180 -0
  46. package/notification-sms/src/NotificationInboxPage.vue +266 -0
  47. package/notification-sms/src/contracts.ts +195 -0
  48. package/notification-sms/src/index.ts +4 -0
  49. package/notification-sms/src/runtime.ts +143 -0
  50. package/ops-console/src/OpsConsolePage.vue +337 -0
  51. package/ops-console/src/contracts.ts +169 -0
  52. package/ops-console/src/index.ts +3 -0
  53. package/ops-console/src/runtime.ts +199 -0
  54. package/package.json +108 -0
  55. package/reference-codes/src/ReferenceCodesPage.vue +942 -0
  56. package/reference-codes/src/contracts.ts +484 -0
  57. package/reference-codes/src/index.ts +53 -0
  58. package/reference-codes/src/runtime.ts +855 -0
  59. package/settings/src/SettingsPage.vue +536 -0
  60. package/settings/src/contracts.ts +331 -0
  61. package/settings/src/index.ts +45 -0
  62. package/settings/src/runtime.ts +545 -0
  63. package/task-job/src/TaskJobPage.vue +120 -0
  64. package/task-job/src/contracts.ts +117 -0
  65. package/task-job/src/index.ts +2 -0
  66. package/task-job/src/runtime.ts +105 -0
  67. package/testing/src/index.ts +141 -0
@@ -0,0 +1,117 @@
1
+ export type TaskStatus = 'queued' | 'running' | 'succeeded' | 'dead' | 'cancelled'
2
+
3
+ export interface TaskJob {
4
+ readonly jobKey: string
5
+ readonly taskType: string
6
+ readonly status: TaskStatus
7
+ readonly attemptCount: number
8
+ readonly maxAttempts: number
9
+ readonly revision: number
10
+ readonly lastErrorCode: string | null
11
+ readonly availableAt: string
12
+ readonly createdAt: string
13
+ readonly updatedAt: string
14
+ readonly completedAt: string | null
15
+ }
16
+
17
+ export interface TaskJobList {
18
+ readonly items: readonly TaskJob[]
19
+ readonly page: number
20
+ readonly pageSize: number
21
+ readonly total: number
22
+ }
23
+
24
+ export interface TaskTransportResult {
25
+ readonly body: unknown
26
+ readonly headers: Headers
27
+ readonly status: number
28
+ }
29
+
30
+ export interface TaskJobTransport {
31
+ list: (status: TaskStatus, page: number, pageSize: number, signal: AbortSignal) => Promise<TaskTransportResult>
32
+ cancel: (jobKey: string, revision: number, signal: AbortSignal) => Promise<TaskTransportResult>
33
+ retry: (jobKey: string, revision: number, signal: AbortSignal) => Promise<TaskTransportResult>
34
+ }
35
+
36
+ const jobKeyPattern = /^job_[0-9a-f]{32}$/
37
+ const taskTypePattern = /^[a-z][a-z0-9]*(?:[.-][a-z0-9]+)*$/
38
+ const errorCodePattern = /^[A-Z][A-Z0-9_]{2,63}$/
39
+ const statuses: readonly TaskStatus[] = ['queued', 'running', 'succeeded', 'dead', 'cancelled']
40
+
41
+ const record = (value: unknown): Record<string, unknown> => {
42
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) throw new Error('TASK_RESPONSE_INVALID')
43
+ return value as Record<string, unknown>
44
+ }
45
+
46
+ const exactKeys = (value: Record<string, unknown>, keys: readonly string[]): void => {
47
+ const actual = Object.keys(value).sort()
48
+ const expected = [...keys].sort()
49
+ if (actual.length !== expected.length || actual.some((key, index) => key !== expected[index])) {
50
+ throw new Error('TASK_RESPONSE_INVALID')
51
+ }
52
+ }
53
+
54
+ const timestamp = (value: unknown): value is string => typeof value === 'string'
55
+ && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(value)
56
+ && Number.isFinite(Date.parse(value))
57
+
58
+ export const parseTaskJob = (value: unknown): TaskJob => {
59
+ const item = record(value)
60
+ exactKeys(item, [
61
+ 'job_key', 'task_type', 'status', 'attempt_count', 'max_attempts', 'revision',
62
+ 'last_error_code', 'available_at', 'created_at', 'updated_at', 'completed_at',
63
+ ])
64
+ if (
65
+ typeof item.job_key !== 'string' || !jobKeyPattern.test(item.job_key)
66
+ || typeof item.task_type !== 'string' || !taskTypePattern.test(item.task_type)
67
+ || typeof item.status !== 'string' || !statuses.includes(item.status as TaskStatus)
68
+ || typeof item.attempt_count !== 'number' || !Number.isSafeInteger(item.attempt_count) || item.attempt_count < 0
69
+ || typeof item.max_attempts !== 'number' || !Number.isSafeInteger(item.max_attempts) || item.max_attempts < 1 || item.max_attempts > 10
70
+ || item.attempt_count > item.max_attempts
71
+ || typeof item.revision !== 'number' || !Number.isSafeInteger(item.revision) || item.revision < 1
72
+ || (item.last_error_code !== null && (typeof item.last_error_code !== 'string' || !errorCodePattern.test(item.last_error_code)))
73
+ || !timestamp(item.available_at) || !timestamp(item.created_at) || !timestamp(item.updated_at)
74
+ || (item.completed_at !== null && !timestamp(item.completed_at))
75
+ || (['succeeded', 'dead', 'cancelled'].includes(item.status) !== (item.completed_at !== null))
76
+ ) throw new Error('TASK_RESPONSE_INVALID')
77
+ return {
78
+ jobKey: item.job_key,
79
+ taskType: item.task_type,
80
+ status: item.status as TaskStatus,
81
+ attemptCount: item.attempt_count,
82
+ maxAttempts: item.max_attempts,
83
+ revision: item.revision,
84
+ lastErrorCode: item.last_error_code,
85
+ availableAt: item.available_at,
86
+ createdAt: item.created_at,
87
+ updatedAt: item.updated_at,
88
+ completedAt: item.completed_at,
89
+ }
90
+ }
91
+
92
+ export const parseTaskResponse = (value: unknown): TaskJob => {
93
+ const body = record(value)
94
+ exactKeys(body, ['data', 'meta'])
95
+ record(body.meta)
96
+ return parseTaskJob(body.data)
97
+ }
98
+
99
+ export const parseTaskList = (value: unknown): TaskJobList => {
100
+ const body = record(value)
101
+ exactKeys(body, ['data', 'meta'])
102
+ const data = record(body.data)
103
+ const meta = record(body.meta)
104
+ exactKeys(data, ['items'])
105
+ if (!Array.isArray(data.items)) throw new Error('TASK_RESPONSE_INVALID')
106
+ for (const key of ['page', 'page_size', 'total'] as const) {
107
+ if (typeof meta[key] !== 'number' || !Number.isSafeInteger(meta[key]) || meta[key] < (key === 'total' ? 0 : 1)) {
108
+ throw new Error('TASK_RESPONSE_INVALID')
109
+ }
110
+ }
111
+ return {
112
+ items: data.items.map(parseTaskJob),
113
+ page: meta.page as number,
114
+ pageSize: meta.page_size as number,
115
+ total: meta.total as number,
116
+ }
117
+ }
@@ -0,0 +1,2 @@
1
+ export * from './contracts'
2
+ export * from './runtime'
@@ -0,0 +1,105 @@
1
+ import { defineAdminModule } from '@peanut-admin/admin/core'
2
+ import { inject, reactive } from 'vue'
3
+ import type { AdminModuleContribution } from '@peanut-admin/admin/core'
4
+ import type { InjectionKey } from 'vue'
5
+
6
+ import { parseTaskList, parseTaskResponse } from './contracts'
7
+ import type { TaskJob, TaskJobTransport, TaskStatus, TaskTransportResult } from './contracts'
8
+
9
+ export const TASK_JOB_MODULE_KEY = 'peanut.task-job' as const
10
+ export const TASK_JOB_ROUTE_NAME = 'peanut.task-job.list' as const
11
+ export const TASK_JOB_ROUTE_PATH = '/app/tasks' as const
12
+ export const TASK_JOB_READ_PERMISSION = 'peanut.task-job.read' as const
13
+ export const TASK_JOB_MANAGE_PERMISSION = 'peanut.task-job.manage' as const
14
+ export const TASK_JOB_STORE_KEY = 'peanut.task-job.runtime' as const
15
+
16
+ export interface TaskJobError { readonly message: string; readonly requestId: string | null; readonly status: number | null }
17
+ export interface TaskJobState {
18
+ items: TaskJob[]; status: TaskStatus; page: number; pageSize: number; total: number
19
+ loading: boolean; mutating: boolean; error: TaskJobError | null
20
+ }
21
+ export interface TaskJobRuntime {
22
+ readonly state: TaskJobState
23
+ readonly canManage: () => boolean
24
+ load: () => Promise<void>
25
+ setStatus: (status: TaskStatus) => Promise<void>
26
+ cancel: (job: TaskJob) => Promise<void>
27
+ retry: (job: TaskJob) => Promise<void>
28
+ dispose: () => void
29
+ }
30
+ export interface TaskJobRuntimeOptions {
31
+ readonly transport: TaskJobTransport
32
+ readonly canRead: () => boolean
33
+ readonly canManage: () => boolean
34
+ }
35
+
36
+ const failure = (result: TaskTransportResult): TaskJobError => {
37
+ const body = typeof result.body === 'object' && result.body !== null && !Array.isArray(result.body)
38
+ ? result.body as Record<string, unknown> : {}
39
+ const id = body.request_id ?? result.headers.get('X-Request-Id')
40
+ return {
41
+ message: typeof body.detail === 'string' && body.detail !== '' ? body.detail : `Task request failed (${result.status}).`,
42
+ requestId: typeof id === 'string' && id !== '' ? id : null,
43
+ status: result.status,
44
+ }
45
+ }
46
+
47
+ export const createTaskJobRuntime = (options: TaskJobRuntimeOptions): TaskJobRuntime => {
48
+ const state = reactive<TaskJobState>({
49
+ items: [], status: 'queued', page: 1, pageSize: 20, total: 0,
50
+ loading: false, mutating: false, error: null,
51
+ })
52
+ const controllers = new Set<AbortController>()
53
+ let generation = 0
54
+ const run = async <T>(operation: (signal: AbortSignal) => Promise<T>): Promise<T> => {
55
+ const controller = new AbortController(); controllers.add(controller)
56
+ try { return await operation(controller.signal) } finally { controllers.delete(controller) }
57
+ }
58
+ const load = async (): Promise<void> => {
59
+ const current = ++generation; state.loading = true; state.error = null
60
+ try {
61
+ if (!options.canRead()) throw new Error('TASK_PERMISSION_DENIED')
62
+ const result = await run(signal => options.transport.list(state.status, state.page, state.pageSize, signal))
63
+ if (current !== generation) return
64
+ if (result.status !== 200) { state.error = failure(result); return }
65
+ const list = parseTaskList(result.body)
66
+ state.items = [...list.items]; state.page = list.page; state.pageSize = list.pageSize; state.total = list.total
67
+ } catch {
68
+ if (current === generation) state.error = { message: 'The task service could not be reached.', requestId: null, status: null }
69
+ } finally { if (current === generation) state.loading = false }
70
+ }
71
+ const mutate = async (job: TaskJob, operation: 'cancel' | 'retry'): Promise<void> => {
72
+ if (!options.canManage() || state.mutating) return
73
+ state.mutating = true; state.error = null
74
+ try {
75
+ const result = await run(signal => options.transport[operation](job.jobKey, job.revision, signal))
76
+ if (result.status !== 200) { state.error = failure(result); return }
77
+ parseTaskResponse(result.body); await load()
78
+ } catch { state.error = { message: 'The task action could not be completed.', requestId: null, status: null } }
79
+ finally { state.mutating = false }
80
+ }
81
+ return {
82
+ state, canManage: options.canManage, load,
83
+ async setStatus(status) { state.status = status; state.page = 1; await load() },
84
+ cancel: job => mutate(job, 'cancel'),
85
+ retry: job => mutate(job, 'retry'),
86
+ dispose() { generation += 1; for (const controller of controllers) controller.abort(); controllers.clear() },
87
+ }
88
+ }
89
+
90
+ export const taskJobRuntimeKey: InjectionKey<TaskJobRuntime> = Symbol(TASK_JOB_STORE_KEY)
91
+ export const useTaskJobRuntime = (): TaskJobRuntime => {
92
+ const runtime = inject(taskJobRuntimeKey)
93
+ if (runtime === undefined) throw new Error('TASK_JOB_RUNTIME_MISSING')
94
+ return runtime
95
+ }
96
+ export const createTaskJobModuleContribution = (runtime: TaskJobRuntime): AdminModuleContribution => defineAdminModule({
97
+ key: TASK_JOB_MODULE_KEY,
98
+ routes: [{
99
+ name: TASK_JOB_ROUTE_NAME, path: TASK_JOB_ROUTE_PATH,
100
+ component: async () => ({ default: (await import('./TaskJobPage.vue')).default }),
101
+ access: { moduleKey: TASK_JOB_MODULE_KEY, permissionKeys: [TASK_JOB_READ_PERMISSION] },
102
+ }],
103
+ disposeOnTenantChange: true,
104
+ stores: [{ key: TASK_JOB_STORE_KEY, dispose: runtime.dispose }],
105
+ })
@@ -0,0 +1,141 @@
1
+ import type {
2
+ PlatformContextData,
3
+ ProblemDetails,
4
+ TenantContextData,
5
+ } from '@peanut-admin/admin/core'
6
+
7
+ export const WEB_TESTING_PACKAGE = '@peanut-admin/admin/testing' as const
8
+ export const WEB_TESTING_VERSION = '0.1.0' as const
9
+
10
+ export const mockTenantContext = (
11
+ overrides: Partial<TenantContextData> = {},
12
+ ): TenantContextData => ({
13
+ audience: 'tenant',
14
+ accountId: '1',
15
+ tenantId: '10',
16
+ memberId: '20',
17
+ moduleKeys: ['core'],
18
+ permissionKeys: ['core.member.read'],
19
+ authorizationRevision: '1',
20
+ ...overrides,
21
+ })
22
+
23
+ export const mockPlatformContext = (
24
+ overrides: Partial<PlatformContextData> = {},
25
+ ): PlatformContextData => ({
26
+ audience: 'platform',
27
+ accountId: '1',
28
+ operatorId: '30',
29
+ permissionKeys: ['platform.tenant.read'],
30
+ authorizationRevision: '1',
31
+ ...overrides,
32
+ })
33
+
34
+ export interface MockProblemOptions extends Partial<ProblemDetails> {
35
+ code: string
36
+ status: number
37
+ }
38
+
39
+ export const mockProblemDetails = (options: MockProblemOptions): ProblemDetails => {
40
+ const { code, status, ...overrides } = options
41
+
42
+ return {
43
+ type: `/docs/problems/${code.toLowerCase().replaceAll('_', '-')}`,
44
+ title: 'Request rejected',
45
+ status,
46
+ detail: 'The request was rejected by the test fixture.',
47
+ code,
48
+ request_id: 'req_web_testing_fixture',
49
+ ...overrides,
50
+ }
51
+ }
52
+
53
+ export interface MockAccessState {
54
+ permissionKeys: ReadonlySet<string>
55
+ moduleKeys: ReadonlySet<string>
56
+ hasPermission: (permission: string) => boolean
57
+ hasModule: (moduleKey: string) => boolean
58
+ }
59
+
60
+ export const mockAccessState = (options: {
61
+ permissionKeys?: readonly string[]
62
+ moduleKeys?: readonly string[]
63
+ } = {}): MockAccessState => {
64
+ const permissionKeys = new Set(options.permissionKeys ?? [])
65
+ const moduleKeys = new Set(options.moduleKeys ?? [])
66
+
67
+ return {
68
+ permissionKeys,
69
+ moduleKeys,
70
+ hasPermission: permission => permission !== '*' && permissionKeys.has(permission),
71
+ hasModule: moduleKey => moduleKeys.has(moduleKey),
72
+ }
73
+ }
74
+
75
+ export type GuardResult = boolean | Promise<boolean>
76
+ export type AudienceGuard = (path: string) => GuardResult
77
+
78
+ export interface RouteGuardHarness {
79
+ navigate: (path: string) => Promise<'allowed' | 'denied'>
80
+ }
81
+
82
+ export const createRouteGuardHarness = (guards: {
83
+ tenant: AudienceGuard
84
+ platform: AudienceGuard
85
+ }): RouteGuardHarness => ({
86
+ async navigate(path) {
87
+ const pathname = new URL(path, 'https://peanut-admin.test').pathname
88
+ if (pathname === '/app' || pathname.startsWith('/app/')) {
89
+ return await guards.tenant(pathname) ? 'allowed' : 'denied'
90
+ }
91
+ if (pathname === '/platform' || pathname.startsWith('/platform/')) {
92
+ return await guards.platform(pathname) ? 'allowed' : 'denied'
93
+ }
94
+
95
+ return 'allowed'
96
+ },
97
+ })
98
+
99
+ const containsTenantState = (value: unknown): boolean => {
100
+ if (value === null || value === undefined || value === false || value === '' || value === 0) {
101
+ return false
102
+ }
103
+ if (Array.isArray(value)) {
104
+ return value.length > 0
105
+ }
106
+ if (value instanceof Map || value instanceof Set) {
107
+ return value.size > 0
108
+ }
109
+ if (typeof value === 'object') {
110
+ return Object.values(value).some(containsTenantState)
111
+ }
112
+
113
+ return true
114
+ }
115
+
116
+ export const assertTenantStateDisposed = async (
117
+ inspect: () => unknown,
118
+ switchTenant: () => void | Promise<void>,
119
+ ): Promise<void> => {
120
+ await switchTenant()
121
+ if (containsTenantState(inspect())) {
122
+ throw new Error('TENANT_STATE_LEAK')
123
+ }
124
+ }
125
+
126
+ export interface Deferred<T> {
127
+ promise: Promise<T>
128
+ resolve: (value: T | PromiseLike<T>) => void
129
+ reject: (reason?: unknown) => void
130
+ }
131
+
132
+ export const createDeferred = <T>(): Deferred<T> => {
133
+ let resolve!: Deferred<T>['resolve']
134
+ let reject!: Deferred<T>['reject']
135
+ const promise = new Promise<T>((resolvePromise, rejectPromise) => {
136
+ resolve = resolvePromise
137
+ reject = rejectPromise
138
+ })
139
+
140
+ return { promise, resolve, reject }
141
+ }