@peanut-admin/admin 0.1.0-alpha.5 → 0.1.0-alpha.6

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.
@@ -0,0 +1,37 @@
1
+ import { hasPermission } from './access'
2
+ import { defineAdminOverrideSlot } from '../runtime/overrides'
3
+
4
+ export type PermissionEvaluator = (
5
+ permissions: ReadonlySet<string>,
6
+ permission: string,
7
+ ) => boolean
8
+
9
+ export const PERMISSION_EVALUATOR_OVERRIDE_KEY =
10
+ 'authorization.permission.service.evaluator' as const
11
+
12
+ const isPermissionEvaluator = (candidate: unknown): candidate is PermissionEvaluator => (
13
+ typeof candidate === 'function'
14
+ )
15
+
16
+ export const permissionEvaluatorSlot = defineAdminOverrideSlot({
17
+ key: PERMISSION_EVALUATOR_OVERRIDE_KEY,
18
+ kind: 'service' as const,
19
+ contractVersion: '1.0.0',
20
+ defaultValue: hasPermission as PermissionEvaluator,
21
+ validate: isPermissionEvaluator,
22
+ })
23
+
24
+ export const evaluateRequiredPermissions = (
25
+ requiredPermissions: string | string[],
26
+ grantedPermissions: readonly string[],
27
+ evaluator: PermissionEvaluator,
28
+ ): boolean => {
29
+ const required = (Array.isArray(requiredPermissions) ? requiredPermissions : [requiredPermissions])
30
+ .filter(Boolean)
31
+ if (required.length === 0) return true
32
+
33
+ const permissionSet = new Set(grantedPermissions)
34
+ return permissionSet.has('*') || required.some(
35
+ permission => permission !== '*' && evaluator(permissionSet, permission),
36
+ )
37
+ }
@@ -0,0 +1,32 @@
1
+ export interface TenantChoice {
2
+ tenant_id: number
3
+ tenant_code: string
4
+ tenant_name: string
5
+ member_id: number
6
+ member_display_name: string
7
+ }
8
+
9
+ export interface TenantSelection {
10
+ state: 'tenant_selection_required'
11
+ challenge_token: string
12
+ expires_at: string
13
+ tenants: TenantChoice[]
14
+ }
15
+
16
+ export interface TenantAuthentication {
17
+ state: 'authenticated'
18
+ access_token: string
19
+ token_type: 'Bearer'
20
+ expires_in: number
21
+ context: {
22
+ tenant_id: string
23
+ account_id: string
24
+ tenant_member_id: string
25
+ }
26
+ }
27
+
28
+ export type TenantSessionOutcome = TenantSelection | TenantAuthentication
29
+
30
+ export const isMultiTenantDeployment = (value: unknown): boolean => value === 'multi-tenant'
31
+
32
+ export const isTenantAccessToken = (token: string | null): boolean => token?.startsWith('pa_tat_') === true
@@ -14,6 +14,12 @@ export { isProblemCode, parseProblemDetails } from './api/problem'
14
14
  export type { ProblemDetails, ProblemFieldError } from './api/problem'
15
15
  export { hasAllPermissions, hasPermission, useAccess } from './access/access'
16
16
  export type { AccessHints } from './access/access'
17
+ export {
18
+ evaluateRequiredPermissions,
19
+ permissionEvaluatorSlot,
20
+ PERMISSION_EVALUATOR_OVERRIDE_KEY,
21
+ } from './access/permission-policy'
22
+ export type { PermissionEvaluator } from './access/permission-policy'
17
23
  export {
18
24
  usePlatformAuth,
19
25
  usePlatformContext,
@@ -21,10 +27,19 @@ export {
21
27
  useTenantContext,
22
28
  } from './auth/stores'
23
29
  export type { PlatformContextData, TenantContextData } from './auth/stores'
30
+ export { isMultiTenantDeployment, isTenantAccessToken } from './auth/tenant-session'
31
+ export type {
32
+ TenantAuthentication,
33
+ TenantChoice,
34
+ TenantSelection,
35
+ TenantSessionOutcome,
36
+ } from './auth/tenant-session'
24
37
  export { disposeTenantState, registerTenantDisposer } from './lifecycle/tenant'
25
38
  export { createTenantLifecycle } from './lifecycle/tenant'
26
39
  export type { TenantDisposer, TenantLifecycle, TenantLifecycleTicket } from './lifecycle/tenant'
27
40
  export { createMenuRouteRegistry, defineAdminModule } from './module/contribution'
41
+ export { collectPluginContributions, routesForTenantModules } from './module/plugin-contribution-policy'
42
+ export { enabledTenantModulesFromRoutes } from './module/tenant-modules'
28
43
  export type {
29
44
  AdminModuleContribution,
30
45
  AdminModuleLocaleContribution,
@@ -35,6 +50,8 @@ export type {
35
50
  AdminRouteAccess,
36
51
  MenuRouteRegistry,
37
52
  } from './module/contribution'
53
+ export type { PluginFrontendContribution, PluginFrontendRoute } from './module/plugin-contribution-policy'
54
+ export type { TenantModuleRoute } from './module/tenant-modules'
38
55
  export { defineAdminHostConfig } from './runtime/config'
39
56
  export type { AdminAudienceHostConfig, AdminHostConfig } from './runtime/config'
40
57
  export { mapAdminRuntimeError } from './runtime/errors'
@@ -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,36 @@
1
+ export type DeploymentMode = 'standalone' | 'multi-tenant'
2
+
3
+ /**
4
+ * Minimal router contract shared by host applications without coupling to a
5
+ * particular Vue Router major version.
6
+ */
7
+ export interface DeploymentRoute {
8
+ meta?: {
9
+ controlPlane?: unknown
10
+ instanceTool?: boolean
11
+ [key: string]: unknown
12
+ }
13
+ children?: DeploymentRoute[]
14
+ }
15
+
16
+ export const deploymentMode = (value: unknown): DeploymentMode => (
17
+ value === 'multi-tenant' ? 'multi-tenant' : 'standalone'
18
+ )
19
+
20
+ export const allowsInstanceTools = (value: unknown): boolean => value === 'standalone'
21
+
22
+ export const routesForDeployment = <T extends DeploymentRoute>(
23
+ routes: T[],
24
+ mode: DeploymentMode,
25
+ instanceToolsAllowed = mode === 'standalone',
26
+ ): T[] => routes.reduce<T[]>((visible, route) => {
27
+ if (mode !== 'multi-tenant' && route.meta?.controlPlane !== undefined) return visible
28
+ if (!instanceToolsAllowed && route.meta?.instanceTool === true) return visible
29
+ visible.push({
30
+ ...route,
31
+ children: route.children
32
+ ? routesForDeployment(route.children, mode, instanceToolsAllowed)
33
+ : route.children,
34
+ } as T)
35
+ return visible
36
+ }, [])
@@ -29,6 +29,10 @@ export { TargetScopeSummary, TargetSelector } from './targets'
29
29
  export type { TargetScopeMode } from './targets'
30
30
  export { SHELL_THEME_TOKENS } from './theme'
31
31
  export type { ShellSlotName, ShellThemeToken } from './theme'
32
+ export { allowsInstanceTools, deploymentMode, routesForDeployment } from './deployment-mode'
33
+ export type { DeploymentMode, DeploymentRoute } from './deployment-mode'
34
+ export { tabFromRoute } from './tabs'
35
+ export type { ShellTab, ShellTabRoute, ShellTabState } from './tabs'
32
36
  export {
33
37
  ADMIN_SHELL_OVERRIDE_SLOTS,
34
38
  resolveWorkspaceShell,
@@ -0,0 +1,31 @@
1
+ /** Framework-neutral route data required by an admin workspace tab. */
2
+ export interface ShellTabRoute {
3
+ name: unknown
4
+ fullPath: string
5
+ query?: any
6
+ meta?: {
7
+ locale?: string
8
+ ignoreCache?: boolean | undefined
9
+ }
10
+ }
11
+
12
+ export interface ShellTab {
13
+ title: string
14
+ name: string
15
+ fullPath: string
16
+ query?: any
17
+ ignoreCache?: boolean | undefined
18
+ }
19
+
20
+ export interface ShellTabState {
21
+ tagList: ShellTab[]
22
+ cacheTabList: Set<string>
23
+ }
24
+
25
+ export const tabFromRoute = (route: ShellTabRoute): ShellTab => ({
26
+ title: route.meta?.locale || '',
27
+ name: String(route.name),
28
+ fullPath: route.fullPath,
29
+ query: route.query,
30
+ ignoreCache: route.meta?.ignoreCache,
31
+ })
package/package.json CHANGED
@@ -1,17 +1,14 @@
1
1
  {
2
2
  "name": "@peanut-admin/admin",
3
- "version": "0.1.0-alpha.5",
3
+ "version": "0.1.0-alpha.6",
4
4
  "description": "Reusable Peanut Admin Web services and module contributions",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
7
7
  "repository": {
8
8
  "type": "git",
9
- "url": "git+https://github.com/peanut-opensource/peanut-admin.git",
10
- "directory": "packages/web"
9
+ "url": "https://github.com/peanut-opensource/peanut-admin-core"
11
10
  },
12
- "sideEffects": [
13
- "*.vue"
14
- ],
11
+ "sideEffects": ["*.vue"],
15
12
  "files": [
16
13
  "admin-core/src",
17
14
  "admin-shell/src",
@@ -23,7 +20,6 @@
23
20
  "import-export/src",
24
21
  "ops-console/src",
25
22
  "integration-security/src",
26
- "collaboration/src",
27
23
  "testing/src",
28
24
  "client-core/src",
29
25
  "client-nuxt/src",
@@ -70,10 +66,6 @@
70
66
  "types": "./integration-security/src/index.ts",
71
67
  "import": "./integration-security/src/index.ts"
72
68
  },
73
- "./collaboration": {
74
- "types": "./collaboration/src/index.ts",
75
- "import": "./collaboration/src/index.ts"
76
- },
77
69
  "./testing": {
78
70
  "development": {
79
71
  "types": "./testing/src/index.ts",
@@ -95,57 +87,24 @@
95
87
  },
96
88
  "typesVersions": {
97
89
  "*": {
98
- "core": [
99
- "admin-core/src/index.ts"
100
- ],
101
- "shell": [
102
- "admin-shell/src/index.ts"
103
- ],
104
- "settings": [
105
- "settings/src/index.ts"
106
- ],
107
- "reference-codes": [
108
- "reference-codes/src/index.ts"
109
- ],
110
- "file-media": [
111
- "file-media/src/index.ts"
112
- ],
113
- "task-job": [
114
- "task-job/src/index.ts"
115
- ],
116
- "notification-sms": [
117
- "notification-sms/src/index.ts"
118
- ],
119
- "import-export": [
120
- "import-export/src/index.ts"
121
- ],
122
- "ops-console": [
123
- "ops-console/src/index.ts"
124
- ],
125
- "integration-security": [
126
- "integration-security/src/index.ts"
127
- ],
128
- "collaboration": [
129
- "collaboration/src/index.ts"
130
- ],
131
- "testing": [
132
- "testing/src/index.ts"
133
- ],
134
- "client": [
135
- "client-core/src/index.ts"
136
- ],
137
- "client/nuxt": [
138
- "client-nuxt/src/index.ts"
139
- ],
140
- "client/uniapp": [
141
- "client-uniapp/src/index.ts"
142
- ]
90
+ "core": ["admin-core/src/index.ts"],
91
+ "shell": ["admin-shell/src/index.ts"],
92
+ "settings": ["settings/src/index.ts"],
93
+ "reference-codes": ["reference-codes/src/index.ts"],
94
+ "file-media": ["file-media/src/index.ts"],
95
+ "task-job": ["task-job/src/index.ts"],
96
+ "notification-sms": ["notification-sms/src/index.ts"],
97
+ "import-export": ["import-export/src/index.ts"],
98
+ "ops-console": ["ops-console/src/index.ts"],
99
+ "integration-security": ["integration-security/src/index.ts"],
100
+ "testing": ["testing/src/index.ts"],
101
+ "client": ["client-core/src/index.ts"],
102
+ "client/nuxt": ["client-nuxt/src/index.ts"],
103
+ "client/uniapp": ["client-uniapp/src/index.ts"]
143
104
  }
144
105
  },
145
106
  "dependencies": {
146
- "openapi-fetch": "0.17.0",
147
- "y-websocket": "3.1.0",
148
- "yjs": "13.6.32"
107
+ "openapi-fetch": "0.17.0"
149
108
  },
150
109
  "devDependencies": {
151
110
  "@vue/test-utils": "2.4.11",
@@ -162,7 +121,8 @@
162
121
  "peerDependencies": {
163
122
  "element-plus": "^2.14.3",
164
123
  "pinia": ">=2.0.23 <5",
165
- "vue": "^3.4.21"
124
+ "vue": "^3.4.21",
125
+ "vue-router": ">=4.0.0 <6"
166
126
  },
167
127
  "peerDependenciesMeta": {
168
128
  "element-plus": {
@@ -173,7 +133,7 @@
173
133
  }
174
134
  },
175
135
  "scripts": {
176
- "test": "vitest run admin-core/tests admin-shell/tests collaboration/tests file-media/tests import-export/tests integration-security/tests notification-sms/tests ops-console/tests reference-codes/tests settings/tests testing/tests client-core/tests client-nuxt/tests client-uniapp/tests",
177
- "typecheck": "tsc --noEmit -p admin-core/tsconfig.json && tsc --noEmit -p admin-shell/tsconfig.json && tsc --noEmit -p collaboration/tsconfig.json && vue-tsc --noEmit -p file-media/tsconfig.json && vue-tsc --noEmit -p import-export/tsconfig.json && vue-tsc --noEmit -p integration-security/tsconfig.json && vue-tsc --noEmit -p notification-sms/tsconfig.json && vue-tsc --noEmit -p ops-console/tsconfig.json && vue-tsc --noEmit -p reference-codes/tsconfig.json && vue-tsc --noEmit -p settings/tsconfig.json && vue-tsc --noEmit -p task-job/tsconfig.json && tsc --noEmit -p testing/tsconfig.json && tsc --noEmit -p client-core/tsconfig.json && tsc --noEmit -p client-nuxt/tsconfig.json && tsc --noEmit -p client-uniapp/tsconfig.json"
136
+ "test": "vitest run admin-core/tests admin-shell/tests file-media/tests import-export/tests integration-security/tests notification-sms/tests ops-console/tests reference-codes/tests settings/tests testing/tests client-core/tests client-nuxt/tests client-uniapp/tests",
137
+ "typecheck": "tsc --noEmit -p admin-core/tsconfig.json && tsc --noEmit -p admin-shell/tsconfig.json && vue-tsc --noEmit -p file-media/tsconfig.json && vue-tsc --noEmit -p import-export/tsconfig.json && vue-tsc --noEmit -p integration-security/tsconfig.json && vue-tsc --noEmit -p notification-sms/tsconfig.json && vue-tsc --noEmit -p ops-console/tsconfig.json && vue-tsc --noEmit -p reference-codes/tsconfig.json && vue-tsc --noEmit -p settings/tsconfig.json && vue-tsc --noEmit -p task-job/tsconfig.json && tsc --noEmit -p testing/tsconfig.json && tsc --noEmit -p client-core/tsconfig.json && tsc --noEmit -p client-nuxt/tsconfig.json && tsc --noEmit -p client-uniapp/tsconfig.json"
178
138
  }
179
- }
139
+ }
@@ -1,207 +0,0 @@
1
- export const COLLABORATION_ENGINE_NAME = 'yjs' as const
2
- export const COLLABORATION_ENGINE_VERSION = '13.6.32' as const
3
-
4
- export type CollaborationCapability = 'read' | 'write'
5
- export type CollaborationSessionState = 'active' | 'published' | 'closed' | 'expired'
6
- export type CollaborationConnectionStatus = 'idle' | 'admitting' | 'hydrating' | 'connecting' | 'connected' | 'disconnected' | 'error' | 'disposed'
7
- export type CollaborationUpdateOrigin = 'local' | 'remote' | 'replay'
8
- export type CollaborationTransportStatus = 'connecting' | 'connected' | 'disconnected'
9
- export type CollaborationErrorCode =
10
- | 'COLLABORATION_INVALID'
11
- | 'COLLABORATION_NOT_FOUND'
12
- | 'COLLABORATION_DENIED'
13
- | 'COLLABORATION_CONFLICT'
14
- | 'COLLABORATION_LEASE_EXPIRED'
15
- | 'COLLABORATION_PAYLOAD_TOO_LARGE'
16
- | 'COLLABORATION_BACKPRESSURE'
17
- | 'COLLABORATION_PROVIDER_UNAVAILABLE'
18
- | 'COLLABORATION_INTEGRITY_FAILURE'
19
- | 'COLLABORATION_INTERNAL_ERROR'
20
-
21
- export interface CollaborationSession {
22
- readonly sessionKey: string
23
- readonly artifactType: string
24
- readonly artifactKey: string
25
- readonly engineName: typeof COLLABORATION_ENGINE_NAME
26
- readonly engineVersion: typeof COLLABORATION_ENGINE_VERSION
27
- readonly baseRevisionKey: string
28
- readonly baseRevisionDigest: string
29
- readonly latestSequence: number
30
- readonly state: CollaborationSessionState
31
- readonly expiresAt: string
32
- }
33
-
34
- export interface CollaborationLease {
35
- readonly leaseKey: string
36
- readonly clientKey: string
37
- readonly capability: CollaborationCapability
38
- readonly expiresAt: string
39
- }
40
-
41
- export interface CollaborationTransportAdmission {
42
- readonly websocketUrl: string
43
- readonly roomName: string
44
- }
45
-
46
- export interface CollaborationAdmission {
47
- readonly session: CollaborationSession
48
- readonly lease: CollaborationLease
49
- readonly transport: CollaborationTransportAdmission
50
- }
51
-
52
- export interface CollaborationUpdateEnvelope {
53
- readonly updateKey: string
54
- readonly sequence: number
55
- readonly engineName: typeof COLLABORATION_ENGINE_NAME
56
- readonly engineVersion: typeof COLLABORATION_ENGINE_VERSION
57
- readonly digest: string
58
- readonly payload: Uint8Array
59
- }
60
-
61
- export interface CollaborationSnapshotEnvelope {
62
- readonly snapshotKey: string
63
- readonly coveredSequence: number
64
- readonly engineName: typeof COLLABORATION_ENGINE_NAME
65
- readonly engineVersion: typeof COLLABORATION_ENGINE_VERSION
66
- readonly snapshotDigest: string
67
- readonly stateVectorDigest: string
68
- readonly snapshot: Uint8Array
69
- readonly stateVector: Uint8Array
70
- }
71
-
72
- export interface CollaborationStatePage {
73
- readonly snapshot: CollaborationSnapshotEnvelope | null
74
- readonly updates: readonly CollaborationUpdateEnvelope[]
75
- readonly latestSequence: number
76
- readonly nextAfterSequence: number | null
77
- }
78
-
79
- export interface CollaborationSafeError {
80
- readonly code: CollaborationErrorCode
81
- readonly message: string
82
- readonly requestId: string | null
83
- readonly status: number
84
- }
85
-
86
- export interface CollaborationEngine<TDocument = unknown> {
87
- readonly document: TDocument
88
- applyUpdate: (update: Uint8Array, origin?: Exclude<CollaborationUpdateOrigin, 'local'>) => void
89
- encodeStateVector: () => Uint8Array
90
- encodeSnapshot: () => Uint8Array
91
- onUpdate: (listener: (update: Uint8Array, origin: CollaborationUpdateOrigin) => void) => () => void
92
- dispose: () => void
93
- }
94
-
95
- export interface CollaborationTransport {
96
- connect: (admission: CollaborationTransportAdmission, initialSnapshot: Uint8Array) => void
97
- disconnect: () => void
98
- sendUpdate: (update: Uint8Array) => void
99
- onStatus: (listener: (status: CollaborationTransportStatus) => void) => () => void
100
- onUpdate: (listener: (update: Uint8Array) => void) => () => void
101
- dispose: () => void
102
- }
103
-
104
- export interface CollaborationHostApi {
105
- admit: (signal: AbortSignal) => Promise<CollaborationAdmission>
106
- state: (sessionKey: string, afterSequence: number, signal: AbortSignal) => Promise<CollaborationStatePage>
107
- }
108
-
109
- export interface CollaborationRuntimeState {
110
- readonly status: CollaborationConnectionStatus
111
- readonly session: CollaborationSession | null
112
- readonly lease: CollaborationLease | null
113
- readonly latestSequence: number
114
- readonly error: CollaborationSafeError | null
115
- }
116
-
117
- const stableKey = /^[a-z][a-z0-9]*(?:[.-][a-z0-9]+)*$/
118
- const opaqueKey = /^[a-z][a-z0-9]*_[0-9a-f]{32}$/
119
- const sha256 = /^[0-9a-f]{64}$/
120
- const instant = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/
121
-
122
- const printable = (value: string, maximum: number): boolean => value.length >= 1 && value.length <= maximum && /^[\x20-\x7e]+$/.test(value)
123
- const validInstant = (value: string): boolean => instant.test(value) && Number.isFinite(Date.parse(value))
124
- const sequence = (value: number): boolean => Number.isSafeInteger(value) && value >= 0
125
- const bytes = (value: Uint8Array, maximum: number): boolean => value.byteLength >= 1 && value.byteLength <= maximum
126
-
127
- export const assertCollaborationAdmission = (admission: CollaborationAdmission): void => {
128
- const { session, lease, transport } = admission
129
- if (!opaqueKey.test(session.sessionKey) || !stableKey.test(session.artifactType) || session.artifactType.length > 64
130
- || !printable(session.artifactKey, 128) || session.engineName !== COLLABORATION_ENGINE_NAME
131
- || session.engineVersion !== COLLABORATION_ENGINE_VERSION || !printable(session.baseRevisionKey, 128)
132
- || !sha256.test(session.baseRevisionDigest) || !sequence(session.latestSequence) || session.state !== 'active'
133
- || !validInstant(session.expiresAt) || !opaqueKey.test(lease.leaseKey) || !printable(lease.clientKey, 128)
134
- || (lease.capability !== 'read' && lease.capability !== 'write') || !validInstant(lease.expiresAt)
135
- || typeof transport.websocketUrl !== 'string' || !printable(transport.roomName, 128)) {
136
- throw new Error('COLLABORATION_RESPONSE_INVALID')
137
- }
138
- }
139
-
140
- export const assertCollaborationStatePage = (page: CollaborationStatePage, afterSequence: number): void => {
141
- if (!sequence(page.latestSequence) || page.latestSequence < afterSequence
142
- || (page.nextAfterSequence !== null && (!sequence(page.nextAfterSequence) || page.nextAfterSequence <= afterSequence || page.nextAfterSequence > page.latestSequence))) {
143
- throw new Error('COLLABORATION_RESPONSE_INVALID')
144
- }
145
- let cursor = afterSequence
146
- if (page.snapshot !== null) {
147
- const snapshot = page.snapshot
148
- if (!opaqueKey.test(snapshot.snapshotKey) || !sequence(snapshot.coveredSequence) || snapshot.coveredSequence < afterSequence
149
- || snapshot.coveredSequence > page.latestSequence || snapshot.engineName !== COLLABORATION_ENGINE_NAME
150
- || snapshot.engineVersion !== COLLABORATION_ENGINE_VERSION || !sha256.test(snapshot.snapshotDigest)
151
- || !sha256.test(snapshot.stateVectorDigest) || !bytes(snapshot.snapshot, 8_388_608) || !bytes(snapshot.stateVector, 8_388_608)) {
152
- throw new Error('COLLABORATION_RESPONSE_INVALID')
153
- }
154
- cursor = snapshot.coveredSequence
155
- }
156
- for (const update of page.updates) {
157
- if (!opaqueKey.test(update.updateKey) || update.sequence !== cursor + 1 || update.sequence > page.latestSequence
158
- || update.engineName !== COLLABORATION_ENGINE_NAME || update.engineVersion !== COLLABORATION_ENGINE_VERSION
159
- || !sha256.test(update.digest) || !bytes(update.payload, 262_144)) {
160
- throw new Error('COLLABORATION_RESPONSE_INVALID')
161
- }
162
- cursor = update.sequence
163
- }
164
- if (page.nextAfterSequence !== null && page.nextAfterSequence !== cursor) throw new Error('COLLABORATION_RESPONSE_INVALID')
165
- if (page.nextAfterSequence === null && cursor !== page.latestSequence) throw new Error('COLLABORATION_RESPONSE_INVALID')
166
- }
167
-
168
- const messages: Readonly<Record<CollaborationErrorCode, string>> = {
169
- COLLABORATION_INVALID: 'The collaboration request was rejected.',
170
- COLLABORATION_NOT_FOUND: 'The collaboration session was not found.',
171
- COLLABORATION_DENIED: 'You do not have access to this collaboration session.',
172
- COLLABORATION_CONFLICT: 'The collaboration session changed. Reopen it and try again.',
173
- COLLABORATION_LEASE_EXPIRED: 'The collaboration lease expired. Reconnect to continue.',
174
- COLLABORATION_PAYLOAD_TOO_LARGE: 'The collaboration update is too large.',
175
- COLLABORATION_BACKPRESSURE: 'The collaboration session must be saved before more updates can be accepted.',
176
- COLLABORATION_PROVIDER_UNAVAILABLE: 'The collaboration service is temporarily unavailable.',
177
- COLLABORATION_INTEGRITY_FAILURE: 'The collaboration update could not be verified.',
178
- COLLABORATION_INTERNAL_ERROR: 'The collaboration request could not be completed.',
179
- }
180
-
181
- const statuses: Readonly<Record<CollaborationErrorCode, number>> = {
182
- COLLABORATION_INVALID: 422,
183
- COLLABORATION_NOT_FOUND: 404,
184
- COLLABORATION_DENIED: 403,
185
- COLLABORATION_CONFLICT: 409,
186
- COLLABORATION_LEASE_EXPIRED: 409,
187
- COLLABORATION_PAYLOAD_TOO_LARGE: 413,
188
- COLLABORATION_BACKPRESSURE: 429,
189
- COLLABORATION_PROVIDER_UNAVAILABLE: 503,
190
- COLLABORATION_INTEGRITY_FAILURE: 500,
191
- COLLABORATION_INTERNAL_ERROR: 500,
192
- }
193
-
194
- export class CollaborationRequestError extends Error {
195
- readonly safe: CollaborationSafeError
196
-
197
- constructor(code: CollaborationErrorCode, requestId: string | null = null) {
198
- super(messages[code])
199
- this.name = 'CollaborationRequestError'
200
- const safeRequestId = requestId !== null && /^[A-Za-z0-9._-]{1,128}$/.test(requestId) ? requestId : null
201
- this.safe = { code, message: messages[code], requestId: safeRequestId, status: statuses[code] }
202
- }
203
- }
204
-
205
- export const safeCollaborationError = (error: unknown): CollaborationSafeError => error instanceof CollaborationRequestError
206
- ? error.safe
207
- : new CollaborationRequestError('COLLABORATION_INTERNAL_ERROR').safe
@@ -1,46 +0,0 @@
1
- import * as Y from 'yjs'
2
- import type { CollaborationEngine, CollaborationUpdateOrigin } from './contracts'
3
-
4
- const remoteOrigin = Symbol('peanut.collaboration.remote')
5
- const replayOrigin = Symbol('peanut.collaboration.replay')
6
-
7
- export type YjsCollaborationEngine = CollaborationEngine<Y.Doc>
8
-
9
- export const createYjsCollaborationEngine = (options: { readonly gc?: boolean; readonly guid?: string } = {}): YjsCollaborationEngine => {
10
- const document = new Y.Doc(options)
11
- const listeners = new Set<(update: Uint8Array, origin: CollaborationUpdateOrigin) => void>()
12
- let disposed = false
13
- const updated = (update: Uint8Array, origin: unknown): void => {
14
- if (disposed) return
15
- const kind: CollaborationUpdateOrigin = origin === remoteOrigin ? 'remote' : origin === replayOrigin ? 'replay' : 'local'
16
- for (const listener of listeners) listener(update.slice(), kind)
17
- }
18
- document.on('update', updated)
19
- return {
20
- document,
21
- applyUpdate(update, origin = 'remote') {
22
- if (disposed) throw new Error('COLLABORATION_ENGINE_DISPOSED')
23
- Y.applyUpdate(document, update, origin === 'replay' ? replayOrigin : remoteOrigin)
24
- },
25
- encodeStateVector() {
26
- if (disposed) throw new Error('COLLABORATION_ENGINE_DISPOSED')
27
- return Y.encodeStateVector(document)
28
- },
29
- encodeSnapshot() {
30
- if (disposed) throw new Error('COLLABORATION_ENGINE_DISPOSED')
31
- return Y.encodeStateAsUpdate(document)
32
- },
33
- onUpdate(listener) {
34
- if (disposed) throw new Error('COLLABORATION_ENGINE_DISPOSED')
35
- listeners.add(listener)
36
- return () => { listeners.delete(listener) }
37
- },
38
- dispose() {
39
- if (disposed) return
40
- disposed = true
41
- listeners.clear()
42
- document.off('update', updated)
43
- document.destroy()
44
- },
45
- }
46
- }
@@ -1,4 +0,0 @@
1
- export * from './contracts'
2
- export * from './engine'
3
- export * from './runtime'
4
- export * from './transport'
@@ -1,157 +0,0 @@
1
- import {
2
- CollaborationRequestError,
3
- assertCollaborationAdmission,
4
- assertCollaborationStatePage,
5
- safeCollaborationError,
6
- } from './contracts'
7
- import type {
8
- CollaborationEngine,
9
- CollaborationHostApi,
10
- CollaborationRuntimeState,
11
- CollaborationTransport,
12
- } from './contracts'
13
-
14
- export interface CollaborationRuntime {
15
- readonly state: CollaborationRuntimeState
16
- connect: () => Promise<void>
17
- reconnect: () => Promise<void>
18
- disconnect: () => void
19
- onState: (listener: (state: CollaborationRuntimeState) => void) => () => void
20
- encodeSnapshot: () => Uint8Array
21
- encodeStateVector: () => Uint8Array
22
- dispose: () => void
23
- }
24
-
25
- export interface CollaborationRuntimeOptions<TDocument = unknown> {
26
- readonly host: CollaborationHostApi
27
- readonly engine: CollaborationEngine<TDocument>
28
- readonly transport: CollaborationTransport
29
- }
30
-
31
- const aborted = (error: unknown): boolean => error instanceof DOMException && error.name === 'AbortError'
32
-
33
- export const createCollaborationRuntime = <TDocument>(options: CollaborationRuntimeOptions<TDocument>): CollaborationRuntime => {
34
- const listeners = new Set<(state: CollaborationRuntimeState) => void>()
35
- let current: CollaborationRuntimeState = { status: 'idle', session: null, lease: null, latestSequence: 0, error: null }
36
- let controller: AbortController | null = null
37
- let generation = 0
38
- let disposed = false
39
- let establishedSessionKey: string | null = null
40
-
41
- const publish = (patch: Partial<CollaborationRuntimeState>): void => {
42
- current = { ...current, ...patch }
43
- for (const listener of listeners) listener(current)
44
- }
45
- const disconnect = (): void => {
46
- if (disposed) return
47
- generation += 1
48
- controller?.abort()
49
- controller = null
50
- options.transport.disconnect()
51
- publish({ status: 'disconnected', lease: null, error: null })
52
- }
53
- const hydrate = async (sessionKey: string, firstSequence: number, signal: AbortSignal, run: number): Promise<number> => {
54
- let cursor = firstSequence
55
- for (let pages = 0; pages < 1000; pages += 1) {
56
- const page = await options.host.state(sessionKey, cursor, signal)
57
- if (run !== generation) return cursor
58
- assertCollaborationStatePage(page, cursor)
59
- if (page.snapshot !== null) {
60
- options.engine.applyUpdate(page.snapshot.snapshot, 'replay')
61
- cursor = page.snapshot.coveredSequence
62
- }
63
- for (const update of page.updates) {
64
- options.engine.applyUpdate(update.payload, 'replay')
65
- cursor = update.sequence
66
- }
67
- if (page.nextAfterSequence === null) return page.latestSequence
68
- cursor = page.nextAfterSequence
69
- }
70
- throw new CollaborationRequestError('COLLABORATION_INTERNAL_ERROR')
71
- }
72
- const connect = async (): Promise<void> => {
73
- if (disposed) throw new Error('COLLABORATION_RUNTIME_DISPOSED')
74
- const run = ++generation
75
- controller?.abort()
76
- options.transport.disconnect()
77
- const nextController = new AbortController()
78
- controller = nextController
79
- publish({ status: 'admitting', lease: null, error: null })
80
- try {
81
- const admission = await options.host.admit(nextController.signal)
82
- if (run !== generation) return
83
- assertCollaborationAdmission(admission)
84
- if (establishedSessionKey !== null && establishedSessionKey !== admission.session.sessionKey) {
85
- throw new CollaborationRequestError('COLLABORATION_CONFLICT')
86
- }
87
- const initialSequence = establishedSessionKey === null ? 0 : current.latestSequence
88
- publish({ status: 'hydrating', session: admission.session, lease: admission.lease, latestSequence: initialSequence })
89
- const latestSequence = await hydrate(admission.session.sessionKey, initialSequence, nextController.signal, run)
90
- if (run !== generation) return
91
- if (latestSequence !== admission.session.latestSequence) throw new CollaborationRequestError('COLLABORATION_INTEGRITY_FAILURE')
92
- establishedSessionKey = admission.session.sessionKey
93
- publish({ status: 'connecting', latestSequence })
94
- options.transport.connect(admission.transport, options.engine.encodeSnapshot())
95
- } catch (error) {
96
- if (run !== generation || aborted(error)) return
97
- options.transport.disconnect()
98
- publish({ status: 'error', lease: null, error: safeCollaborationError(error) })
99
- } finally {
100
- if (controller === nextController) controller = null
101
- }
102
- }
103
-
104
- const removeEngineListener = options.engine.onUpdate((update, origin) => {
105
- if ((current.status === 'connecting' || current.status === 'connected') && origin === 'local') {
106
- try {
107
- if (update.byteLength > 262_144) throw new CollaborationRequestError('COLLABORATION_PAYLOAD_TOO_LARGE')
108
- options.transport.sendUpdate(update)
109
- } catch (error) {
110
- options.transport.disconnect()
111
- publish({ status: 'error', lease: null, error: safeCollaborationError(error) })
112
- }
113
- }
114
- })
115
- const removeTransportUpdateListener = options.transport.onUpdate(update => {
116
- if (current.status !== 'connecting' && current.status !== 'connected') return
117
- try { options.engine.applyUpdate(update, 'remote') } catch (error) {
118
- options.transport.disconnect()
119
- publish({ status: 'error', lease: null, error: safeCollaborationError(error) })
120
- }
121
- })
122
- const removeTransportStatusListener = options.transport.onStatus(status => {
123
- if (disposed || current.status === 'disposed' || current.status === 'error') return
124
- if (status === 'connected') publish({ status: 'connected', error: null })
125
- else if (status === 'connecting') publish({ status: 'connecting' })
126
- else publish({ status: 'disconnected', lease: null })
127
- })
128
-
129
- return {
130
- get state() { return current },
131
- connect,
132
- async reconnect() { disconnect(); await connect() },
133
- disconnect,
134
- onState(listener) {
135
- if (disposed) throw new Error('COLLABORATION_RUNTIME_DISPOSED')
136
- listeners.add(listener)
137
- return () => { listeners.delete(listener) }
138
- },
139
- encodeSnapshot: () => options.engine.encodeSnapshot(),
140
- encodeStateVector: () => options.engine.encodeStateVector(),
141
- dispose() {
142
- if (disposed) return
143
- generation += 1
144
- controller?.abort()
145
- controller = null
146
- removeEngineListener()
147
- removeTransportUpdateListener()
148
- removeTransportStatusListener()
149
- options.transport.dispose()
150
- options.engine.dispose()
151
- disposed = true
152
- current = { ...current, status: 'disposed', lease: null, error: null }
153
- for (const listener of listeners) listener(current)
154
- listeners.clear()
155
- },
156
- }
157
- }
@@ -1,142 +0,0 @@
1
- import * as Y from 'yjs'
2
- import { WebsocketProvider } from 'y-websocket'
3
- import type { CollaborationTransport, CollaborationTransportAdmission, CollaborationTransportStatus } from './contracts'
4
-
5
- interface ProviderStatusEvent { readonly status: CollaborationTransportStatus }
6
- interface CollaborationWebsocketProvider {
7
- connect: () => void
8
- disconnect: () => void
9
- destroy: () => void
10
- on: (event: 'status', listener: (event: ProviderStatusEvent) => void) => void
11
- off: (event: 'status', listener: (event: ProviderStatusEvent) => void) => void
12
- }
13
-
14
- export interface CollaborationWebsocketProviderOptions {
15
- readonly connect: false
16
- readonly disableBc: true
17
- }
18
-
19
- export type CollaborationWebsocketProviderFactory = (
20
- websocketUrl: string,
21
- roomName: string,
22
- document: Y.Doc,
23
- options: CollaborationWebsocketProviderOptions,
24
- ) => CollaborationWebsocketProvider
25
-
26
- export interface YWebsocketCollaborationTransportOptions {
27
- readonly hostOrigin?: string
28
- readonly providerFactory?: CollaborationWebsocketProviderFactory
29
- }
30
-
31
- const providerFactory: CollaborationWebsocketProviderFactory = (websocketUrl, roomName, document, options) => new WebsocketProvider(
32
- websocketUrl,
33
- roomName,
34
- document,
35
- options,
36
- )
37
-
38
- const loopback = (hostname: string): boolean => hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '[::1]'
39
-
40
- const validateAdmission = (admission: CollaborationTransportAdmission, hostOrigin: string): URL => {
41
- let websocket: URL
42
- let host: URL
43
- try {
44
- websocket = new URL(admission.websocketUrl)
45
- host = new URL(hostOrigin)
46
- } catch {
47
- throw new Error('COLLABORATION_TRANSPORT_INVALID')
48
- }
49
- const expectedProtocol = host.protocol === 'https:' ? 'wss:' : host.protocol === 'http:' ? 'ws:' : ''
50
- if ((websocket.protocol !== 'wss:' && websocket.protocol !== 'ws:') || websocket.protocol !== expectedProtocol
51
- || websocket.hostname !== host.hostname || websocket.port !== host.port || websocket.username !== '' || websocket.password !== ''
52
- || websocket.search !== '' || websocket.hash !== '' || (websocket.protocol === 'ws:' && !loopback(websocket.hostname))
53
- || !/^[a-z0-9][a-z0-9._-]{0,127}$/.test(admission.roomName)) {
54
- throw new Error('COLLABORATION_TRANSPORT_INVALID')
55
- }
56
- return websocket
57
- }
58
-
59
- export const createYWebsocketCollaborationTransport = (options: YWebsocketCollaborationTransportOptions = {}): CollaborationTransport => {
60
- const statusListeners = new Set<(status: CollaborationTransportStatus) => void>()
61
- const updateListeners = new Set<(update: Uint8Array) => void>()
62
- const createProvider = options.providerFactory ?? providerFactory
63
- let provider: CollaborationWebsocketProvider | null = null
64
- let document: Y.Doc | null = null
65
- let statusHandler: ((event: ProviderStatusEvent) => void) | null = null
66
- let updateHandler: ((update: Uint8Array, origin: unknown) => void) | null = null
67
- let disposed = false
68
-
69
- const notifyStatus = (status: CollaborationTransportStatus): void => {
70
- for (const listener of statusListeners) listener(status)
71
- }
72
- const release = (): void => {
73
- if (provider !== null && statusHandler !== null) provider.off('status', statusHandler)
74
- if (document !== null && updateHandler !== null) document.off('update', updateHandler)
75
- provider?.disconnect()
76
- provider?.destroy()
77
- document?.destroy()
78
- provider = null
79
- document = null
80
- statusHandler = null
81
- updateHandler = null
82
- }
83
-
84
- return {
85
- connect(admission, initialSnapshot) {
86
- if (disposed) throw new Error('COLLABORATION_TRANSPORT_DISPOSED')
87
- if (initialSnapshot.byteLength < 1 || initialSnapshot.byteLength > 8_388_608) throw new Error('COLLABORATION_TRANSPORT_INVALID')
88
- const origin = options.hostOrigin ?? globalThis.location?.origin
89
- if (origin === undefined) throw new Error('COLLABORATION_TRANSPORT_ORIGIN_REQUIRED')
90
- const websocket = validateAdmission(admission, origin)
91
- release()
92
- const syncDocument = new Y.Doc()
93
- Y.applyUpdate(syncDocument, initialSnapshot)
94
- const nextProvider = createProvider(websocket.toString(), admission.roomName, syncDocument, { connect: false, disableBc: true })
95
- const nextStatusHandler = (event: ProviderStatusEvent): void => {
96
- if (provider !== nextProvider || !['connecting', 'connected', 'disconnected'].includes(event.status)) return
97
- if (event.status === 'disconnected') nextProvider.disconnect()
98
- notifyStatus(event.status)
99
- }
100
- const nextUpdateHandler = (update: Uint8Array, updateOrigin: unknown): void => {
101
- if (provider !== nextProvider || updateOrigin !== nextProvider) return
102
- for (const listener of updateListeners) listener(update.slice())
103
- }
104
- provider = nextProvider
105
- document = syncDocument
106
- statusHandler = nextStatusHandler
107
- updateHandler = nextUpdateHandler
108
- syncDocument.on('update', nextUpdateHandler)
109
- nextProvider.on('status', nextStatusHandler)
110
- notifyStatus('connecting')
111
- nextProvider.connect()
112
- },
113
- disconnect() {
114
- if (disposed) return
115
- release()
116
- notifyStatus('disconnected')
117
- },
118
- sendUpdate(update) {
119
- if (disposed) throw new Error('COLLABORATION_TRANSPORT_DISPOSED')
120
- if (document === null) throw new Error('COLLABORATION_TRANSPORT_DISCONNECTED')
121
- if (update.byteLength < 1 || update.byteLength > 262_144) throw new Error('COLLABORATION_TRANSPORT_UPDATE_INVALID')
122
- Y.applyUpdate(document, update)
123
- },
124
- onStatus(listener) {
125
- if (disposed) throw new Error('COLLABORATION_TRANSPORT_DISPOSED')
126
- statusListeners.add(listener)
127
- return () => { statusListeners.delete(listener) }
128
- },
129
- onUpdate(listener) {
130
- if (disposed) throw new Error('COLLABORATION_TRANSPORT_DISPOSED')
131
- updateListeners.add(listener)
132
- return () => { updateListeners.delete(listener) }
133
- },
134
- dispose() {
135
- if (disposed) return
136
- release()
137
- disposed = true
138
- statusListeners.clear()
139
- updateListeners.clear()
140
- },
141
- }
142
- }