diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index 48e1ca1..8238d9b 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -82,8 +82,14 @@ jobs: shell: bash run: | set -euo pipefail + core_version="$(node -p "require('./apps/core/package.json').version")" + core_version="${core_version%%+*}+sha.${GITHUB_SHA}" + if [[ "$GITHUB_REF" == refs/tags/v* ]]; then + core_version="${GITHUB_REF_NAME#v}" + fi for component in core ui; do docker build --target "$component" --build-arg DEPLOYMENT_VERSION="$GITHUB_SHA" \ + --build-arg ASMBLYR_CORE_VERSION="$core_version" \ --label org.opencontainers.image.source="https://github.com/$GITHUB_REPOSITORY" \ --label org.opencontainers.image.revision="$GITHUB_SHA" \ --label org.opencontainers.image.licenses=MIT \ diff --git a/Dockerfile b/Dockerfile index a6c0217..fc0aa74 100644 --- a/Dockerfile +++ b/Dockerfile @@ -20,6 +20,8 @@ RUN node scripts/container/prepare.mjs /runtime # Production images share a build and release, but contain separate runtimes. FROM base AS core +ARG ASMBLYR_CORE_VERSION +ENV ASMBLYR_CORE_VERSION=$ASMBLYR_CORE_VERSION ENV NODE_ENV=production HOST=0.0.0.0 PORT=3001 WORKDIR /app COPY --from=production-dependencies /runtime ./ @@ -41,6 +43,8 @@ CMD ["node", "apps/ui/server.js"] # Compatibility target for existing single-container installations. FROM base AS app +ARG ASMBLYR_CORE_VERSION +ENV ASMBLYR_CORE_VERSION=$ASMBLYR_CORE_VERSION ENV NODE_ENV=production NEXT_TELEMETRY_DISABLED=1 PORT=3000 CORE_PORT=3001 WORKDIR /app COPY --from=production-dependencies /runtime ./ diff --git a/apps/core/migrations/20261010120000_extension_registry.cjs b/apps/core/migrations/20261010120000_extension_registry.cjs new file mode 100644 index 0000000..c311612 --- /dev/null +++ b/apps/core/migrations/20261010120000_extension_registry.cjs @@ -0,0 +1,29 @@ +exports.up = async function (knex) { + await knex.schema.withSchema("public").createTable("asmblyr_extension_states", (table) => { + table.text("package_name").primary(); + table.boolean("enabled").notNullable(); + table.timestamp("updated_at", { useTz: true }).notNullable().defaultTo(knex.fn.now()); + }); + await knex.schema.withSchema("public").createTable("asmblyr_extension_history", (table) => { + table.bigIncrements("id").primary(); + table.text("package_name").notNullable(); + table.string("action", 24).notNullable(); + table.string("result", 32).notNullable(); + table.string("error_code", 80).nullable(); + table.text("error_message").nullable(); + table.uuid("actor_id").notNullable(); + table.timestamp("created_at", { useTz: true }).notNullable().defaultTo(knex.fn.now()); + table.index(["package_name", "id"]); + }); +}; + +exports.down = async function (knex) { + if (await knex("asmblyr_extension_history").withSchema("public").first("id")) { + throw new Error("Cannot discard extension operation history; restore a backup instead"); + } + if (await knex("asmblyr_extension_states").withSchema("public").first("package_name")) { + throw new Error("Cannot discard extension activation states; restore a backup instead"); + } + await knex.schema.withSchema("public").dropTable("asmblyr_extension_history"); + await knex.schema.withSchema("public").dropTable("asmblyr_extension_states"); +}; diff --git a/apps/core/migrations/20261010130000_extension_runtime_audit.cjs b/apps/core/migrations/20261010130000_extension_runtime_audit.cjs new file mode 100644 index 0000000..f74d907 --- /dev/null +++ b/apps/core/migrations/20261010130000_extension_runtime_audit.cjs @@ -0,0 +1,20 @@ +exports.up = async function (knex) { + await knex.raw("ALTER TABLE public.asmblyr_extension_history ALTER COLUMN actor_id DROP NOT NULL"); + await knex.schema.withSchema("public").alterTable("asmblyr_extension_history", (table) => { + table.uuid("instance_id").nullable(); + }); +}; + +exports.down = async function (knex) { + const runtimeHistory = await knex("asmblyr_extension_history") + .withSchema("public") + .whereNull("actor_id") + .first("id"); + if (runtimeHistory) { + throw new Error("Cannot discard extension runtime audit history; restore a backup instead"); + } + await knex.schema.withSchema("public").alterTable("asmblyr_extension_history", (table) => { + table.dropColumn("instance_id"); + }); + await knex.raw("ALTER TABLE public.asmblyr_extension_history ALTER COLUMN actor_id SET NOT NULL"); +}; diff --git a/apps/core/package.json b/apps/core/package.json index ca77244..e13f6ff 100644 --- a/apps/core/package.json +++ b/apps/core/package.json @@ -6,7 +6,7 @@ "migrations", "knexfile.mjs" ], - "version": "0.0.0", + "version": "0.1.0-beta.8", "type": "module", "scripts": { "dev": "tsx watch --include \"../../packages/plugin-*/**/*.ts\" --include \"../../packages/plugin-*/package.json\" --include \"../../packages/plugin-*/locales/*.json\" --include \"../../examples/plugins/**/*.ts\" --include \"../../examples/plugins/*/package.json\" --include \"../../package.json\" --exclude \"../../packages/plugin-*/dist/**\" --exclude \"../../examples/plugins/*/dist/**\" src/server.ts --plugin-sources --with-ui", @@ -41,7 +41,8 @@ "openai": "^7.23.0", "openid-client": "6.8.8", "pg": "^8.23.0", - "sanitize-html": "^2.17.7" + "sanitize-html": "^2.17.7", + "semver": "^7.8.5" }, "devDependencies": { "@asmblyr-collaborative/sdk": "workspace:*", @@ -49,6 +50,7 @@ "@types/oidc-provider": "9.12.1", "@types/pg": "^8.23.1", "@types/sanitize-html": "^2.16.1", + "@types/semver": "^7.8.0", "eslint": "^9.39.5", "tsx": "^4.23.15", "typescript": "^5.9.3", diff --git a/apps/core/src/app.ts b/apps/core/src/app.ts index 10faab6..684187e 100644 --- a/apps/core/src/app.ts +++ b/apps/core/src/app.ts @@ -1,4 +1,5 @@ import { randomUUID } from "node:crypto"; +import { CORE_VERSION } from "./version.js"; import { registerReadiness } from "./health/readiness.js"; import { registerPresenceRoutes } from "./presence/routes.js"; import { PostgresRealtimeBus, InMemoryRealtimeBus } from "./realtime/bus.js"; @@ -63,6 +64,8 @@ import { registerPluginSettingsRoutes } from "./plugins/settings-routes.js"; import { registerPluginBoundary } from "./plugins/routing.js"; import { registerPluginRoutes } from "./plugins/routes.js"; import type { LoadedPlugin } from "./plugins/definition.js"; +import type { RegistryPackage } from "./plugins/registry-manifest.js"; +import { registerExtensionRegistryRoutes } from "./plugins/registry-routes.js"; import { installPluginCollections } from "./plugins/install-collections.js"; import { registerPluginUiRoutes } from "./plugins/ui-routes.js"; import { pluginActor } from "./plugins/actor.js"; @@ -106,6 +109,10 @@ interface AppOptions { sso?: SsoService; oauth?: OAuthConfig; plugins?: readonly LoadedPlugin[]; + registryPackages?: readonly RegistryPackage[]; + registryFailures?: ReadonlyMap; + registryInstanceId?: string; + coreVersion?: string; integrations?: IntegrationOptions; googleProtocol?: GoogleOAuthProtocol; } @@ -126,6 +133,12 @@ export function createApp({ sso = new SsoService([]), oauth, plugins = [], + registryPackages = plugins.flatMap((plugin) => + plugin.registry ? [plugin.registry] : [], + ), + registryFailures = new Map(), + registryInstanceId = randomUUID(), + coreVersion = CORE_VERSION, operationLimits = defaultOperationLimits, passkeys = { rpId: "localhost", @@ -302,6 +315,15 @@ export function createApp({ registerMonitoringRoutes(app, database, monitoring); registerConnectionRoutes(app, database, google); registerPluginSettingsRoutes(app, database, plugins); + registerExtensionRegistryRoutes( + app, + database, + registryPackages, + plugins, + coreVersion, + registryFailures, + registryInstanceId, + ); registerTermRoutes(app, database); app.get("/health", async () => ({ status: "ok", service: "core" })); diff --git a/apps/core/src/plugins/definition.ts b/apps/core/src/plugins/definition.ts index 582a069..ba0ccb1 100644 --- a/apps/core/src/plugins/definition.ts +++ b/apps/core/src/plugins/definition.ts @@ -8,6 +8,7 @@ import type { PluginMigration } from "./migration-index.js"; import type { PluginSettingsDefinition } from "@asmblyr-collaborative/contracts"; import type { LoadedHook } from "./hook-index.js"; import type { TranslationCatalogs } from "@asmblyr-collaborative/contracts"; +import type { RegistryPackage } from "./registry-manifest.js"; export interface LoadedPlugin { translations?: TranslationCatalogs; @@ -22,6 +23,7 @@ export interface LoadedPlugin { settings?: PluginSettingsDefinition; /** Intersection approved at load time. Missing means no optional capabilities. */ capabilities?: readonly PluginCapability[]; + registry?: RegistryPackage; } export function isRecord(value: unknown): value is Record { diff --git a/apps/core/src/plugins/load.ts b/apps/core/src/plugins/load.ts index 08300f1..21aaa51 100644 --- a/apps/core/src/plugins/load.ts +++ b/apps/core/src/plugins/load.ts @@ -44,6 +44,12 @@ import { sourceEndpoints, type PendingEndpoint, } from "./route-index.js"; +import { + parseRegistryPackage, + type RegistryPackage, +} from "./registry-manifest.js"; +import { resolveRegistryPackages } from "./registry-resolver.js"; +import { CORE_VERSION } from "../version.js"; const packageNamePattern = /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/; @@ -75,6 +81,49 @@ function configuredPlugins(value: unknown): string[] { return names; } +/** Reads the trusted project allowlist and manifests without executing package code. */ +export async function readConfiguredRegistryPackages( + projectPackage: URL, + tolerateInvalid = false, +): Promise { + const project = JSON.parse(await readFile(projectPackage, "utf8")); + const names = configuredPlugins(project); + const resolve = createRequire(projectPackage).resolve; + const packages: RegistryPackage[] = []; + for (const name of names) { + try { + const manifestPath = await realpath(resolve(`${name}/package.json`)); + const manifest = JSON.parse(await readFile(manifestPath, "utf8")); + validateManifest(manifest, name); + const entry = parseRegistryPackage(manifest, name); + try { + approvedCapabilities(project, entry.manifest, name); + } catch (error) { + entry.approvalIssue = + error instanceof Error ? error.message : "Capability approval failed"; + } + packages.push(entry); + } catch (cause) { + if (tolerateInvalid) { + packages.push({ + name, + version: "0.0.0", + description: null, + manifest: { version: 1 }, + validationIssue: + cause instanceof Error ? cause.message : String(cause), + }); + continue; + } + throw new Error( + `Cannot load plugin ${name}; check its manifest, exports and build`, + { cause }, + ); + } + } + return packages; +} + function validateManifest(value: unknown, name: string): string | undefined { if ( !isRecord(value) || @@ -100,10 +149,53 @@ function validateManifest(value: unknown, name: string): string | undefined { export async function loadPlugins( projectPackage: URL, - { sourcePlugins = false }: { sourcePlugins?: boolean } = {}, + { + sourcePlugins = false, + disabledPackages = [], + registryPackages, + coreVersion = CORE_VERSION, + onFailure, + }: { + sourcePlugins?: boolean; + disabledPackages?: readonly string[]; + registryPackages?: readonly RegistryPackage[]; + coreVersion?: string; + onFailure?: (name: string, error: string) => void; + } = {}, ): Promise { const project = JSON.parse(await readFile(projectPackage, "utf8")); - const names = configuredPlugins(project); + const allowlist = new Set(configuredPlugins(project)); + if (registryPackages?.some((entry) => !allowlist.has(entry.name))) { + throw new Error("Registry packages must belong to asmblyr.plugins"); + } + const configured = + registryPackages ?? (await readConfiguredRegistryPackages(projectPackage)); + const disabled = new Set(disabledPackages); + const active = configured.filter((entry) => !disabled.has(entry.name)); + const resolution = resolveRegistryPackages(active, coreVersion); + const errors = Object.entries(resolution.issues); + if (errors.length && !onFailure) { + const details = errors + .map(([name, messages]) => `${name}: ${messages.join("; ")}`) + .join(" | "); + const namespaceIssue = errors + .flatMap(([, messages]) => messages) + .find((message) => message.startsWith("Namespace ")); + throw new Error( + `Plugin dependency or compatibility check failed: ${details}`, + { + cause: new Error( + namespaceIssue + ? `Duplicate plugin namespace: ${namespaceIssue}` + : details, + ), + }, + ); + } + const names = resolution.order; + const registryByName = new Map( + configured.map((entry) => [entry.name, entry]), + ); const resolve = createRequire(projectPackage).resolve; const projectDirectory = path.dirname(fileURLToPath(projectPackage)); const entries: { @@ -118,15 +210,37 @@ export async function loadPlugins( hooks: PendingHook[]; settings?: URL; capabilities: PluginCapability[]; + registry: RegistryPackage; }[] = []; const namespaces = new Set(); + const failed = new Set(); // Validate every package before executing any plugin code. for (const name of names) { + const registryEntry = registryByName.get(name)!; + const resolutionIssue = resolution.issues[name]?.join("; "); + const preflightIssue = + registryEntry.validationIssue ?? + registryEntry.approvalIssue ?? + resolutionIssue; + if (preflightIssue && onFailure) { + failed.add(name); + onFailure(name, preflightIssue); + continue; + } try { const manifestPath = await realpath(resolve(`${name}/package.json`)); const manifest = JSON.parse(await readFile(manifestPath, "utf8")); const namespace = validateManifest(manifest, name); + const registry = registryByName.get(name)!; + const currentRegistry = parseRegistryPackage(manifest, name); + if ( + registry.version !== currentRegistry.version || + JSON.stringify(registry.manifest) !== + JSON.stringify(currentRegistry.manifest) + ) { + throw new Error(`Plugin ${name}: manifest changed during discovery`); + } const capabilities = approvedCapabilities( project, manifest.asmblyr.manifest, @@ -228,8 +342,14 @@ export async function loadPlugins( hooks, settings, capabilities, + registry, }); } catch (cause) { + if (onFailure) { + failed.add(name); + onFailure(name, cause instanceof Error ? cause.message : String(cause)); + continue; + } throw new Error( `Cannot load plugin ${name}; check its manifest, exports and build`, { @@ -252,23 +372,43 @@ export async function loadPlugins( hooks, settings, capabilities, + registry, } of entries) { - const module: { default?: unknown } = await import(url.href); - const definition = parsePluginDefinition(module.default, name); - plugins.push({ - translations, - name, - namespace, - hasUi, - definition, - capabilities, - hooks: await importHooks(hooks), - settings: await importSettings(settings), - endpoints: await importEndpoints(routes, name), - collections: await importCollections(collections, namespace), - migrations: await importMigrations(migrations), - }); - validatePluginCapabilities(plugins[plugins.length - 1]); + const unavailable = Object.keys(registry.manifest.dependencies ?? {}).find( + (dependency) => failed.has(dependency), + ); + if (unavailable && onFailure) { + failed.add(name); + onFailure(name, `Required dependency ${unavailable} failed to start`); + continue; + } + try { + const module: { default?: unknown } = await import(url.href); + const definition = parsePluginDefinition(module.default, name); + const plugin: LoadedPlugin = { + translations, + name, + namespace, + hasUi, + definition, + capabilities, + registry, + hooks: await importHooks(hooks), + settings: await importSettings(settings), + endpoints: await importEndpoints(routes, name), + collections: await importCollections(collections, namespace), + migrations: await importMigrations(migrations), + }; + validatePluginCapabilities(plugin); + plugins.push(plugin); + } catch (cause) { + if (onFailure) { + failed.add(name); + onFailure(name, cause instanceof Error ? cause.message : String(cause)); + continue; + } + throw cause; + } } return plugins; } diff --git a/apps/core/src/plugins/registry-manifest.ts b/apps/core/src/plugins/registry-manifest.ts new file mode 100644 index 0000000..282aeed --- /dev/null +++ b/apps/core/src/plugins/registry-manifest.ts @@ -0,0 +1,93 @@ +import { z } from "@asmblyr-collaborative/kit/actions"; +import semver from "semver"; + +const packageName = z + .string() + .regex(/^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/); +const versionRange = z + .string() + .max(120) + .refine( + (value) => semver.validRange(value) !== null, + "Invalid semantic version range", + ); +const dependencies = z + .record(packageName, versionRange) + .refine( + (value) => Object.keys(value).length <= 40, + "Too many plugin dependencies", + ); + +const manifestSchema = z.strictObject({ + version: z.literal(1), + namespace: z + .string() + .regex(/^[a-z][a-z0-9-]{0,30}$/) + .optional(), + capabilities: z.array(z.string()).max(40).optional(), + title: z.string().trim().min(1).max(120).optional(), + description: z.string().trim().max(2000).optional(), + category: z.string().trim().min(1).max(80).optional(), + publisher: z + .strictObject({ + id: z.string().trim().min(1).max(120), + name: z.string().trim().min(1).max(120), + }) + .optional(), + compatibility: z + .strictObject({ + collaborative: versionRange, + node: versionRange.optional(), + }) + .optional(), + dependencies: dependencies.optional(), + optionalDependencies: dependencies.optional(), +}); + +const packageSchema = z.object({ + name: packageName, + version: z.string().optional(), + description: z.string().max(2000).optional(), + asmblyr: z.object({ manifest: manifestSchema }), +}); + +export type RegistryManifest = z.infer; + +export interface RegistryPackage { + name: string; + version: string; + description: string | null; + manifest: RegistryManifest; + approvalIssue?: string; + validationIssue?: string; +} + +/** Parse metadata before any plugin module is imported. Versionless v1 fixtures remain compatible. */ +export function parseRegistryPackage( + value: unknown, + expectedName: string, +): RegistryPackage { + const parsed = packageSchema.parse(value); + if (parsed.name !== expectedName) { + throw new Error(`Plugin ${expectedName}: package name mismatch`); + } + const version = parsed.version ?? "0.0.0"; + if (!semver.valid(version)) { + throw new Error(`Plugin ${expectedName}: invalid semantic version`); + } + if ( + Object.hasOwn(parsed.asmblyr.manifest.dependencies ?? {}, expectedName) || + Object.hasOwn( + parsed.asmblyr.manifest.optionalDependencies ?? {}, + expectedName, + ) + ) { + throw new Error(`Plugin ${expectedName}: cannot depend on itself`); + } + return { + name: parsed.name, + version, + description: parsed.description ?? null, + manifest: parsed.asmblyr.manifest, + }; +} diff --git a/apps/core/src/plugins/registry-query.ts b/apps/core/src/plugins/registry-query.ts new file mode 100644 index 0000000..1bca99c --- /dev/null +++ b/apps/core/src/plugins/registry-query.ts @@ -0,0 +1,111 @@ +import { EndpointError } from "@asmblyr-collaborative/kit"; +import type { ExtensionEntry } from "@asmblyr-collaborative/contracts"; + +export interface ExtensionListQuery { + page: number; + limit: number; + search: string; + status: ExtensionEntry["status"] | ""; + category: string; + sort: "title" | "version" | "status"; +} + +export function parseExtensionListQuery(value: unknown): ExtensionListQuery { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return { + page: 1, + limit: 20, + search: "", + status: "", + category: "", + sort: "title", + }; + } + const query = value as Record; + if ( + Object.keys(query).some( + (key) => + !["page", "limit", "search", "status", "category", "sort"].includes( + key, + ), + ) + ) { + throw new EndpointError( + 400, + "EXTENSION_QUERY_INVALID", + "Неизвестный параметр поиска", + ); + } + for (const key of ["page", "limit"]) { + if ( + query[key] !== undefined && + (typeof query[key] !== "string" || !/^\d+$/.test(query[key])) + ) { + throw new EndpointError( + 400, + "EXTENSION_QUERY_INVALID", + `Некорректное значение ${key}`, + ); + } + } + const page = query.page === undefined ? 1 : Number(query.page); + const limit = query.limit === undefined ? 20 : Number(query.limit); + if ( + !Number.isSafeInteger(page) || + page < 1 || + !Number.isSafeInteger(limit) || + limit < 1 || + limit > 100 + ) { + throw new EndpointError( + 400, + "EXTENSION_QUERY_INVALID", + "Некорректная пагинация", + ); + } + for (const key of ["search", "status", "category", "sort"]) { + if ( + query[key] !== undefined && + (typeof query[key] !== "string" || query[key].length > 120) + ) { + throw new EndpointError( + 400, + "EXTENSION_QUERY_INVALID", + `Некорректное значение ${key}`, + ); + } + } + const status = query.status ?? ""; + if ( + status && + ![ + "healthy", + "disabled", + "restart_required", + "incompatible", + "failed", + ].includes(status as string) + ) { + throw new EndpointError( + 400, + "EXTENSION_QUERY_INVALID", + "Некорректный статус", + ); + } + const sort = query.sort ?? "title"; + if (!["title", "version", "status"].includes(sort as string)) { + throw new EndpointError( + 400, + "EXTENSION_QUERY_INVALID", + "Некорректная сортировка", + ); + } + return { + page, + limit, + search: (query.search as string | undefined) ?? "", + status: status as ExtensionListQuery["status"], + category: (query.category as string | undefined) ?? "", + sort: sort as ExtensionListQuery["sort"], + }; +} diff --git a/apps/core/src/plugins/registry-resolver.ts b/apps/core/src/plugins/registry-resolver.ts new file mode 100644 index 0000000..2e2dc21 --- /dev/null +++ b/apps/core/src/plugins/registry-resolver.ts @@ -0,0 +1,108 @@ +import semver from "semver"; +import type { RegistryPackage } from "./registry-manifest.js"; + +export interface Resolution { + order: string[]; + issues: Record; +} + +/** Required dependencies must be configured, version compatible and acyclic. */ +export function resolveRegistryPackages( + packages: readonly RegistryPackage[], + coreVersion: string, + nodeVersion = process.version, +): Resolution { + const byName = new Map(packages.map((entry) => [entry.name, entry])); + const issues: Record = Object.create(null); + const order: string[] = []; + const visiting = new Set(); + const visited = new Set(); + + function issue(name: string, message: string) { + (issues[name] ??= []).push(message); + } + + const namespaces = new Map(); + for (const entry of packages) { + const namespace = entry.manifest.namespace; + if (!namespace) { + continue; + } + const owner = namespaces.get(namespace); + if (owner) { + issue(entry.name, `Namespace ${namespace} is already used by ${owner}`); + issue(owner, `Namespace ${namespace} is also used by ${entry.name}`); + } else { + namespaces.set(namespace, entry.name); + } + } + + function visit(name: string, path: string[]) { + if (visited.has(name)) { + return; + } + if (visiting.has(name)) { + issue(name, `Dependency cycle: ${[...path, name].join(" -> ")}`); + return; + } + const entry = byName.get(name); + if (!entry) { + return; + } + visiting.add(name); + + const compatibility = entry.manifest.compatibility; + if ( + compatibility && + !semver.satisfies(coreVersion, compatibility.collaborative) + ) { + issue( + name, + `Requires Collaborative ${compatibility.collaborative}; current ${coreVersion}`, + ); + } + if ( + compatibility?.node && + !semver.satisfies(nodeVersion, compatibility.node) + ) { + issue( + name, + `Requires Node ${compatibility.node}; current ${nodeVersion}`, + ); + } + for (const [dependency, range] of Object.entries( + entry.manifest.dependencies ?? {}, + )) { + const installed = byName.get(dependency); + if (!installed) { + issue(name, `Missing required dependency ${dependency} ${range}`); + continue; + } + if (!semver.satisfies(installed.version, range)) { + issue( + name, + `Dependency ${dependency} requires ${range}; configured ${installed.version}`, + ); + } + if (visiting.has(dependency)) { + issue( + name, + `Dependency cycle: ${[...path, name, dependency].join(" -> ")}`, + ); + continue; + } + visit(dependency, [...path, name]); + for (const inherited of issues[dependency] ?? []) { + issue(name, `Dependency ${dependency}: ${inherited}`); + } + } + visiting.delete(name); + visited.add(name); + order.push(name); + } + + for (const entry of packages) { + visit(entry.name, []); + } + return { order, issues }; +} diff --git a/apps/core/src/plugins/registry-routes.ts b/apps/core/src/plugins/registry-routes.ts new file mode 100644 index 0000000..1aa4c48 --- /dev/null +++ b/apps/core/src/plugins/registry-routes.ts @@ -0,0 +1,285 @@ +import type { FastifyInstance } from "fastify"; +import type { Knex } from "knex"; +import { EndpointError } from "@asmblyr-collaborative/kit"; +import semver from "semver"; +import type { ExtensionEntry } from "@asmblyr-collaborative/contracts"; +import { + requireSettingsRead, + requireSettingsSection, +} from "../settings/access.js"; +import type { LoadedPlugin } from "./definition.js"; +import type { RegistryPackage } from "./registry-manifest.js"; +import { parseExtensionListQuery } from "./registry-query.js"; +import { resolveRegistryPackages } from "./registry-resolver.js"; +import { + extensionHistory, + extensionStates, + setExtensionEnabled, +} from "./registry-state.js"; + +function idFor(name: string): string { + return Buffer.from(name).toString("base64url"); +} + +function extensionStatus( + loaded: boolean, + desired: boolean, + issues: string[], + lastError: string | null, +): ExtensionEntry["status"] { + if (desired && lastError && !loaded) { + return "failed"; + } + if (loaded !== desired) { + return "restart_required"; + } + if (!desired) { + return "disabled"; + } + if (issues.length) { + return "incompatible"; + } + return "healthy"; +} + +export function registerExtensionRegistryRoutes( + app: FastifyInstance, + database: Knex | null, + packages: readonly RegistryPackage[], + plugins: readonly LoadedPlugin[], + coreVersion: string, + failures: ReadonlyMap, + instanceId: string, +): void { + function db(): Knex { + if (!database) { + throw Object.assign(new Error("Database is not configured"), { + statusCode: 503, + }); + } + return database; + } + function find(id: string): RegistryPackage { + const entry = packages.find((candidate) => idFor(candidate.name) === id); + if (!entry) { + throw new EndpointError( + 404, + "EXTENSION_NOT_FOUND", + "Расширение не найдено", + ); + } + return entry; + } + function snapshot( + entry: RegistryPackage, + states: Map, + ): ExtensionEntry { + const runtime = plugins.find((plugin) => plugin.name === entry.name); + const loaded = Boolean(runtime); + const desired = states.get(entry.name) ?? true; + const configured = packages.filter( + (candidate) => + candidate.name === entry.name || states.get(candidate.name) !== false, + ); + const resolution = resolveRegistryPackages(configured, coreVersion); + const issues = [ + ...(resolution.issues[entry.name] ?? []), + ...(entry.approvalIssue ? [entry.approvalIssue] : []), + ...(entry.validationIssue ? [entry.validationIssue] : []), + ]; + const lastError = failures.get(entry.name) ?? null; + return { + id: idFor(entry.name), + packageName: entry.name, + namespace: entry.manifest.namespace ?? null, + title: + entry.manifest.title ?? + runtime?.translations?.ru?.["settings.title"] ?? + runtime?.settings?.title ?? + entry.manifest.namespace ?? + entry.name, + description: entry.manifest.description ?? entry.description, + category: entry.manifest.category ?? null, + publisher: entry.manifest.publisher ?? null, + version: entry.version, + latestAvailableVersion: null, + manifestVersion: entry.manifest.version, + status: extensionStatus(loaded, desired, issues, lastError), + desiredState: desired ? "enabled" : "disabled", + actualState: loaded ? "enabled" : lastError ? "failed" : "disabled", + instanceId, + pendingRestart: loaded !== desired, + lastError, + loaded, + desiredEnabled: desired, + restartRequired: loaded !== desired, + compatible: issues.length === 0, + issues, + capabilities: entry.manifest.capabilities ?? [], + dependencies: entry.manifest.dependencies ?? {}, + optionalDependencies: entry.manifest.optionalDependencies ?? {}, + compatibility: entry.manifest.compatibility ?? null, + actions: { + enable: !desired && issues.length === 0, + disable: desired, + configure: loaded && Boolean(runtime?.settings), + }, + }; + } + async function read(request: { headers: { authorization?: string } }) { + await requireSettingsRead(db(), request, "plugins"); + return extensionStates(db()); + } + app.get("/settings/extension-registry", async (request, reply) => { + reply.header("Cache-Control", "no-store"); + const states = await read(request); + const query = parseExtensionListQuery(request.query); + const entries = packages + .map((entry) => snapshot(entry, states)) + .filter( + (entry) => + !query.search || + `${entry.title} ${entry.packageName} ${entry.description ?? ""}` + .toLowerCase() + .includes(query.search.toLowerCase()), + ) + .filter((entry) => !query.status || entry.status === query.status) + .filter((entry) => !query.category || entry.category === query.category) + .sort((left, right) => { + if (query.sort === "version") { + return semver.compare(left.version, right.version); + } + return left[query.sort].localeCompare(right[query.sort]); + }); + return { + data: entries.slice( + (query.page - 1) * query.limit, + query.page * query.limit, + ), + page: query.page, + limit: query.limit, + total: entries.length, + }; + }); + app.get<{ Params: { id: string } }>( + "/settings/extension-registry/:id", + async (request, reply) => { + reply.header("Cache-Control", "no-store"); + const states = await read(request); + return { data: snapshot(find(request.params.id), states) }; + }, + ); + app.get<{ Params: { id: string } }>( + "/settings/extension-registry/:id/versions", + async (request, reply) => { + reply.header("Cache-Control", "no-store"); + await read(request); + const entry = find(request.params.id); + return { + data: [ + { + version: entry.version, + source: "project-package", + installed: true, + }, + ], + }; + }, + ); + app.get<{ Params: { id: string } }>( + "/settings/extension-registry/:id/dependencies", + async (request, reply) => { + reply.header("Cache-Control", "no-store"); + await read(request); + const entry = find(request.params.id); + return { + data: { + required: entry.manifest.dependencies ?? {}, + optional: entry.manifest.optionalDependencies ?? {}, + }, + }; + }, + ); + app.get<{ Params: { id: string } }>( + "/settings/extension-registry/:id/permissions", + async (request, reply) => { + reply.header("Cache-Control", "no-store"); + await read(request); + const entry = find(request.params.id); + return { + data: { + declared: entry.manifest.capabilities ?? [], + approvalIssue: entry.approvalIssue ?? null, + }, + }; + }, + ); + app.get<{ Params: { id: string } }>( + "/settings/extension-registry/:id/health", + async (request, reply) => { + reply.header("Cache-Control", "no-store"); + const states = await read(request); + const entry = snapshot(find(request.params.id), states); + return { + data: { + status: entry.status, + issues: entry.issues, + restartRequired: entry.restartRequired, + desiredState: entry.desiredState, + actualState: entry.actualState, + pendingRestart: entry.pendingRestart, + lastError: entry.lastError, + instanceId: entry.instanceId, + }, + }; + }, + ); + app.get<{ Params: { id: string } }>( + "/settings/extension-registry/:id/history", + async (request, reply) => { + reply.header("Cache-Control", "no-store"); + await read(request); + return { + data: await extensionHistory(db(), find(request.params.id).name), + }; + }, + ); + function operation(enabled: boolean) { + return async ( + request: { params: { id: string }; headers: { authorization?: string } }, + reply: { header(name: string, value: string): void }, + ) => { + reply.header("Cache-Control", "no-store"); + const actor = await requireSettingsSection(db(), request, "plugins"); + const entry = find(request.params.id); + const changed = await setExtensionEnabled( + db(), + packages, + entry.name, + enabled, + actor.id, + coreVersion, + ); + if (changed) { + app.log.info({ + event: enabled + ? "extension.enable.requested" + : "extension.disable.requested", + packageName: entry.name, + actorId: actor.id, + instanceId, + }); + } + const states = await extensionStates(db()); + return { data: { ...snapshot(entry, states), changed } }; + }; + } + app.post<{ Params: { id: string } }>( + "/settings/extension-registry/:id/enable", + operation(true), + ); + app.post<{ Params: { id: string } }>( + "/settings/extension-registry/:id/disable", + operation(false), + ); +} diff --git a/apps/core/src/plugins/registry-state.ts b/apps/core/src/plugins/registry-state.ts new file mode 100644 index 0000000..8c41989 --- /dev/null +++ b/apps/core/src/plugins/registry-state.ts @@ -0,0 +1,202 @@ +import type { Knex } from "knex"; +import { EndpointError } from "@asmblyr-collaborative/kit"; +import type { RegistryPackage } from "./registry-manifest.js"; +import { resolveRegistryPackages } from "./registry-resolver.js"; + +interface StateRow { + package_name: string; + enabled: boolean; +} + +export interface HistoryRow { + id: string; + package_name: string; + action: string; + result: string; + error_code: string | null; + error_message: string | null; + actor_id: string | null; + instance_id: string | null; + created_at: string; +} + +export async function extensionStates( + database: Knex, +): Promise> { + const rows = await database("asmblyr_extension_states") + .withSchema("public") + .select("package_name", "enabled"); + return new Map(rows.map((row) => [row.package_name, row.enabled])); +} + +export async function disabledExtensionPackages( + database: Knex, +): Promise { + const states = await extensionStates(database); + return [...states].filter(([, enabled]) => !enabled).map(([name]) => name); +} + +export async function extensionHistory( + database: Knex, + name: string, + limit = 50, +): Promise { + return database("asmblyr_extension_history") + .withSchema("public") + .where({ package_name: name }) + .orderBy("id", "desc") + .limit(limit) + .select( + "id", + "package_name", + "action", + "result", + "error_code", + "error_message", + "actor_id", + "instance_id", + "created_at", + ); +} + +/** Audit startup outcomes without treating one process as cluster-wide state. */ +export async function recordExtensionStartup( + database: Knex, + packages: readonly RegistryPackage[], + loadedNames: ReadonlySet, + failures: ReadonlyMap, + instanceId: string, +): Promise { + const rows = packages + .filter((entry) => loadedNames.has(entry.name) || failures.has(entry.name)) + .map((entry) => { + const error = failures.get(entry.name); + const validation = Boolean(entry.validationIssue || entry.approvalIssue); + let errorCode: string | null = null; + if (error) { + errorCode = validation + ? "EXTENSION_VALIDATION_FAILED" + : "EXTENSION_STARTUP_FAILED"; + } + return { + package_name: entry.name, + action: validation ? "validation" : "startup", + result: loadedNames.has(entry.name) ? "succeeded" : "failed", + error_code: errorCode, + error_message: error ?? null, + actor_id: null, + instance_id: instanceId, + }; + }); + if (rows.length) { + await database("asmblyr_extension_history") + .withSchema("public") + .insert(rows); + } +} + +/** A global transaction lock protects the desired dependency graph across workers. */ +export async function setExtensionEnabled( + database: Knex, + packages: readonly RegistryPackage[], + name: string, + enabled: boolean, + actorId: string, + coreVersion: string, +): Promise { + if (!packages.some((entry) => entry.name === name)) { + throw new EndpointError( + 404, + "EXTENSION_NOT_FOUND", + "Расширение не найдено", + ); + } + const selected = packages.find((entry) => entry.name === name)!; + try { + if (enabled && selected.approvalIssue) { + throw new EndpointError( + 409, + "EXTENSION_PERMISSION_REVIEW_REQUIRED", + selected.approvalIssue, + ); + } + if (enabled && selected.validationIssue) { + throw new EndpointError( + 409, + "EXTENSION_MANIFEST_INVALID", + selected.validationIssue, + ); + } + return await database.transaction(async (transaction) => { + await transaction.raw( + "SELECT pg_advisory_xact_lock(hashtextextended(?::text, 0))", + ["asmblyr:extension-registry"], + ); + const states = await extensionStates(transaction); + const current = states.get(name) ?? true; + if (current === enabled) { + return false; + } + const before = packages.filter( + (entry) => states.get(entry.name) !== false, + ); + const baseline = resolveRegistryPackages(before, coreVersion); + states.set(name, enabled); + const desired = packages.filter( + (entry) => states.get(entry.name) !== false, + ); + const resolution = resolveRegistryPackages(desired, coreVersion); + const problems = Object.entries(resolution.issues) + .map( + ([packageName, messages]) => + [ + packageName, + messages.filter( + (message) => !baseline.issues[packageName]?.includes(message), + ), + ] as const, + ) + .filter(([, messages]) => messages.length); + if (problems.length) { + throw new EndpointError( + 409, + "EXTENSION_DEPENDENCY_CONFLICT", + problems + .map( + ([packageName, messages]) => + `${packageName}: ${messages.join("; ")}`, + ) + .join(" | "), + ); + } + await transaction("asmblyr_extension_states") + .withSchema("public") + .insert({ package_name: name, enabled }) + .onConflict("package_name") + .merge({ enabled, updated_at: transaction.fn.now() }); + await transaction("asmblyr_extension_history") + .withSchema("public") + .insert({ + package_name: name, + action: enabled ? "enable" : "disable", + result: "restart_required", + actor_id: actorId, + }); + return true; + }); + } catch (error) { + if (error instanceof EndpointError && error.statusCode === 409) { + await database("asmblyr_extension_history") + .withSchema("public") + .insert({ + package_name: name, + action: enabled ? "enable" : "disable", + result: "blocked", + error_code: error.code, + error_message: error.message, + actor_id: actorId, + }); + } + throw error; + } +} diff --git a/apps/core/src/server.ts b/apps/core/src/server.ts index 0ed48a6..a80cbe1 100644 --- a/apps/core/src/server.ts +++ b/apps/core/src/server.ts @@ -1,10 +1,16 @@ import "dotenv/config"; +import { randomUUID } from "node:crypto"; +import { CORE_VERSION } from "./version.js"; import { createApp } from "./app.js"; import { ssoFromEnv } from "./auth/sso/config.js"; import { SsoService } from "./auth/sso/service.js"; import { SsoProtocol } from "./auth/sso/protocol.js"; import { oauthFromEnv } from "./oauth/config.js"; -import { loadPlugins } from "./plugins/load.js"; +import { loadPlugins, readConfiguredRegistryPackages } from "./plugins/load.js"; +import { + disabledExtensionPackages, + recordExtensionStartup, +} from "./plugins/registry-state.js"; import { passkeysFromEnv } from "./auth/passkeys/config.js"; import { limitsFromEnv } from "./operations/limits.js"; import { cleanupOperations } from "./operations/retention.js"; @@ -29,6 +35,31 @@ const sso = new SsoService( console.warn("SSO provider failure", JSON.stringify(diagnostic)); }), ); +const projectPackage = new URL("../../../package.json", import.meta.url); +const registryPackages = await readConfiguredRegistryPackages( + projectPackage, + true, +); +const registryInstanceId = randomUUID(); +const registryFailures = new Map(); +let disabledPackages: string[] = []; +const registryDatabase = process.env.DATABASE_URL + ? knex({ + client: "pg", + connection: process.env.DATABASE_URL, + pool: { min: 0, max: 1 }, + }) + : null; +if (registryDatabase) { + disabledPackages = await disabledExtensionPackages(registryDatabase); +} +const plugins = await loadPlugins(projectPackage, { + sourcePlugins: process.argv.includes("--plugin-sources"), + disabledPackages, + registryPackages, + coreVersion: CORE_VERSION, + onFailure: (name, error) => registryFailures.set(name, error), +}); const app = createApp({ trustProxy: process.env.TRUST_PROXY?.split(",") .map((value) => value.trim()) @@ -45,13 +76,27 @@ const app = createApp({ integrations: { env: process.env }, sso, oauth: await oauthFromEnv(process.env), - plugins: await loadPlugins( - new URL("../../../package.json", import.meta.url), - { - sourcePlugins: process.argv.includes("--plugin-sources"), - }, - ), + registryPackages, + registryFailures, + registryInstanceId, + coreVersion: CORE_VERSION, + plugins, }); +for (const entry of registryPackages) { + const error = registryFailures.get(entry.name); + if (!error) { + continue; + } + app.log.error({ + event: + entry.validationIssue || entry.approvalIssue + ? "extension.validation.failed" + : "extension.startup.failed", + packageName: entry.name, + instanceId: registryInstanceId, + error, + }); +} const publicPort = process.env.PUBLIC_PORT ?? (process.argv.includes("--with-ui") ? "3000" : undefined); @@ -103,6 +148,7 @@ const cleanupTimer = setInterval(async () => { cleanupTimer.unref(); app.addHook("onClose", async () => { clearInterval(cleanupTimer); + await registryDatabase?.destroy(); await maintenance?.destroy(); }); let stopping = false; @@ -118,6 +164,29 @@ for (const signal of ["SIGINT", "SIGTERM"] as const) { try { await app.listen({ port, host }); + if (registryDatabase) { + await recordExtensionStartup( + registryDatabase, + registryPackages, + new Set(plugins.map((plugin) => plugin.name)), + registryFailures, + registryInstanceId, + ); + } + for (const plugin of plugins) { + app.log.info({ + event: "extension.startup.succeeded", + packageName: plugin.name, + instanceId: registryInstanceId, + }); + } + app.log.info({ + event: "extension.reconciliation.completed", + instanceId: registryInstanceId, + loaded: plugins.length, + failed: registryFailures.size, + disabled: disabledPackages.length, + }); if (publicServer && publicPort) { await new Promise((resolve, reject) => { publicServer.once("error", reject); diff --git a/apps/core/src/version.ts b/apps/core/src/version.ts new file mode 100644 index 0000000..70d11ce --- /dev/null +++ b/apps/core/src/version.ts @@ -0,0 +1,28 @@ +import { readFileSync } from "node:fs"; +import semver from "semver"; + +const metadata = JSON.parse( + readFileSync(new URL("../package.json", import.meta.url), "utf8"), +); + +export function resolveCoreVersion( + packageVersion: string, + releaseVersion?: string, +): string { + const version = releaseVersion || packageVersion; + const parsed = semver.parse(version); + const build = parsed?.build.length ? `+${parsed.build.join(".")}` : ""; + const canonical = parsed ? `${parsed.version}${build}` : undefined; + if (!parsed || parsed.version === "0.0.0" || canonical !== version) { + throw new Error( + "ASMBLYR_CORE_VERSION must be a non-placeholder semantic version", + ); + } + return version; +} + +/** The host project's package version does not describe the installed Core. */ +export const CORE_VERSION = resolveCoreVersion( + metadata.version, + process.env.ASMBLYR_CORE_VERSION, +); diff --git a/apps/core/test/core-version.test.ts b/apps/core/test/core-version.test.ts new file mode 100644 index 0000000..687c0b4 --- /dev/null +++ b/apps/core/test/core-version.test.ts @@ -0,0 +1,34 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { CORE_VERSION, resolveCoreVersion } from "../src/version.js"; + +test("Core compatibility uses its own package or an explicit release version", () => { + assert.equal(resolveCoreVersion("0.1.0-beta.8"), "0.1.0-beta.8"); + assert.equal( + resolveCoreVersion("0.1.0-beta.8", "0.1.0-beta.9"), + "0.1.0-beta.9", + ); + assert.equal( + resolveCoreVersion("0.1.0-beta.8", "0.1.0-beta.8+sha.abc123"), + "0.1.0-beta.8+sha.abc123", + ); + assert.equal(resolveCoreVersion("0.1.0-beta.8", ""), "0.1.0-beta.8"); + assert.notEqual(CORE_VERSION, "0.0.0"); + assert.throws(() => resolveCoreVersion("0.0.0"), /semantic version/); + assert.throws( + () => resolveCoreVersion("0.0.0", "0.0.0+sha.abc123"), + /semantic version/, + ); + assert.throws( + () => resolveCoreVersion("0.1.0-beta.8", "v0.1.0-beta.9"), + /semantic version/, + ); + assert.throws( + () => resolveCoreVersion("0.1.0-beta.8", "main"), + /semantic version/, + ); + assert.throws( + () => resolveCoreVersion("0.1.0-beta.8", "abcdef123456"), + /semantic version/, + ); +}); diff --git a/apps/core/test/extension-registry.integration.test.ts b/apps/core/test/extension-registry.integration.test.ts new file mode 100644 index 0000000..10aa199 --- /dev/null +++ b/apps/core/test/extension-registry.integration.test.ts @@ -0,0 +1,298 @@ +import "./support/require-test-database.js"; +import assert from "node:assert/strict"; +import test from "node:test"; +import { createApp } from "../src/app.js"; +import { loadPlugins } from "../src/plugins/load.js"; +import { parseRegistryPackage } from "../src/plugins/registry-manifest.js"; +import { + disabledExtensionPackages, + recordExtensionStartup, + extensionStates, + setExtensionEnabled, +} from "../src/plugins/registry-state.js"; +import { pluginItemsFixture } from "./support/plugin-items-fixture.js"; + +test("constructor package names remain healthy and switch through the registry using Core compatibility", async (t) => { + const registry = parseRegistryPackage( + { + name: "constructor", + version: "1.0.0", + asmblyr: { + manifest: { + version: 1, + namespace: "constructor", + dependencies: {}, + optionalDependencies: {}, + compatibility: { collaborative: ">=0.1.0-0" }, + }, + }, + }, + "constructor", + ); + const fixture = await pluginItemsFixture({ + plugins: [ + { + name: registry.name, + namespace: "constructor", + definition: {}, + endpoints: [], + registry, + }, + ], + }); + t.after(async () => { + await fixture + .db("asmblyr_extension_history") + .where({ package_name: registry.name }) + .delete(); + await fixture + .db("asmblyr_extension_states") + .where({ package_name: registry.name }) + .delete(); + await fixture.close(); + }); + const list = (await fixture.call("GET", "/settings/extension-registry")).data; + const entry = list.find( + (row: { packageName: string }) => row.packageName === registry.name, + ); + assert.equal(entry.status, "healthy"); + assert.equal(entry.compatible, true); + const disabled = ( + await fixture.call( + "POST", + `/settings/extension-registry/${entry.id}/disable`, + ) + ).data; + assert.equal(disabled.desiredState, "disabled"); + assert.equal(disabled.pendingRestart, true); + const enabled = ( + await fixture.call( + "POST", + `/settings/extension-registry/${entry.id}/enable`, + ) + ).data; + assert.equal(enabled.status, "healthy"); + assert.equal(enabled.compatible, true); +}); + +test("registry enforces RBAC, records desired state and preserves live runtime until restart", async (t) => { + const plugins = await loadPlugins( + new URL("../../../package.json", import.meta.url), + ); + const comments = plugins.find((plugin) => plugin.namespace === "comments")!; + const fixture = await pluginItemsFixture({ plugins: [comments] }); + t.after(async () => { + await fixture + .db("asmblyr_extension_history") + .where({ package_name: comments.name }) + .delete(); + await fixture + .db("asmblyr_extension_states") + .where({ package_name: comments.name }) + .delete(); + await fixture.close(); + }); + const base = "/settings/extension-registry"; + await fixture.call("GET", base, undefined, 401, null); + await fixture.call("GET", base, undefined, 403, fixture.memberToken); + await fixture.call("GET", `${base}/unknown`, undefined, 401, null); + await fixture.call("GET", `${base}/unknown`, undefined, 404); + const list = (await fixture.call("GET", base)).data; + const entry = list.find( + (item: { packageName: string }) => item.packageName === comments.name, + ); + assert.ok(entry); + assert.equal(entry.status, "healthy"); + assert.equal(entry.desiredState, "enabled"); + assert.equal(entry.actualState, "enabled"); + assert.equal(entry.pendingRestart, false); + assert.ok(entry.instanceId); + await fixture.call( + "POST", + `${base}/${entry.id}/disable`, + undefined, + 403, + fixture.memberToken, + ); + const concurrent = await Promise.all([ + fixture.call("POST", `${base}/${entry.id}/disable`), + fixture.call("POST", `${base}/${entry.id}/disable`), + ]); + assert.deepEqual(concurrent.map((response) => response.data.changed).sort(), [ + false, + true, + ]); + const changed = concurrent.find((response) => response.data.changed)!.data; + assert.equal(changed.status, "restart_required"); + assert.equal(changed.loaded, true); + assert.equal(changed.desiredEnabled, false); + assert.equal(changed.desiredState, "disabled"); + assert.equal(changed.actualState, "enabled"); + assert.equal(changed.pendingRestart, true); + assert.equal( + (await fixture.call("GET", `${base}/${entry.id}/history`)).data.length, + 1, + ); + const repeated = (await fixture.call("POST", `${base}/${entry.id}/disable`)) + .data; + assert.equal(repeated.changed, false); + assert.equal( + (await fixture.call("GET", `${base}/${entry.id}/history`)).data.length, + 1, + ); + const enabled = (await fixture.call("POST", `${base}/${entry.id}/enable`)) + .data; + assert.equal(enabled.status, "healthy"); + assert.equal( + (await fixture.call("GET", `${base}/${entry.id}/history`)).data.length, + 2, + ); + await Promise.all([ + fixture.call("POST", `${base}/${entry.id}/disable`), + fixture.call("POST", `${base}/${entry.id}/enable`), + ]); + const desiredAfterRace = (await extensionStates(fixture.db)).get( + comments.name, + ); + const afterRaceHistory = ( + await fixture.call("GET", `${base}/${entry.id}/history`) + ).data; + assert.equal(desiredAfterRace, afterRaceHistory[0].action === "enable"); + if (!desiredAfterRace) { + await fixture.call("POST", `${base}/${entry.id}/enable`); + } + const dependent = parseRegistryPackage( + { + name: "dependent", + version: "1.0.0", + asmblyr: { + manifest: { + version: 1, + namespace: "dependent", + dependencies: { [comments.name]: "*" }, + }, + }, + }, + "dependent", + ); + await assert.rejects( + setExtensionEnabled( + fixture.db, + [comments.registry!, dependent], + comments.name, + false, + fixture.admin.id, + "0.0.0", + ), + { statusCode: 409, code: "EXTENSION_DEPENDENCY_CONFLICT" }, + ); + const activity = (await fixture.call("GET", `${base}/${entry.id}/history`)) + .data; + assert.equal(activity[0].result, "blocked"); + assert.equal(activity[0].error_code, "EXTENSION_DEPENDENCY_CONFLICT"); + await assert.rejects( + setExtensionEnabled( + fixture.db, + [comments.registry!], + "unknown", + false, + fixture.admin.id, + "0.0.0", + ), + { statusCode: 404 }, + ); + await assert.rejects( + setExtensionEnabled( + fixture.db, + [{ ...comments.registry!, validationIssue: "Unsupported manifest" }], + comments.name, + true, + fixture.admin.id, + "0.0.0", + ), + { statusCode: 409, code: "EXTENSION_MANIFEST_INVALID" }, + ); + const firstInstance = "00000000-0000-4000-8000-000000000001"; + const secondInstance = "00000000-0000-4000-8000-000000000002"; + await recordExtensionStartup( + fixture.db, + [comments.registry!], + new Set([comments.name]), + new Map(), + firstInstance, + ); + await recordExtensionStartup( + fixture.db, + [comments.registry!], + new Set(), + new Map([[comments.name, "Initialization failed"]]), + secondInstance, + ); + const runtimeHistory = ( + await fixture.call("GET", `${base}/${entry.id}/history`) + ).data; + assert.equal(runtimeHistory[0].instance_id, secondInstance); + assert.equal(runtimeHistory[0].result, "failed"); + assert.equal(runtimeHistory[0].error_message, "Initialization failed"); + assert.equal(runtimeHistory[1].instance_id, firstInstance); + assert.equal(runtimeHistory[1].result, "succeeded"); + + await setExtensionEnabled( + fixture.db, + [comments.registry!], + comments.name, + false, + fixture.admin.id, + "0.0.0", + ); + const afterRestart = await loadPlugins( + new URL("../../../package.json", import.meta.url), + { + disabledPackages: await disabledExtensionPackages(fixture.db), + }, + ); + assert.ok(!afterRestart.some((plugin) => plugin.name === comments.name)); + const restarted = createApp({ + databaseUrl: process.env.DATABASE_URL, + logger: false, + registryPackages: [comments.registry!], + registryInstanceId: firstInstance, + }); + const disabledResponse = await restarted.inject({ + method: "GET", + url: `${base}/${entry.id}`, + headers: { authorization: `Bearer ${fixture.adminToken}` }, + }); + assert.equal(disabledResponse.statusCode, 200); + assert.equal(disabledResponse.json().data.desiredState, "disabled"); + assert.equal(disabledResponse.json().data.actualState, "disabled"); + assert.equal(disabledResponse.json().data.pendingRestart, false); + await restarted.close(); + + await setExtensionEnabled( + fixture.db, + [comments.registry!], + comments.name, + true, + fixture.admin.id, + "0.0.0", + ); + const failed = createApp({ + databaseUrl: process.env.DATABASE_URL, + logger: false, + registryPackages: [comments.registry!], + registryFailures: new Map([[comments.name, "Initialization failed"]]), + registryInstanceId: secondInstance, + }); + const failedResponse = await failed.inject({ + method: "GET", + url: `${base}/${entry.id}`, + headers: { authorization: `Bearer ${fixture.adminToken}` }, + }); + assert.equal(failedResponse.statusCode, 200); + assert.equal(failedResponse.json().data.desiredState, "enabled"); + assert.equal(failedResponse.json().data.actualState, "failed"); + assert.equal(failedResponse.json().data.lastError, "Initialization failed"); + assert.equal(failedResponse.json().data.instanceId, secondInstance); + await failed.close(); +}); diff --git a/apps/core/test/extension-registry.test.ts b/apps/core/test/extension-registry.test.ts new file mode 100644 index 0000000..ac040f9 --- /dev/null +++ b/apps/core/test/extension-registry.test.ts @@ -0,0 +1,268 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { mkdtemp, writeFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import { CORE_VERSION, resolveCoreVersion } from "../src/version.js"; +import { + parseRegistryPackage, + type RegistryPackage, +} from "../src/plugins/registry-manifest.js"; +import { resolveRegistryPackages } from "../src/plugins/registry-resolver.js"; +import { parseExtensionListQuery } from "../src/plugins/registry-query.js"; +import { + loadPlugins, + readConfiguredRegistryPackages, +} from "../src/plugins/load.js"; + +function fixture( + name: string, + dependencies: Record = {}, +): RegistryPackage { + return parseRegistryPackage( + { + name, + version: "1.2.0", + asmblyr: { manifest: { version: 1, namespace: name, dependencies } }, + }, + name, + ); +} + +test("npm names inherited from Object.prototype remain valid dependency keys", () => { + const dependency = fixture("constructor"); + const consumer = fixture("consumer", { constructor: "^1.0.0" }); + const result = resolveRegistryPackages([consumer, dependency], "0.0.0"); + assert.deepEqual(result.order, ["constructor", "consumer"]); + assert.equal(result.issues.constructor, undefined); + assert.equal(result.issues.consumer, undefined); + assert.throws( + () => fixture("constructor", { constructor: "*" }), + /cannot depend on itself/, + ); + const incompatible = { + ...dependency, + manifest: { + ...dependency.manifest, + compatibility: { collaborative: ">=99.0.0" }, + }, + }; + assert.match( + resolveRegistryPackages([incompatible], "0.0.0").issues.constructor[0], + /Requires Collaborative/, + ); +}); + +test("a constructor-named package failure reaches onFailure instead of aborting Core startup", async (t) => { + const directory = await mkdtemp(path.join(tmpdir(), "asmblyr-registry-")); + t.after(() => rm(directory, { recursive: true, force: true })); + const projectFile = path.join(directory, "package.json"); + await writeFile( + projectFile, + JSON.stringify({ + version: "99.0.0", + asmblyr: { plugins: ["constructor"] }, + }), + ); + const failures = new Map(); + const plugins = await loadPlugins(pathToFileURL(projectFile), { + registryPackages: [fixture("constructor")], + onFailure: (name, error) => failures.set(name, error), + }); + assert.deepEqual(plugins, []); + assert.match(failures.get("constructor")!, /Cannot find module/); +}); + +test("host project versions cannot override the installed Core compatibility", async (t) => { + const directory = await mkdtemp( + path.join(tmpdir(), "asmblyr-registry-version-"), + ); + t.after(() => rm(directory, { recursive: true, force: true })); + const projectFile = path.join(directory, "package.json"); + await writeFile( + projectFile, + JSON.stringify({ + version: "99.0.0", + asmblyr: { plugins: ["future-core"] }, + }), + ); + const entry = parseRegistryPackage( + { + name: "future-core", + version: "1.0.0", + asmblyr: { + manifest: { version: 1, compatibility: { collaborative: ">=98.0.0" } }, + }, + }, + "future-core", + ); + const failures = new Map(); + const plugins = await loadPlugins(pathToFileURL(projectFile), { + registryPackages: [entry], + onFailure: (name, error) => failures.set(name, error), + }); + assert.deepEqual(plugins, []); + assert.equal( + failures.get(entry.name), + `Requires Collaborative >=98.0.0; current ${CORE_VERSION}`, + ); + const supported = { + ...entry, + manifest: { + ...entry.manifest, + compatibility: { collaborative: ">=0.1.0-0" }, + }, + }; + assert.equal( + resolveRegistryPackages([supported], CORE_VERSION).issues[entry.name], + undefined, + ); + const ciVersion = resolveCoreVersion( + "0.1.0-beta.8", + "0.1.0-beta.8+sha.abc123", + ); + assert.equal( + resolveRegistryPackages([supported], ciVersion).issues[entry.name], + undefined, + ); + assert.equal( + resolveRegistryPackages([entry], ciVersion).issues[entry.name][0], + `Requires Collaborative >=98.0.0; current ${ciVersion}`, + ); +}); + +test("registry manifest validates versions, ranges and identity", () => { + assert.equal(fixture("alpha").version, "1.2.0"); + assert.throws(() => + parseRegistryPackage( + { + name: "alpha", + version: "latest", + asmblyr: { manifest: { version: 1 } }, + }, + "alpha", + ), + ); + assert.throws(() => + parseRegistryPackage( + { + name: "alpha", + version: "1.0.0", + asmblyr: { manifest: { version: 1, dependencies: { beta: "latest" } } }, + }, + "alpha", + ), + ); + assert.throws(() => + parseRegistryPackage( + { + name: "alpha", + version: "1.0.0", + asmblyr: { manifest: { version: 1 } }, + }, + "beta", + ), + ); +}); + +test("resolver orders dependencies and diagnoses absence, ranges, cycles and Core compatibility", () => { + const alpha = fixture("alpha", { beta: "^1.0.0" }); + const beta = fixture("beta"); + assert.deepEqual(resolveRegistryPackages([alpha, beta], "0.0.0").order, [ + "beta", + "alpha", + ]); + assert.match( + resolveRegistryPackages([alpha], "0.0.0").issues.alpha[0], + /Missing/, + ); + assert.match( + resolveRegistryPackages( + [fixture("alpha", { beta: "^2.0.0" }), beta], + "0.0.0", + ).issues.alpha[0], + /requires/, + ); + assert.match( + resolveRegistryPackages([alpha, fixture("beta", { alpha: "*" })], "0.0.0") + .issues.beta[0], + /cycle/, + ); + const incompatible = parseRegistryPackage( + { + name: "gamma", + version: "1.0.0", + asmblyr: { + manifest: { version: 1, compatibility: { collaborative: ">=2.0.0" } }, + }, + }, + "gamma", + ); + assert.match( + resolveRegistryPackages([incompatible], "0.0.0").issues.gamma[0], + /Requires Collaborative/, + ); + const duplicateNamespace = parseRegistryPackage( + { + name: "other", + version: "1.0.0", + asmblyr: { manifest: { version: 1, namespace: "alpha" } }, + }, + "other", + ); + assert.match( + resolveRegistryPackages([fixture("alpha"), duplicateNamespace], "0.0.0") + .issues.other[0], + /Namespace alpha/, + ); +}); + +test("registry list query rejects malformed and duplicate parameter values", () => { + assert.deepEqual(parseExtensionListQuery({}), { + page: 1, + limit: 20, + search: "", + status: "", + category: "", + sort: "title", + }); + for (const value of [ + { limit: "101" }, + { page: "1e3" }, + { page: ["1", "2"] }, + { status: "unknown" }, + { sort: "arbitrary" }, + { unexpected: "x" }, + ]) { + assert.throws(() => parseExtensionListQuery(value), { statusCode: 400 }); + } +}); + +test("startup excludes a disabled trusted package before importing its routes", async () => { + const plugins = await loadPlugins( + new URL("../../../package.json", import.meta.url), + { + disabledPackages: ["@asmblyr-collaborative/plugin-comments"], + }, + ); + assert.ok(plugins.some((plugin) => plugin.namespace === "google")); + assert.ok(!plugins.some((plugin) => plugin.namespace === "comments")); +}); + +test("startup isolates invalid trusted packages and reports the failure", async () => { + const project = new URL("../../../package.json", import.meta.url); + const configured = await readConfiguredRegistryPackages(project); + const comments = configured.find( + (entry) => entry.name === "@asmblyr-collaborative/plugin-comments", + )!; + const failures = new Map(); + const plugins = await loadPlugins(project, { + registryPackages: [ + { ...comments, validationIssue: "Unsupported manifest" }, + ], + onFailure: (name, error) => failures.set(name, error), + }); + assert.equal(plugins.length, 0); + assert.equal(failures.get(comments.name), "Unsupported manifest"); +}); diff --git a/apps/ui/src/app/(admin)/admin/settings/plugins/loading.tsx b/apps/ui/src/app/(admin)/admin/settings/plugins/loading.tsx new file mode 100644 index 0000000..f573607 --- /dev/null +++ b/apps/ui/src/app/(admin)/admin/settings/plugins/loading.tsx @@ -0,0 +1,23 @@ +import { Skeleton } from "@/components/ui/skeleton"; + +export default function Loading() { + return ( +
+ + +
+ {Array.from({ length: 6 }, (_, index) => ( + + ))} +
+ + +
+ ); +} diff --git a/apps/ui/src/app/(admin)/admin/settings/plugins/page.tsx b/apps/ui/src/app/(admin)/admin/settings/plugins/page.tsx index 5b040df..aeb7188 100644 --- a/apps/ui/src/app/(admin)/admin/settings/plugins/page.tsx +++ b/apps/ui/src/app/(admin)/admin/settings/plugins/page.tsx @@ -1,5 +1,6 @@ import { PluginsWorkspace } from "@/components/admin/settings/plugins/plugins-workspace"; import type { PluginSettingsEntry } from "@/components/admin/settings/plugins/types"; +import type { ExtensionEntry } from "@asmblyr-collaborative/contracts"; import { requireSettingsSection, readSettingsResource, @@ -11,10 +12,24 @@ export default async function SettingsPage() { token, "/settings/plugins", ); + const registry: ExtensionEntry[] = []; + let page = 1; + while (true) { + const chunk = await readSettingsResource( + token, + `/settings/extension-registry?limit=100&page=${page}`, + ); + registry.push(...chunk); + if (chunk.length < 100) { + break; + } + page += 1; + } return ( ); } diff --git a/apps/ui/src/components/admin/settings/plugins/extension-history.tsx b/apps/ui/src/components/admin/settings/plugins/extension-history.tsx new file mode 100644 index 0000000..c9d12b8 --- /dev/null +++ b/apps/ui/src/components/admin/settings/plugins/extension-history.tsx @@ -0,0 +1,64 @@ +import type { ExtensionHistoryEntry } from "@asmblyr-collaborative/contracts"; +import { useUiCopy } from "@/lib/ui-copy"; + +export function ExtensionHistory({ + history, + error, + loading, +}: { + history: ExtensionHistoryEntry[]; + error: string; + loading: boolean; +}) { + const copy = useUiCopy(); + return ( +
+

{copy("История операций")}

+ {loading && ( +

+ {copy("Загрузка истории…")} +

+ )} + {error && ( +

+ {error} +

+ )} + {!loading && history.length ? ( +
    + {history.map((item) => ( +
  • + + {item.action} · {item.result} + {item.error_message && ( + + {item.error_message} + + )} + + +
  • + ))} +
+ ) : ( + !loading && + !error && ( +

+ {copy("Операций пока нет.")} +

+ ) + )} +
+ ); +} diff --git a/apps/ui/src/components/admin/settings/plugins/plugin-editor-dialog.tsx b/apps/ui/src/components/admin/settings/plugins/plugin-editor-dialog.tsx index 56c45ec..729dee7 100644 --- a/apps/ui/src/components/admin/settings/plugins/plugin-editor-dialog.tsx +++ b/apps/ui/src/components/admin/settings/plugins/plugin-editor-dialog.tsx @@ -1,79 +1,325 @@ "use client"; -import type { PluginSettingsSnapshot } from "@asmblyr-collaborative/contracts"; +import { useEffect, useState } from "react"; import { PortalContainerContext } from "@asmblyr-collaborative/kit/ui/portal-container"; +import { Button } from "@asmblyr-collaborative/kit/ui/button"; +import { useTranslations } from "@asmblyr-collaborative/kit/ui/i18n"; import { EditorDialog } from "@/components/ui/editor-dialog"; import { Badge } from "@asmblyr-collaborative/kit/ui/badge"; import { PluginSettingsForm } from "./settings-form"; -import { useTranslations } from "@asmblyr-collaborative/kit/ui/i18n"; -import { - capabilityLabels, - pluginTitle, - type PluginSettingsEntry, -} from "./types"; +import { ExtensionHistory } from "./extension-history"; +import { capabilityLabels, type PluginSettingsEntry } from "./types"; +import type { + ExtensionEntry, + ExtensionHistoryEntry, +} from "@asmblyr-collaborative/contracts"; import { useUiCopy } from "@/lib/ui-copy"; export function PluginEditorDialog({ readOnly = false, + entry, + dependents, plugin, onClose, - onChange, + onEntryChange, + onSettingsChange, }: { readOnly?: boolean; + entry: ExtensionEntry | null; + dependents: string[]; plugin: PluginSettingsEntry | null; onClose: () => void; - onChange: (settings: PluginSettingsSnapshot) => void; + onEntryChange: (entry: ExtensionEntry) => void; + onSettingsChange: () => void; }) { const copy = useUiCopy(); - const { translate } = useTranslations(); - const namespace = `plugin.${plugin?.namespace ?? ""}`; + const [history, setHistory] = useState([]); + const [historyLoading, setHistoryLoading] = useState(true); + const [historyError, setHistoryError] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + const [message, setMessage] = useState(""); + + const entryId = entry?.id; + useEffect(() => { + if (!entryId) { + return; + } + const controller = new AbortController(); + void fetch(`/api/settings/extension-registry/${entryId}/history`, { + signal: controller.signal, + }) + .then(async (response) => { + const result = await response.json(); + if (!response.ok) { + throw new Error(result.message ?? "Activity unavailable"); + } + if (controller.signal.aborted) { + return; + } + setHistory(result.data); + setHistoryError(""); + setHistoryLoading(false); + }) + .catch((cause) => { + if (!controller.signal.aborted) { + setHistoryError( + cause instanceof Error ? cause.message : "Activity unavailable", + ); + setHistoryLoading(false); + } + }); + return () => controller.abort(); + }, [entryId]); + + async function change(enabled: boolean) { + if (!entry || readOnly || busy) { + return; + } + const action = enabled ? "enable" : "disable"; + const warning = + !enabled && dependents.length + ? `\n${copy("Зависят от этого расширения")}: ${dependents.join(", ")}.` + : ""; + const prompt = copy( + enabled + ? "Включить расширение после перезапуска Core?" + : "Отключить расширение после перезапуска Core?", + ); + if (!window.confirm(`${prompt}${warning}`)) { + return; + } + setBusy(true); + setError(""); + setMessage(""); + try { + const response = await fetch( + `/api/settings/extension-registry/${entry.id}/${action}`, + { method: "POST" }, + ); + const result = await response.json(); + if (!response.ok) { + throw new Error( + result.message ?? copy("Не удалось изменить состояние расширения"), + ); + } + onEntryChange(result.data); + setMessage( + copy( + result.data.restartRequired + ? "Изменение сохранено. Перезапустите Core и обновите страницу." + : "Состояние сохранено.", + ), + ); + const activity = await fetch( + `/api/settings/extension-registry/${entry.id}/history`, + ); + if (activity.ok) { + const updated = await activity.json(); + setHistory(updated.data); + setHistoryError(""); + setHistoryLoading(false); + } + } catch (cause) { + setError( + cause instanceof Error ? cause.message : copy("Операция недоступна"), + ); + } finally { + setBusy(false); + } + } + return ( {(container) => - plugin && ( + entry && ( -
-
-

{plugin.name}

- {plugin.settings?.definition.description && ( -

- {translate( - namespace, - "settings.description", - plugin.settings.definition.description, +

+
+

+ {entry.packageName} +

+

+ {entry.description ?? copy("Описание не указано.")} +

+
+ + {copy( + entry.status === "restart_required" + ? "Нужен перезапуск" + : entry.status === "disabled" + ? "Отключено" + : entry.status === "incompatible" + ? "Проблемы" + : entry.status === "failed" + ? "Ошибка запуска" + : "Активно", )} + + + {copy("Версия")} {entry.version} + + {entry.publisher && · {entry.publisher.name}} +
+

+ {copy("Требуется")}:{" "} + {copy( + entry.desiredState === "enabled" ? "Включено" : "Отключено", + )}{" "} + · {copy("В этом Core")}:{" "} + {copy( + entry.actualState === "enabled" + ? "Активно" + : entry.actualState === "failed" + ? "Ошибка" + : "Неактивно", + )} +

+

+ Core instance: {entry.instanceId} +

+ {entry.lastError && ( +

+ {copy("Ошибка загрузки")}: {entry.lastError}

)} -
- {plugin.settings ? ( - - ) : ( -

- {copy("У этого плагина нет настраиваемых параметров.")} + {dependents.length > 0 && ( +

+ {copy("Зависят от этого расширения")}:{" "} + {dependents.join(", ")} +

+ )} + {entry.restartRequired && ( +

+ {copy( + "Текущий процесс продолжает прежний режим до перезапуска Core.", + )} +

+ )} + {entry.issues.map((issue) => ( +

+ {issue} +

+ ))} + +
+

{copy("Управление")}

+
+ {!readOnly && entry.actions.enable && ( + + )} + {!readOnly && entry.actions.disable && ( + + )} +
+

+ {copy( + "Установка и обновление пакета выполняются доверенным процессом развёртывания.", + )}

- )} + {error && ( +

+ {error} +

+ )} + {message && ( +

+ {message} +

+ )} +

- {copy("Разрешённые возможности")} + {copy("Версии и совместимость")}

+

+ {copy("Установлена версия")} {entry.version}.{" "} + {entry.latestAvailableVersion + ? `${copy("Доступна версия")} ${entry.latestAvailableVersion}.` + : copy("Внешний каталог версий не подключён.")} +

+

+ {copy("Collaborative")}:{" "} + {entry.compatibility?.collaborative ?? "*"} · Node:{" "} + {entry.compatibility?.node ?? "*"} +

+
+
+

{copy("Зависимости")}

+ {Object.keys(entry.dependencies).length ? ( +
    + {Object.entries(entry.dependencies).map(([name, range]) => ( +
  • + {name} {range} +
  • + ))} +
+ ) : ( +

+ {copy("Обязательных зависимостей нет.")} +

+ )} + {Object.keys(entry.optionalDependencies).length > 0 && ( +

+ {copy("Необязательные")}:{" "} + {Object.entries(entry.optionalDependencies) + .map(([name, range]) => `${name} ${range}`) + .join(", ")} +

+ )} +
+
+

{copy("Разрешения")}

- {plugin.capabilities.map((capability) => ( + {entry.capabilities.map((capability) => ( ))}
- {!plugin.capabilities.length && ( -

+ {!entry.capabilities.length && ( +

{copy("Дополнительные возможности не запрошены.")}

)} -

+

{copy( - "Список возможностей задаётся в конфигурации проекта. Изменение списка требует перезапуска Core. Доступ к данным дополнительно ограничен правами пользователя.", + "Разрешения утверждаются в конфигурации проекта до запуска Core.", )}

+
+

{copy("Конфигурация")}

+ {plugin?.settings && entry.loaded ? ( + + ) : ( +

+ {copy("Настройки недоступны для этого расширения.")} +

+ )} +
+
+

+ {copy("Состояние и диагностика")} +

+

+ {entry.issues.length + ? entry.issues.join("; ") + : copy( + entry.loaded + ? "Код расширения загружен в текущем процессе." + : "Код расширения не загружен в текущем процессе.", + )} +

+
+
) diff --git a/apps/ui/src/components/admin/settings/plugins/plugins-workspace.tsx b/apps/ui/src/components/admin/settings/plugins/plugins-workspace.tsx index 08c2186..d303be9 100644 --- a/apps/ui/src/components/admin/settings/plugins/plugins-workspace.tsx +++ b/apps/ui/src/components/admin/settings/plugins/plugins-workspace.tsx @@ -1,12 +1,14 @@ "use client"; -import { SettingsReadOnlyNotice } from "@/components/admin/settings/read-only-notice"; - -import { useState } from "react"; +import { useMemo, useState } from "react"; import { useRouter } from "next/navigation"; -import { ArrowUpRight } from "lucide-react"; +import { ArrowUpRight, Puzzle, Search } from "lucide-react"; import { Button } from "@asmblyr-collaborative/kit/ui/button"; +import { useTranslations } from "@asmblyr-collaborative/kit/ui/i18n"; +import { Input } from "@asmblyr-collaborative/kit/ui/input"; import { PageHeader } from "@/components/layout/page-header"; +import { SettingsReadOnlyNotice } from "@/components/admin/settings/read-only-notice"; +import { Badge } from "@asmblyr-collaborative/kit/ui/badge"; import { Table, TableBody, @@ -16,87 +18,259 @@ import { TableRow, } from "@/components/ui/table"; import { PluginEditorDialog } from "./plugin-editor-dialog"; -import { pluginTitle, type PluginSettingsEntry } from "./types"; -import { useTranslations } from "@asmblyr-collaborative/kit/ui/i18n"; +import type { ExtensionEntry } from "@asmblyr-collaborative/contracts"; +import type { PluginSettingsEntry } from "./types"; import { useUiCopy } from "@/lib/ui-copy"; export function PluginsWorkspace({ readOnly = false, plugins, + registry, }: { readOnly?: boolean; plugins: PluginSettingsEntry[]; + registry: ExtensionEntry[]; }) { const copy = useUiCopy(); - const { translate } = useTranslations(); const router = useRouter(); - const [selection, setSelection] = useState(null); + const [selectedId, setSelectedId] = useState(null); + const [search, setSearch] = useState(""); + const [status, setStatus] = useState(""); + const [category, setCategory] = useState(""); + const [sort, setSort] = useState<"title" | "version" | "status">("title"); + + const rows = registry; + const selection = rows.find((entry) => entry.id === selectedId) ?? null; + const categories = [ + ...new Set( + rows.flatMap((entry) => (entry.category ? [entry.category] : [])), + ), + ].sort(); + const visible = useMemo( + () => + rows + .filter( + (entry) => + !search || + `${entry.title} ${entry.packageName} ${entry.description ?? ""}` + .toLowerCase() + .includes(search.toLowerCase()), + ) + .filter((entry) => !status || entry.status === status) + .filter((entry) => !category || entry.category === category) + .sort((left, right) => + String(left[sort]).localeCompare(String(right[sort]), undefined, { + numeric: sort === "version", + }), + ), + [rows, search, status, category, sort], + ); + const counts = { + installed: rows.length, + active: rows.filter((entry) => entry.loaded).length, + disabled: rows.filter((entry) => entry.status === "disabled").length, + failed: rows.filter((entry) => + ["incompatible", "failed"].includes(entry.status), + ).length, + }; return (
- {plugins.length ? ( -
+
+ {( + [ + ["Установлено", counts.installed], + ["Активно", counts.active], + ["Отключено", counts.disabled], + ["Проблемы", counts.failed], + ] as const + ).map(([label, count]) => ( +
+

{copy(label)}

+

{count}

+
+ ))} +
+
+ + + + {categories.length > 0 && ( + <> + + + + )} + + +
+ {visible.length ? ( +
- {copy("Плагин")} - {copy("Настройки")} - {copy("Возможности")} - + {copy("Расширение")} + {copy("Версия")} + {copy("Статус")} + {copy("Требуется")} + {copy("В процессе")} + {copy("Издатель")} + {copy("Открыть")} - {plugins.map((plugin) => ( - setSelection(plugin)} - > + {visible.map((entry) => ( + -

- {plugin.name} +

+ {entry.description ?? entry.packageName}

- - {plugin.settings - ? copy("Доступны") - : copy("Нет параметров")} + + {entry.version} - {plugin.capabilities.length} - + + {copy( + entry.status === "restart_required" + ? "Нужен перезапуск" + : entry.status === "disabled" + ? "Отключено" + : entry.status === "incompatible" + ? "Проблемы" + : entry.status === "failed" + ? "Ошибка запуска" + : "Активно", + )} + + + + {copy( + entry.desiredState === "enabled" + ? "Включено" + : "Отключено", + )} + + + {copy( + entry.actualState === "enabled" + ? "Активно" + : entry.actualState === "failed" + ? "Ошибка" + : "Неактивно", + )} + + + {entry.publisher?.name ?? "—"} + + +
))} @@ -105,19 +279,38 @@ export function PluginsWorkspace({ ) : (

- {copy("Нет включённых плагинов.")} + {copy( + rows.length + ? "Расширения не найдены." + : "Нет настроенных расширений.", + )}

)} setSelection(null)} - onChange={(settings) => { - setSelection((current) => - current ? { ...current, settings } : null, - ); - router.refresh(); - }} + entry={selection} + dependents={ + selection + ? rows + .filter( + (candidate) => + candidate.desiredState === "enabled" && + Object.hasOwn( + candidate.dependencies, + selection.packageName, + ), + ) + .map((candidate) => candidate.title) + : [] + } + plugin={ + plugins.find((plugin) => plugin.name === selection?.packageName) ?? + null + } + onClose={() => setSelectedId(null)} + onEntryChange={() => router.refresh()} + onSettingsChange={() => router.refresh()} /> ); diff --git a/apps/ui/src/components/plugins/page.tsx b/apps/ui/src/components/plugins/page.tsx index e1bf2fe..4789324 100644 --- a/apps/ui/src/components/plugins/page.tsx +++ b/apps/ui/src/components/plugins/page.tsx @@ -2,11 +2,12 @@ import { useMemo } from "react"; import { notFound, useSearchParams } from "next/navigation"; -import { usePluginPages } from "./registry"; +import { usePluginPages, usePluginUiStatus } from "./registry"; import { pluginRequest } from "./request"; import { PluginUiHost } from "./ui-host"; import { PreparedPluginPage } from "./prepared-page"; import { useUiCopy } from "@/lib/ui-copy"; +import { Button } from "@asmblyr-collaborative/kit/ui/button"; export function PluginPage({ namespace, @@ -18,6 +19,7 @@ export function PluginPage({ const copy = useUiCopy(); const pages = usePluginPages(); + const { pending, failedNamespaces, retry } = usePluginUiStatus(); const page = pages.find( (entry) => entry.namespace === namespace && entry.id === pageId, ); @@ -26,7 +28,29 @@ export function PluginPage({ [namespace, copy], ); const draftId = useSearchParams().get("draft"); - if (!page) notFound(); + if (!page) { + if (pending) { + return

{copy("Загрузка интерфейса расширения…")}

; + } + if (failedNamespaces.includes(namespace)) { + return ( +
+

{copy("Не удалось загрузить интерфейс расширения.")}

+ +
+ ); + } + notFound(); + } const View = page.component; return ( diff --git a/apps/ui/src/components/plugins/registry-context.ts b/apps/ui/src/components/plugins/registry-context.ts new file mode 100644 index 0000000..96cfaeb --- /dev/null +++ b/apps/ui/src/components/plugins/registry-context.ts @@ -0,0 +1,16 @@ +"use client"; + +import { createContext } from "react"; +import type { LoadedUiPlugin } from "./registry-loader"; + +export const PluginRegistryContext = createContext<{ + plugins: LoadedUiPlugin[]; + pending: boolean; + failedNamespaces: string[]; + retry: () => void; +}>({ + plugins: [], + pending: false, + failedNamespaces: [], + retry: () => undefined, +}); diff --git a/apps/ui/src/components/plugins/registry-loader.ts b/apps/ui/src/components/plugins/registry-loader.ts new file mode 100644 index 0000000..e529b39 --- /dev/null +++ b/apps/ui/src/components/plugins/registry-loader.ts @@ -0,0 +1,105 @@ +import type { UiPluginDefinition } from "@asmblyr-collaborative/kit/ui"; + +export interface LoadedUiPlugin { + packageName: string; + namespace: string; + definition: UiPluginDefinition; +} + +export interface UiPluginModule { + packageName: string; + namespace: string; + load: () => Promise<{ default: UiPluginDefinition }>; +} + +export interface UiPluginLoadResult { + plugins: LoadedUiPlugin[]; + failed: { packageName: string; namespace: string }[]; +} + +export interface UiPluginLoader { + load(enabled: readonly string[]): Promise; +} + +/** Share pending imports across StrictMode effects, without importing disabled packages. */ +export function createUiPluginLoader( + modules: readonly UiPluginModule[], +): UiPluginLoader { + const cache = new Map>(); + + function loadModule(entry: UiPluginModule): Promise { + const existing = cache.get(entry.packageName); + if (existing) { + return existing; + } + const pending = Promise.resolve() + .then(() => entry.load()) + .then((module) => { + if ( + !module.default || + typeof module.default !== "object" || + Array.isArray(module.default) + ) { + throw new Error("Invalid UI plugin export"); + } + return { + packageName: entry.packageName, + namespace: entry.namespace, + definition: module.default, + }; + }) + .catch((error) => { + cache.delete(entry.packageName); + throw error; + }); + cache.set(entry.packageName, pending); + return pending; + } + + return { + async load(enabled: readonly string[]): Promise { + const allowed = new Set(enabled); + const outcomes = await Promise.all( + modules + .filter((entry) => allowed.has(entry.packageName)) + .map(async (entry) => { + try { + return { plugin: await loadModule(entry) }; + } catch { + return { + failed: { + packageName: entry.packageName, + namespace: entry.namespace, + }, + }; + } + }), + ); + return { + plugins: outcomes.flatMap((outcome) => + outcome.plugin ? [outcome.plugin] : [], + ), + failed: outcomes.flatMap((outcome) => + outcome.failed ? [outcome.failed] : [], + ), + }; + }, + }; +} + +/** Each effect subscribes independently; cleanup prevents a stale activation from publishing. */ +export function watchUiPlugins( + loader: UiPluginLoader, + enabled: readonly string[], + publish: (result: UiPluginLoadResult) => void, +): () => void { + let active = true; + void loader.load(enabled).then((result) => { + if (active) { + publish(result); + } + }); + return () => { + active = false; + }; +} diff --git a/apps/ui/src/components/plugins/registry.tsx b/apps/ui/src/components/plugins/registry.tsx index 0282e23..543633e 100644 --- a/apps/ui/src/components/plugins/registry.tsx +++ b/apps/ui/src/components/plugins/registry.tsx @@ -1,7 +1,7 @@ "use client"; import { - createContext, + useCallback, useContext, useEffect, useMemo, @@ -12,7 +12,13 @@ import type { TranslationsResult, TranslationCatalogs, } from "@asmblyr-collaborative/contracts"; -import { uiPlugins } from "@/generated/plugin-ui"; +import { uiPluginModules } from "@/generated/plugin-ui"; +import { + createUiPluginLoader, + watchUiPlugins, + type UiPluginLoadResult, +} from "./registry-loader"; +import { PluginRegistryContext } from "./registry-context"; import { TranslationProvider, useTranslations, @@ -20,7 +26,7 @@ import { import { coreTranslations } from "@asmblyr-collaborative/contracts/translations"; import { useAppearance } from "@/components/settings/appearance-provider"; -const RegistryContext = createContext([]); +const loader = createUiPluginLoader(uiPluginModules); export function PluginRegistryProvider({ enabled, @@ -29,16 +35,47 @@ export function PluginRegistryProvider({ enabled: readonly string[]; children?: ReactNode; }) { + const enabledKey = JSON.stringify([...new Set(enabled)].sort()); + const enabledNames = useMemo( + () => new Set(JSON.parse(enabledKey)), + [enabledKey], + ); + const [snapshot, setSnapshot] = useState({ + plugins: [], + failed: [], + }); + const [settledKey, setSettledKey] = useState(null); + const [retryRevision, setRetryRevision] = useState(0); + const retry = useCallback(() => setRetryRevision((value) => value + 1), []); + const requestKey = `${enabledKey}:${retryRevision}`; + useEffect(() => { + return watchUiPlugins(loader, JSON.parse(enabledKey), (result) => { + setSnapshot(result); + setSettledKey(requestKey); + }); + }, [enabledKey, requestKey]); const plugins = useMemo( - () => uiPlugins.filter((plugin) => enabled.includes(plugin.packageName)), - [enabled], + () => + snapshot.plugins.filter((plugin) => enabledNames.has(plugin.packageName)), + [snapshot.plugins, enabledNames], + ); + const pending = enabledNames.size > 0 && settledKey !== requestKey; + const failedNamespaces = useMemo( + () => + snapshot.failed + .filter((entry) => enabledNames.has(entry.packageName)) + .map((entry) => entry.namespace), + [snapshot.failed, enabledNames], + ); + const registry = useMemo( + () => ({ plugins, pending, failedNamespaces, retry }), + [plugins, pending, failedNamespaces, retry], ); const { locale } = useAppearance(); const [remote, setRemote] = useState<{ locale: typeof locale; catalogs: Record; } | null>(null); - const enabledKey = enabled.join("|"); useEffect(() => { const controller = new AbortController(); void fetch(`/api/translations?locale=${locale}`, { @@ -83,19 +120,23 @@ export function PluginRegistryProvider({ [plugins, remote, locale], ); return ( - + {children} - + ); } export function useUiPlugins() { - return useContext(RegistryContext); + return useContext(PluginRegistryContext).plugins; +} + +export function usePluginUiStatus() { + return useContext(PluginRegistryContext); } export function usePluginPages() { diff --git a/apps/ui/test/plugin-fields.test.ts b/apps/ui/test/plugin-fields.test.ts index 19bd55f..bbbc061 100644 --- a/apps/ui/test/plugin-fields.test.ts +++ b/apps/ui/test/plugin-fields.test.ts @@ -1,27 +1,35 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { createElement } from "react"; +import { createElement, type ReactNode } from "react"; import { renderToStaticMarkup } from "react-dom/server"; import { defineUiPlugin } from "@asmblyr-collaborative/kit/ui"; import { PluginRegistryProvider } from "../src/components/plugins/registry"; +import { PluginRegistryContext } from "../src/components/plugins/registry-context"; import { ItemFieldInput } from "../src/components/items/item-field-input"; import { ContentValue } from "../src/components/items/content-value"; import { readColorOptions } from "../../../examples/plugins/color/ui/color-options"; import colorPlugin from "../../../examples/plugins/color/ui/index"; -import { uiPlugins } from "../src/generated/plugin-ui"; -const installedPlugins = [...uiPlugins]; -test.before(() => { - // This example is a test fixture; production enables only configured packages. - uiPlugins.push({ - packageName: "@asmblyr-collaborative/plugin-color", - namespace: "color", - definition: colorPlugin, - }); -}); -test.after(() => { - uiPlugins.splice(0, uiPlugins.length, ...installedPlugins); -}); +function ColorPluginProvider({ children }: { children?: ReactNode }) { + return createElement( + PluginRegistryContext.Provider, + { + value: { + plugins: [ + { + packageName: "@asmblyr-collaborative/plugin-color", + namespace: "color", + definition: colorPlugin, + }, + ], + pending: false, + failedNamespaces: [], + retry: () => undefined, + }, + }, + children, + ); +} const field = { name: "color", @@ -66,8 +74,8 @@ test("disabled plugin preserves an editable draft and uses a plain display", () test("active color editor retains values outside the palette and honors disabled controls", () => { const input = renderToStaticMarkup( createElement( - PluginRegistryProvider, - { enabled: ["@asmblyr-collaborative/plugin-color"] }, + ColorPluginProvider, + {}, createElement(ItemFieldInput, { id: "color", field, @@ -87,8 +95,8 @@ test("active color editor retains values outside the palette and honors disabled test("plugin display escapes arbitrary stored text instead of treating it as CSS or HTML", () => { const html = renderToStaticMarkup( createElement( - PluginRegistryProvider, - { enabled: ["@asmblyr-collaborative/plugin-color"] }, + ColorPluginProvider, + {}, createElement(ContentValue, { field, value: '', @@ -106,6 +114,17 @@ test("plugin display escapes arbitrary stored text instead of treating it as CSS ); }); +test("plugin loading leaves the host form and its stored draft available", () => { + const html = renderToStaticMarkup( + createElement( + PluginRegistryProvider, + { enabled: ["@asmblyr-collaborative/plugin-comments"] }, + createElement("input", { defaultValue: "Unsaved draft", name: "title" }), + ), + ); + assert.match(html, /value="Unsaved draft"/); +}); + test("field UI contributions reject ambiguous IDs and duplicate registrations", () => { const entry = { id: "picker", diff --git a/apps/ui/test/plugin-registry-generation.test.ts b/apps/ui/test/plugin-registry-generation.test.ts new file mode 100644 index 0000000..dec8c3e --- /dev/null +++ b/apps/ui/test/plugin-registry-generation.test.ts @@ -0,0 +1,79 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { mkdtemp, mkdir, writeFile, symlink, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { randomUUID } from "node:crypto"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import { generateUiRegistry } from "@asmblyr-collaborative/kit/node"; +import { createUiPluginLoader } from "../src/components/plugins/registry-loader"; + +for (const source of [false, true]) { + test(`generated ${source ? "source" : "built"} registry does not execute disabled UI entrypoints`, async (t) => { + const marker = `registry-${randomUUID()}`; + const executions = globalThis as unknown as Record< + string, + number | undefined + >; + t.after(() => { + delete executions[marker]; + }); + const directory = await mkdtemp( + path.join(tmpdir(), "asmblyr-ui-registry-"), + ); + t.after(() => rm(directory, { recursive: true, force: true })); + const packageRoot = path.join(directory, "packages", "fixture-ui"); + await mkdir(path.join(packageRoot, "ui"), { recursive: true }); + await mkdir(path.join(directory, "node_modules")); + await writeFile( + path.join(directory, "package.json"), + JSON.stringify({ type: "module", asmblyr: { plugins: ["fixture-ui"] } }), + ); + await writeFile( + path.join(packageRoot, "package.json"), + JSON.stringify({ + name: "fixture-ui", + type: "module", + version: "1.0.0", + exports: { + "./package.json": "./package.json", + "./ui": "./ui/index.mjs", + }, + asmblyr: { manifest: { version: 1, namespace: "fixture" } }, + }), + ); + await writeFile( + path.join(packageRoot, "ui/index.mjs"), + `globalThis[${JSON.stringify(marker)}] = 1; export default { translations: { en: { entry: "built" } } };`, + ); + await writeFile( + path.join(packageRoot, "ui/index.ts"), + `globalThis[${JSON.stringify(marker)}] = 1; export default { translations: { en: { entry: "source" } } };`, + ); + await symlink( + packageRoot, + path.join(directory, "node_modules/fixture-ui"), + "junction", + ); + const output = path.join(directory, "registry.ts"); + await generateUiRegistry( + pathToFileURL(path.join(directory, "package.json")), + output, + source, + ); + const generated = await import(pathToFileURL(output).href); + assert.equal(executions[marker], undefined); + const loader = createUiPluginLoader(generated.uiPluginModules); + assert.deepEqual(await loader.load([]), { plugins: [], failed: [] }); + assert.equal(executions[marker], undefined); + const enabled = await loader.load(["fixture-ui"]); + assert.deepEqual(enabled.failed, []); + assert.equal(enabled.plugins.length, 1); + assert.equal(enabled.plugins[0].packageName, "fixture-ui"); + assert.equal( + enabled.plugins[0].definition.translations?.en?.entry, + source ? "source" : "built", + ); + assert.equal(executions[marker], 1); + }); +} diff --git a/apps/ui/test/plugin-registry-loader.test.ts b/apps/ui/test/plugin-registry-loader.test.ts new file mode 100644 index 0000000..1c0bbf1 --- /dev/null +++ b/apps/ui/test/plugin-registry-loader.test.ts @@ -0,0 +1,151 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import type { UiPluginDefinition } from "@asmblyr-collaborative/kit/ui"; +import { + createUiPluginLoader, + watchUiPlugins, + type UiPluginModule, +} from "../src/components/plugins/registry-loader"; + +test("a disabled module finishing late cannot publish an old activation", async () => { + let resolve!: (module: { default: UiPluginDefinition }) => void; + const loader = createUiPluginLoader([ + { + packageName: "slow", + namespace: "slow", + load: () => + new Promise((done) => { + resolve = done; + }), + }, + ]); + let staleWrites = 0; + let currentNames: string[] = ["slow"]; + const cancel = watchUiPlugins(loader, ["slow"], () => { + staleWrites += 1; + }); + await Promise.resolve(); + cancel(); + watchUiPlugins(loader, [], (result) => { + currentNames = result.plugins.map((entry) => entry.packageName); + }); + resolve({ default: {} }); + await new Promise(setImmediate); + assert.equal(staleWrites, 0); + assert.deepEqual(currentNames, []); +}); + +test("StrictMode cleanup does not prevent the next effect from receiving its pending import", async () => { + let imports = 0; + let resolve!: (module: { default: UiPluginDefinition }) => void; + const loader = createUiPluginLoader([ + { + packageName: "demo", + namespace: "demo", + load: () => { + imports += 1; + return new Promise((done) => { + resolve = done; + }); + }, + }, + ]); + const cancel = watchUiPlugins(loader, ["demo"], () => + assert.fail("cleaned-up effect must not publish"), + ); + cancel(); + let received = 0; + watchUiPlugins(loader, ["demo"], (result) => { + received = result.plugins.length; + }); + await Promise.resolve(); + resolve({ default: {} }); + await new Promise(setImmediate); + assert.equal(imports, 1); + assert.equal(received, 1); +}); + +test("disabled and unknown UI packages are never imported", async () => { + let imports = 0; + const loader = createUiPluginLoader([ + { + packageName: "constructor", + namespace: "demo", + load: async () => { + imports += 1; + throw new Error("must not execute"); + }, + }, + ]); + assert.deepEqual(await loader.load([]), { plugins: [], failed: [] }); + assert.deepEqual(await loader.load(["unknown"]), { plugins: [], failed: [] }); + assert.equal(imports, 0); +}); + +test("a failing UI entry does not prevent healthy modules from loading", async () => { + const good: UiPluginModule = { + packageName: "good", + namespace: "good", + load: async () => ({ default: {} }), + }; + const bad: UiPluginModule = { + packageName: "bad", + namespace: "bad", + load: async () => { + throw new Error("entry crashed"); + }, + }; + const result = await createUiPluginLoader([bad, good]).load(["bad", "good"]); + assert.deepEqual( + result.plugins.map((entry) => entry.packageName), + ["good"], + ); + assert.deepEqual(result.failed, [{ packageName: "bad", namespace: "bad" }]); +}); + +test("concurrent and repeated loads share a single import, including StrictMode effects", async () => { + let imports = 0; + let resolve!: (module: { default: UiPluginDefinition }) => void; + const loader = createUiPluginLoader([ + { + packageName: "demo", + namespace: "demo", + load: () => { + imports += 1; + return new Promise((done) => { + resolve = done; + }); + }, + }, + ]); + const first = loader.load(["demo"]); + const second = loader.load(["demo"]); + await Promise.resolve(); + assert.equal(imports, 1); + resolve({ default: {} }); + const [one, two] = await Promise.all([first, second]); + assert.equal(one.plugins[0], two.plugins[0]); + assert.equal((await loader.load(["demo"])).plugins[0], one.plugins[0]); + assert.equal(imports, 1); + assert.deepEqual(await loader.load([]), { plugins: [], failed: [] }); +}); + +test("failed module imports may be retried without caching failure forever", async () => { + let attempts = 0; + const loader = createUiPluginLoader([ + { + packageName: "demo", + namespace: "demo", + load: async () => { + attempts += 1; + if (attempts === 1) { + throw new Error("temporary chunk failure"); + } + return { default: {} }; + }, + }, + ]); + assert.equal((await loader.load(["demo"])).failed.length, 1); + assert.equal((await loader.load(["demo"])).plugins.length, 1); + assert.equal(attempts, 2); +}); diff --git a/docs/architecture/extension-registry.md b/docs/architecture/extension-registry.md new file mode 100644 index 0000000..dc7b36e --- /dev/null +++ b/docs/architecture/extension-registry.md @@ -0,0 +1,173 @@ +# Extension registry architecture + +The current extension unit is an npm or workspace package named in the root +`asmblyr.plugins` array. Core validates the package manifest, then imports its +server entry, routes, hooks and settings into the trusted Node.js process. +`plugin.ts` remains `definePlugin({})`; routes, hooks, settings and UI keep their +existing file based contracts. `asmblyr.pluginPermissions` approves declared +capabilities. Plugin collections and migration records already live in PostgreSQL. + +The pnpm workspace has Core in `apps/core`, the Next.js administration UI in +`apps/ui`, Kit in `packages/kit`, the HTTP SDK in `packages/sdk`, and the CLI in +`packages/cli`. Core routes use Fastify and the existing settings RBAC; Knex +migrations own Core tables in `public`. The existing settings editor validates +ordinary plugin configuration, while Core records security events. Tests use +Node's test runner and the disposable PostgreSQL runner `scripts/test.mjs`. +Package builds generate route indexes and UI entries, so registry activation +reuses the existing source and production discovery paths. + +## Registry and lifecycle + +The registry is an administrative view of the configured package allowlist and +its installed PostgreSQL ownership. Package metadata is read without importing +code. A package outside the allowlist may be described by a future isolated +registry, but cannot be installed or activated by this runtime. The package +manager and deployment process install and update trusted artifacts; Core never +runs npm or package scripts from an API request. + +The existing loader is the activation boundary. Server routes, hooks, UI and +model handlers are registered at startup. JavaScript modules cannot be unloaded +reliably, so a lifecycle request that changes activation records a desired state +and reports that a restart is required. The current process remains active until +the restart. Startup reconciles desired state before importing plugin code. A failed +manifest, capability check or module import is recorded for that package; Core +continues with other trusted packages and skips required dependents. A failure +while registering an already imported plugin can still prevent Core startup. +Collection ownership and settings remain after deactivation. Updates and removal +of package artifacts remain deployment operations, with backup and explicit +versioned migrations where schema changes are required. + +## Manifest and resolution + +Version 1 `asmblyr.manifest` continues to accept `namespace` and `capabilities`. +Optional registry metadata adds display text, publisher, Core compatibility, +and dependency ranges without changing `plugin.ts`. Package names and versions +come from `package.json`. All data is parsed before use. Required dependency +cycles, missing packages, version mismatches and Core incompatibility block +activation. Optional dependencies are descriptive and do not block activation. + +The manifest stays inside the package's `package.json` under +`asmblyr.manifest`. This valid excerpt uses the existing trusted Comments +package; the root project must still list the package and approve all declared +capabilities before Core imports it: + +```json +{ + "name": "@asmblyr-collaborative/plugin-comments", + "version": "0.0.0", + "asmblyr": { + "manifest": { + "version": 1, + "namespace": "comments", + "capabilities": [ + "identity.profile", + "items.read", + "collections.manage", + "storage.own", + "hooks.items", + "hooks.collections", + "settings", + "notifications" + ] + } + } +} +``` + +`name` must be a package name, `version` a valid semver version, and +`manifest.version` must be `1`. Optional `title`, `description`, `category` +and `publisher: { id, name }` are display metadata; Core does not infer +publisher verification. Optional `compatibility.collaborative` and +`compatibility.node` are semver ranges. Optional `dependencies` and +`optionalDependencies` map package names to semver ranges. Capability names +must belong to Kit's declared set and to the project's explicit approval list. +The package export map remains the source of executable entrypoints; manifest +metadata cannot specify a new code path. Invalid fields and unknown manifest +keys are rejected before import. + +## Data and trust + +Core uses versioned, additive `asmblyr_` tables for desired activation and +operation history. Operations take a PostgreSQL advisory transaction lock for +the whole dependency graph so concurrent state changes serialize. History +records actor or process instance, action, result and time without settings values +or credentials. Existing plugin settings +keep their revision checks and authorization. Secret settings are not supported: +the current settings store exposes values to the browser, so manifests cannot +declare secret fields until an external secret store is integrated. + +Only users with the `plugins` settings read grant can inspect registry metadata; +changing desired activation requires `plugins` update access. Current package +approval still requires a trusted deployer to edit the project allowlist and +capability approvals. A process or browser isolation contract is required before +unknown third party packages can be activated. `worker_threads`, VM and dynamic +import alone do not establish that boundary. + +A future isolated runtime must accept a verified immutable artifact and a +capability grant, start it outside the Core process, expose a bounded request +protocol, report health, and stop it without access to Core's environment or +database credentials. The host must proxy only authorized operations and apply +the same caller permissions as HTTP and MCP. Browser UI would require separate +origin isolation. This is a contract boundary for future work, not an available +runtime in this release. + +## API and compatibility + +The administrative API uses `/settings/extension-registry` to avoid the existing +`GET /extensions` browser UI-discovery route. The registry's read endpoints +return validated metadata, activation, compatibility, dependencies, permissions, +health and history. Mutations use one lifecycle service and return a pending +restart result. Existing `/settings/plugins` and plugin HTTP routes remain valid. +The admin interface reads the new API and continues to use the existing settings +editor for non-secret configuration. + +| Endpoint | Result | +| --------------------------------------------------- | -------------------------------------------------------------------------------------- | +| `GET /settings/extension-registry` | Paginated configured packages; `page`, `limit`, `search`, `status`, `category`, `sort` | +| `GET /settings/extension-registry/:id` | One package and current activation state | +| `GET /settings/extension-registry/:id/versions` | Installed version only | +| `GET /settings/extension-registry/:id/dependencies` | Required and optional ranges | +| `GET /settings/extension-registry/:id/permissions` | Declared capabilities and approval issue | +| `GET /settings/extension-registry/:id/health` | Current process state and validation issues | +| `GET /settings/extension-registry/:id/history` | Activation attempts, newest first | +| `POST /settings/extension-registry/:id/enable` | Request activation after restart | +| `POST /settings/extension-registry/:id/disable` | Request deactivation after restart | + +`id` is the base64url encoding of the package name and is supplied by the list +response. Read routes require a human `plugins/read` or `plugins/update` grant; +mutations require `plugins/update`. The API returns `400` for malformed list +queries, `401` for missing authentication, `403` for insufficient access, `404` +for an unknown ID, and `409` for a dependency or approval conflict. The +generated OpenAPI reference contains the response schemas. + +Each entry reports `desiredState` from PostgreSQL (`enabled` or `disabled`) and +`actualState` from the serving Core process (`enabled`, `disabled`, or `failed`). +`instanceId` identifies that process. `pendingRestart` is true when the desired +and active enablement differ. `lastError` is the latest startup error in this +process; startup history keeps errors from earlier instances. Legacy `loaded`, +`desiredEnabled`, and `restartRequired` fields remain for existing clients. +There is no cluster-wide actual state: a load balancer may return different +instances during a rolling restart. Query each instance directly to inspect +all replicas. Lifecycle events use the Core logger and include +`extension.validation.failed`, `extension.enable.requested`, +`extension.disable.requested`, `extension.startup.succeeded`, +`extension.startup.failed`, and `extension.reconciliation.completed`. + +To test locally with Docker, run +`docker run --rm --name collaborative-registry-pg -e POSTGRES_PASSWORD=localtest -p 127.0.0.1:55439:5432 -d postgres:16`, +then set `TEST_DATABASE_ADMIN_URL=postgresql://postgres:localtest@127.0.0.1:55439/postgres` +and run `node scripts/test.mjs core-integration`. Stop the container with +`docker stop collaborative-registry-pg`. If Docker is unavailable, start a local +PostgreSQL server on loopback and set `TEST_DATABASE_ADMIN_URL` accordingly. +The runner creates, migrates and drops a fresh `asmblyr_test_*` database. Never +point it at a production server. After a failed startup, inspect the Core log +event and registry history. Correct the trusted artifact or approval, then +restart Core; an API enable request cannot repair a bad artifact. + +## Core version and browser activation + +Compatibility checks use the installed Core version, never the host project version. Source builds read apps/core/package.json; ASMBLYR_CORE_VERSION overrides it for a release image. CI injects the release tag without its v prefix; main/PR images add commit metadata to the package version. Invalid versions and the placeholder 0.0.0 fail startup. DEPLOYMENT_VERSION remains the independent Core/UI deployment identifier. + +The browser registry contains deferred imports. Only Core-enabled packages execute their UI entrypoint. Parallel loads share an import promise; failures can be retried. Changes to the enabled set ignore stale completions and immediately remove disabled definitions without replacing the host form. An unavailable field editor falls back to the ordinary input, preserving its draft. Standalone plugin pages show loading and recoverable import errors instead of a premature 404. + +After changing activation, restart Core and refresh existing browser tabs. Already executed JavaScript cannot be unloaded from a running tab. This remains trusted browser code, not a sandbox. diff --git a/docs/features/plugins.md b/docs/features/plugins.md index c57010e..a68050f 100644 --- a/docs/features/plugins.md +++ b/docs/features/plugins.md @@ -4,7 +4,13 @@ Plugins are npm or workspace packages explicitly listed in the root `asmblyr.plugins` configuration. A directory name alone does not install a plugin. Installing packages through the UI is not supported. -Settings shows a list of plugins. Clicking one opens its right-side editor with capabilities and settings. Closing a dirty editor requires confirmation; closing is blocked during save. Plugins without configurable parameters still expose their capability view. +Settings → Extensions shows configured packages with search, status and category filters, compatibility, dependencies, capabilities, operation history and settings. Clicking one opens its detail editor. Closing a dirty settings editor requires confirmation; closing is blocked during save. Plugins without configurable parameters still expose their capability view. + +The summary shows four counters: installed, active, disabled and problems. Each counter reflects configured packages; no catalog or update counters are shown. + +An administrator with `plugins/update` may enable or disable a configured package. The API saves `desiredState` and reports `pendingRestart`; existing routes and hooks remain loaded until Core restarts. Startup reconciles the desired state and reports `actualState`, `lastError`, and the serving Core `instanceId`. A dependency conflict or missing capability approval blocks enablement. No plugin data is deleted on disable. Reading the center requires `plugins/read` or `plugins/update`. + +Installation, version updates and uninstallation require a trusted deployment change to the root package allowlist, lockfile and artifacts. Core never installs arbitrary npm packages from an API request. There is no remote marketplace or version feed, so the center shows only the installed package version and cannot claim updates are available. @@ -16,6 +22,10 @@ Settings shows a list of plugins. Clicking one opens its right-side editor with - The `./ui` entry contains browser code. - Model-visible handlers require an explicit `defineModelContext` annotation. +The existing `asmblyr.manifest.version: 1` contract remains valid. Optional registry metadata in `package.json` includes `title`, `description`, `category`, `publisher: { id, name }`, `compatibility: { collaborative, node? }`, `dependencies` and `optionalDependencies`. Versions and ranges use semantic versioning. The package's `name` and `version` are the identity and installed version; old versionless v1 fixtures resolve to `0.0.0` for compatibility. Required dependencies must be configured, version compatible, enabled and acyclic. Optional dependencies do not block activation. See [registry architecture](../architecture/extension-registry.md). + +The trusted Comments package demonstrates the existing `definePlugin({})`, file routes, hooks and settings with manifest version 1; no new registration file is required. + Kit is the server/browser extension API. SDK is an HTTP client, not access to Knex. `useItems` keeps the caller's permissions. `storage.own` provides privileged access to the plugin's own tables; the plugin must enforce its domain permissions. Annotated model handlers expose schemas through `GET /schema`, filtered by AccessGate. Generated clients call `client.plugins.namespace[methodId](input)` over HTTP. Runtime data and OAuth checks still apply. Legacy handlers without output schemas are excluded from generation. @@ -55,3 +65,20 @@ See [Kit](../reference/kit-guide.md), [hooks](../reference/kit-hooks.md), and [f ## Trust boundary Server plugins run with Core's Node.js and environment access. Browser plugins run in the admin's context. Untrusted plugins require separate process or browser isolation. + +The extension center reads only configured package metadata. It cannot execute untrusted packages, grant new capabilities, or edit project package approvals. The current settings schema contains ordinary values; do not place secrets there. Secret fields require an external secret store and a new contract. Health reports the serving process, validation issues, startup errors and pending restart; it does not probe external services or aggregate replica health. The history endpoint records activation and startup outcomes without settings values. See [registry architecture](../architecture/extension-registry.md). + +If Core fails before startup because a package cannot be resolved or imported, inspect the server error, restore the trusted package artifact or configuration, and restart. If an enable request returns a dependency conflict, enable its required packages first or install a compatible trusted version through the deployment process. After changing desired activation, restart Core and refresh the center to verify the loaded state. + +## Upgrade and local verification + +Existing v1 manifests and `asmblyr.plugins` entries remain valid. Apply the additive migrations before starting the new Core so it can read activation state. The first creates `asmblyr_extension_states` and `asmblyr_extension_history`; the second allows process startup events with nullable actor and instance ID. Empty state means every configured package remains enabled. They do not alter plugin tables or settings. A rollback refuses to discard nonempty activation or runtime history. + +```sh +pnpm install --frozen-lockfile +pnpm db:migrate +pnpm build +pnpm dev +``` + +Sign in with `plugins/read` to inspect Settings → Extensions. With `plugins/update`, disable a trusted package, confirm the pending restart status, restart Core and check that it is disabled. Re-enable and restart again to restore its routes. [SDK](../reference/sdk-guide.md) and [CLI](../reference/cli-guide.md) use the same Registry API. The [HTTP reference](../reference/http.md) includes the typed route contracts. diff --git a/docs/index.md b/docs/index.md index 7e74259..98558f0 100644 --- a/docs/index.md +++ b/docs/index.md @@ -48,6 +48,7 @@ field locks, and transport limits. - [Local development and checks](./development/local-development.md) - [Architecture](./development/architecture.md) - [Collaborative Live architecture](./architecture/realtime.md) +- [Extension registry architecture](./architecture/extension-registry.md) - [Your first plugin](./development/first-extension.md) - [Assistant architecture and model context](./development/assistant-architecture.md) - [Published packages](./reference/packages.md) diff --git a/docs/public/openapi.json b/docs/public/openapi.json index 11ae9e7..67e59ca 100644 --- a/docs/public/openapi.json +++ b/docs/public/openapi.json @@ -11041,6 +11041,1672 @@ "x-source": "apps/core/src/settings/routes.ts" } }, + "/settings/extension-registry": { + "get": { + "tags": [ + "settings" + ], + "operationId": "get__settings_extension_registry", + "summary": "GET /settings/extension-registry", + "description": "Human with plugins/read or update; superuser bypass; configured packages only. Source: apps/core/src/plugins/registry-routes.ts", + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ], + "parameters": [ + { + "name": "page", + "in": "query", + "schema": { + "type": "integer", + "minimum": 1, + "default": 1 + } + }, + { + "name": "limit", + "in": "query", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 20 + } + }, + { + "name": "search", + "in": "query", + "schema": { + "type": "string", + "maxLength": 120 + } + }, + { + "name": "status", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "category", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "sort", + "in": "query", + "schema": { + "enum": [ + "title", + "version", + "status" + ] + } + } + ], + "responses": { + "200": { + "description": "Success; private, no-store", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": false, + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + }, + "packageName": { + "type": "string" + }, + "namespace": { + "type": [ + "string", + "null" + ] + }, + "title": { + "type": "string" + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "category": { + "type": [ + "string", + "null" + ] + }, + "publisher": { + "anyOf": [ + { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "id", + "name" + ] + }, + { + "type": "null" + } + ] + }, + "version": { + "type": "string" + }, + "latestAvailableVersion": { + "type": [ + "string", + "null" + ] + }, + "manifestVersion": { + "const": 1 + }, + "status": { + "enum": [ + "healthy", + "disabled", + "restart_required", + "incompatible", + "failed" + ] + }, + "desiredState": { + "enum": [ + "enabled", + "disabled" + ] + }, + "actualState": { + "enum": [ + "enabled", + "disabled", + "failed" + ] + }, + "instanceId": { + "type": "string" + }, + "pendingRestart": { + "type": "boolean" + }, + "lastError": { + "type": [ + "string", + "null" + ] + }, + "loaded": { + "type": "boolean" + }, + "desiredEnabled": { + "type": "boolean" + }, + "restartRequired": { + "type": "boolean" + }, + "compatible": { + "type": "boolean" + }, + "issues": { + "type": "array", + "items": { + "type": "string" + } + }, + "capabilities": { + "type": "array", + "items": { + "type": "string" + } + }, + "dependencies": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "optionalDependencies": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "compatibility": { + "anyOf": [ + { + "type": "object", + "additionalProperties": false, + "required": [ + "collaborative" + ], + "properties": { + "collaborative": { + "type": "string" + }, + "node": { + "type": "string" + } + } + }, + { + "type": "null" + } + ] + }, + "actions": { + "type": "object", + "additionalProperties": false, + "properties": { + "enable": { + "type": "boolean" + }, + "disable": { + "type": "boolean" + }, + "configure": { + "type": "boolean" + } + }, + "required": [ + "enable", + "disable", + "configure" + ] + } + }, + "required": [ + "id", + "packageName", + "namespace", + "title", + "description", + "category", + "publisher", + "version", + "latestAvailableVersion", + "manifestVersion", + "status", + "desiredState", + "actualState", + "instanceId", + "pendingRestart", + "lastError", + "loaded", + "desiredEnabled", + "restartRequired", + "compatible", + "issues", + "capabilities", + "dependencies", + "optionalDependencies", + "compatibility", + "actions" + ] + } + }, + "page": { + "type": "integer" + }, + "limit": { + "type": "integer" + }, + "total": { + "type": "integer" + } + }, + "required": [ + "data", + "page", + "limit", + "total" + ] + } + } + } + }, + "default": { + "description": "Ошибка авторизации, проверки или конфликта" + } + }, + "x-contract-level": "documented", + "x-source": "apps/core/src/plugins/registry-routes.ts" + } + }, + "/settings/extension-registry/{id}": { + "get": { + "tags": [ + "settings" + ], + "operationId": "get__settings_extension_registry_id", + "summary": "GET /settings/extension-registry/:id", + "description": "Human with plugins/read or update; superuser bypass. Source: apps/core/src/plugins/registry-routes.ts", + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Base64url identifier of the package name" + } + ], + "responses": { + "200": { + "description": "Success; private, no-store", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": false, + "properties": { + "data": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + }, + "packageName": { + "type": "string" + }, + "namespace": { + "type": [ + "string", + "null" + ] + }, + "title": { + "type": "string" + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "category": { + "type": [ + "string", + "null" + ] + }, + "publisher": { + "anyOf": [ + { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "id", + "name" + ] + }, + { + "type": "null" + } + ] + }, + "version": { + "type": "string" + }, + "latestAvailableVersion": { + "type": [ + "string", + "null" + ] + }, + "manifestVersion": { + "const": 1 + }, + "status": { + "enum": [ + "healthy", + "disabled", + "restart_required", + "incompatible", + "failed" + ] + }, + "desiredState": { + "enum": [ + "enabled", + "disabled" + ] + }, + "actualState": { + "enum": [ + "enabled", + "disabled", + "failed" + ] + }, + "instanceId": { + "type": "string" + }, + "pendingRestart": { + "type": "boolean" + }, + "lastError": { + "type": [ + "string", + "null" + ] + }, + "loaded": { + "type": "boolean" + }, + "desiredEnabled": { + "type": "boolean" + }, + "restartRequired": { + "type": "boolean" + }, + "compatible": { + "type": "boolean" + }, + "issues": { + "type": "array", + "items": { + "type": "string" + } + }, + "capabilities": { + "type": "array", + "items": { + "type": "string" + } + }, + "dependencies": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "optionalDependencies": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "compatibility": { + "anyOf": [ + { + "type": "object", + "additionalProperties": false, + "required": [ + "collaborative" + ], + "properties": { + "collaborative": { + "type": "string" + }, + "node": { + "type": "string" + } + } + }, + { + "type": "null" + } + ] + }, + "actions": { + "type": "object", + "additionalProperties": false, + "properties": { + "enable": { + "type": "boolean" + }, + "disable": { + "type": "boolean" + }, + "configure": { + "type": "boolean" + } + }, + "required": [ + "enable", + "disable", + "configure" + ] + } + }, + "required": [ + "id", + "packageName", + "namespace", + "title", + "description", + "category", + "publisher", + "version", + "latestAvailableVersion", + "manifestVersion", + "status", + "desiredState", + "actualState", + "instanceId", + "pendingRestart", + "lastError", + "loaded", + "desiredEnabled", + "restartRequired", + "compatible", + "issues", + "capabilities", + "dependencies", + "optionalDependencies", + "compatibility", + "actions" + ] + } + }, + "required": [ + "data" + ] + } + } + } + }, + "400": { + "description": "Authorization, validation, or conflict error" + }, + "401": { + "description": "Authorization, validation, or conflict error" + }, + "403": { + "description": "Authorization, validation, or conflict error" + }, + "404": { + "description": "Authorization, validation, or conflict error" + }, + "409": { + "description": "Authorization, validation, or conflict error" + }, + "default": { + "description": "Ошибка авторизации, проверки или конфликта" + } + }, + "x-contract-level": "documented", + "x-source": "apps/core/src/plugins/registry-routes.ts" + } + }, + "/settings/extension-registry/{id}/dependencies": { + "get": { + "tags": [ + "settings" + ], + "operationId": "get__settings_extension_registry_id_dependencies", + "summary": "GET /settings/extension-registry/:id/dependencies", + "description": "Human with plugins/read or update; superuser bypass. Source: apps/core/src/plugins/registry-routes.ts", + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Base64url identifier of the package name" + } + ], + "responses": { + "200": { + "description": "Success; private, no-store", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": false, + "properties": { + "data": { + "type": "object", + "additionalProperties": false, + "properties": { + "required": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "optional": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "required": [ + "required", + "optional" + ] + } + }, + "required": [ + "data" + ] + } + } + } + }, + "400": { + "description": "Authorization, validation, or conflict error" + }, + "401": { + "description": "Authorization, validation, or conflict error" + }, + "403": { + "description": "Authorization, validation, or conflict error" + }, + "404": { + "description": "Authorization, validation, or conflict error" + }, + "409": { + "description": "Authorization, validation, or conflict error" + }, + "default": { + "description": "Ошибка авторизации, проверки или конфликта" + } + }, + "x-contract-level": "documented", + "x-source": "apps/core/src/plugins/registry-routes.ts" + } + }, + "/settings/extension-registry/{id}/disable": { + "post": { + "tags": [ + "settings" + ], + "operationId": "post__settings_extension_registry_id_disable", + "summary": "POST /settings/extension-registry/:id/disable", + "description": "Human with plugins/update; superuser bypass; saves desired state until restart. Source: apps/core/src/plugins/registry-routes.ts", + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Base64url identifier of the package name" + } + ], + "responses": { + "200": { + "description": "Success; private, no-store", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": false, + "properties": { + "data": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + }, + "packageName": { + "type": "string" + }, + "namespace": { + "type": [ + "string", + "null" + ] + }, + "title": { + "type": "string" + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "category": { + "type": [ + "string", + "null" + ] + }, + "publisher": { + "anyOf": [ + { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "id", + "name" + ] + }, + { + "type": "null" + } + ] + }, + "version": { + "type": "string" + }, + "latestAvailableVersion": { + "type": [ + "string", + "null" + ] + }, + "manifestVersion": { + "const": 1 + }, + "status": { + "enum": [ + "healthy", + "disabled", + "restart_required", + "incompatible", + "failed" + ] + }, + "desiredState": { + "enum": [ + "enabled", + "disabled" + ] + }, + "actualState": { + "enum": [ + "enabled", + "disabled", + "failed" + ] + }, + "instanceId": { + "type": "string" + }, + "pendingRestart": { + "type": "boolean" + }, + "lastError": { + "type": [ + "string", + "null" + ] + }, + "loaded": { + "type": "boolean" + }, + "desiredEnabled": { + "type": "boolean" + }, + "restartRequired": { + "type": "boolean" + }, + "compatible": { + "type": "boolean" + }, + "issues": { + "type": "array", + "items": { + "type": "string" + } + }, + "capabilities": { + "type": "array", + "items": { + "type": "string" + } + }, + "dependencies": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "optionalDependencies": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "compatibility": { + "anyOf": [ + { + "type": "object", + "additionalProperties": false, + "required": [ + "collaborative" + ], + "properties": { + "collaborative": { + "type": "string" + }, + "node": { + "type": "string" + } + } + }, + { + "type": "null" + } + ] + }, + "actions": { + "type": "object", + "additionalProperties": false, + "properties": { + "enable": { + "type": "boolean" + }, + "disable": { + "type": "boolean" + }, + "configure": { + "type": "boolean" + } + }, + "required": [ + "enable", + "disable", + "configure" + ] + }, + "changed": { + "type": "boolean" + } + }, + "required": [ + "id", + "packageName", + "namespace", + "title", + "description", + "category", + "publisher", + "version", + "latestAvailableVersion", + "manifestVersion", + "status", + "desiredState", + "actualState", + "instanceId", + "pendingRestart", + "lastError", + "loaded", + "desiredEnabled", + "restartRequired", + "compatible", + "issues", + "capabilities", + "dependencies", + "optionalDependencies", + "compatibility", + "actions", + "changed" + ] + } + }, + "required": [ + "data" + ] + } + } + } + }, + "400": { + "description": "Authorization, validation, or conflict error" + }, + "401": { + "description": "Authorization, validation, or conflict error" + }, + "403": { + "description": "Authorization, validation, or conflict error" + }, + "404": { + "description": "Authorization, validation, or conflict error" + }, + "409": { + "description": "Authorization, validation, or conflict error" + }, + "default": { + "description": "Ошибка авторизации, проверки или конфликта" + } + }, + "x-contract-level": "documented", + "x-source": "apps/core/src/plugins/registry-routes.ts" + } + }, + "/settings/extension-registry/{id}/enable": { + "post": { + "tags": [ + "settings" + ], + "operationId": "post__settings_extension_registry_id_enable", + "summary": "POST /settings/extension-registry/:id/enable", + "description": "Human with plugins/update; superuser bypass; saves desired state until restart. Source: apps/core/src/plugins/registry-routes.ts", + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Base64url identifier of the package name" + } + ], + "responses": { + "200": { + "description": "Success; private, no-store", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": false, + "properties": { + "data": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + }, + "packageName": { + "type": "string" + }, + "namespace": { + "type": [ + "string", + "null" + ] + }, + "title": { + "type": "string" + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "category": { + "type": [ + "string", + "null" + ] + }, + "publisher": { + "anyOf": [ + { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "id", + "name" + ] + }, + { + "type": "null" + } + ] + }, + "version": { + "type": "string" + }, + "latestAvailableVersion": { + "type": [ + "string", + "null" + ] + }, + "manifestVersion": { + "const": 1 + }, + "status": { + "enum": [ + "healthy", + "disabled", + "restart_required", + "incompatible", + "failed" + ] + }, + "desiredState": { + "enum": [ + "enabled", + "disabled" + ] + }, + "actualState": { + "enum": [ + "enabled", + "disabled", + "failed" + ] + }, + "instanceId": { + "type": "string" + }, + "pendingRestart": { + "type": "boolean" + }, + "lastError": { + "type": [ + "string", + "null" + ] + }, + "loaded": { + "type": "boolean" + }, + "desiredEnabled": { + "type": "boolean" + }, + "restartRequired": { + "type": "boolean" + }, + "compatible": { + "type": "boolean" + }, + "issues": { + "type": "array", + "items": { + "type": "string" + } + }, + "capabilities": { + "type": "array", + "items": { + "type": "string" + } + }, + "dependencies": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "optionalDependencies": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "compatibility": { + "anyOf": [ + { + "type": "object", + "additionalProperties": false, + "required": [ + "collaborative" + ], + "properties": { + "collaborative": { + "type": "string" + }, + "node": { + "type": "string" + } + } + }, + { + "type": "null" + } + ] + }, + "actions": { + "type": "object", + "additionalProperties": false, + "properties": { + "enable": { + "type": "boolean" + }, + "disable": { + "type": "boolean" + }, + "configure": { + "type": "boolean" + } + }, + "required": [ + "enable", + "disable", + "configure" + ] + }, + "changed": { + "type": "boolean" + } + }, + "required": [ + "id", + "packageName", + "namespace", + "title", + "description", + "category", + "publisher", + "version", + "latestAvailableVersion", + "manifestVersion", + "status", + "desiredState", + "actualState", + "instanceId", + "pendingRestart", + "lastError", + "loaded", + "desiredEnabled", + "restartRequired", + "compatible", + "issues", + "capabilities", + "dependencies", + "optionalDependencies", + "compatibility", + "actions", + "changed" + ] + } + }, + "required": [ + "data" + ] + } + } + } + }, + "400": { + "description": "Authorization, validation, or conflict error" + }, + "401": { + "description": "Authorization, validation, or conflict error" + }, + "403": { + "description": "Authorization, validation, or conflict error" + }, + "404": { + "description": "Authorization, validation, or conflict error" + }, + "409": { + "description": "Authorization, validation, or conflict error" + }, + "default": { + "description": "Ошибка авторизации, проверки или конфликта" + } + }, + "x-contract-level": "documented", + "x-source": "apps/core/src/plugins/registry-routes.ts" + } + }, + "/settings/extension-registry/{id}/health": { + "get": { + "tags": [ + "settings" + ], + "operationId": "get__settings_extension_registry_id_health", + "summary": "GET /settings/extension-registry/:id/health", + "description": "Human with plugins/read or update; current process snapshot. Source: apps/core/src/plugins/registry-routes.ts", + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Base64url identifier of the package name" + } + ], + "responses": { + "200": { + "description": "Success; private, no-store", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": false, + "properties": { + "data": { + "type": "object", + "additionalProperties": false, + "properties": { + "status": { + "type": "string" + }, + "issues": { + "type": "array", + "items": { + "type": "string" + } + }, + "restartRequired": { + "type": "boolean" + }, + "desiredState": { + "enum": [ + "enabled", + "disabled" + ] + }, + "actualState": { + "enum": [ + "enabled", + "disabled", + "failed" + ] + }, + "pendingRestart": { + "type": "boolean" + }, + "lastError": { + "type": [ + "string", + "null" + ] + }, + "instanceId": { + "type": "string" + } + }, + "required": [ + "status", + "issues", + "restartRequired", + "desiredState", + "actualState", + "pendingRestart", + "lastError", + "instanceId" + ] + } + }, + "required": [ + "data" + ] + } + } + } + }, + "400": { + "description": "Authorization, validation, or conflict error" + }, + "401": { + "description": "Authorization, validation, or conflict error" + }, + "403": { + "description": "Authorization, validation, or conflict error" + }, + "404": { + "description": "Authorization, validation, or conflict error" + }, + "409": { + "description": "Authorization, validation, or conflict error" + }, + "default": { + "description": "Ошибка авторизации, проверки или конфликта" + } + }, + "x-contract-level": "documented", + "x-source": "apps/core/src/plugins/registry-routes.ts" + } + }, + "/settings/extension-registry/{id}/history": { + "get": { + "tags": [ + "settings" + ], + "operationId": "get__settings_extension_registry_id_history", + "summary": "GET /settings/extension-registry/:id/history", + "description": "Human with plugins/read or update; superuser bypass. Source: apps/core/src/plugins/registry-routes.ts", + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Base64url identifier of the package name" + } + ], + "responses": { + "200": { + "description": "Success; private, no-store", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": false, + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + }, + "package_name": { + "type": "string" + }, + "action": { + "type": "string" + }, + "result": { + "type": "string" + }, + "error_code": { + "type": [ + "string", + "null" + ] + }, + "error_message": { + "type": [ + "string", + "null" + ] + }, + "actor_id": { + "type": [ + "string", + "null" + ] + }, + "instance_id": { + "type": [ + "string", + "null" + ] + }, + "created_at": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "id", + "package_name", + "action", + "result", + "error_code", + "error_message", + "actor_id", + "instance_id", + "created_at" + ] + } + } + }, + "required": [ + "data" + ] + } + } + } + }, + "400": { + "description": "Authorization, validation, or conflict error" + }, + "401": { + "description": "Authorization, validation, or conflict error" + }, + "403": { + "description": "Authorization, validation, or conflict error" + }, + "404": { + "description": "Authorization, validation, or conflict error" + }, + "409": { + "description": "Authorization, validation, or conflict error" + }, + "default": { + "description": "Ошибка авторизации, проверки или конфликта" + } + }, + "x-contract-level": "documented", + "x-source": "apps/core/src/plugins/registry-routes.ts" + } + }, + "/settings/extension-registry/{id}/permissions": { + "get": { + "tags": [ + "settings" + ], + "operationId": "get__settings_extension_registry_id_permissions", + "summary": "GET /settings/extension-registry/:id/permissions", + "description": "Human with plugins/read or update; superuser bypass. Source: apps/core/src/plugins/registry-routes.ts", + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Base64url identifier of the package name" + } + ], + "responses": { + "200": { + "description": "Success; private, no-store", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": false, + "properties": { + "data": { + "type": "object", + "additionalProperties": false, + "properties": { + "declared": { + "type": "array", + "items": { + "type": "string" + } + }, + "approvalIssue": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "declared", + "approvalIssue" + ] + } + }, + "required": [ + "data" + ] + } + } + } + }, + "400": { + "description": "Authorization, validation, or conflict error" + }, + "401": { + "description": "Authorization, validation, or conflict error" + }, + "403": { + "description": "Authorization, validation, or conflict error" + }, + "404": { + "description": "Authorization, validation, or conflict error" + }, + "409": { + "description": "Authorization, validation, or conflict error" + }, + "default": { + "description": "Ошибка авторизации, проверки или конфликта" + } + }, + "x-contract-level": "documented", + "x-source": "apps/core/src/plugins/registry-routes.ts" + } + }, + "/settings/extension-registry/{id}/versions": { + "get": { + "tags": [ + "settings" + ], + "operationId": "get__settings_extension_registry_id_versions", + "summary": "GET /settings/extension-registry/:id/versions", + "description": "Human with plugins/read or update; installed version only. Source: apps/core/src/plugins/registry-routes.ts", + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Base64url identifier of the package name" + } + ], + "responses": { + "200": { + "description": "Success; private, no-store", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": false, + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "version": { + "type": "string" + }, + "source": { + "const": "project-package" + }, + "installed": { + "type": "boolean" + } + }, + "required": [ + "version", + "source", + "installed" + ] + } + } + }, + "required": [ + "data" + ] + } + } + } + }, + "400": { + "description": "Authorization, validation, or conflict error" + }, + "401": { + "description": "Authorization, validation, or conflict error" + }, + "403": { + "description": "Authorization, validation, or conflict error" + }, + "404": { + "description": "Authorization, validation, or conflict error" + }, + "409": { + "description": "Authorization, validation, or conflict error" + }, + "default": { + "description": "Ошибка авторизации, проверки или конфликта" + } + }, + "x-contract-level": "documented", + "x-source": "apps/core/src/plugins/registry-routes.ts" + } + }, "/settings/integrations": { "get": { "tags": [ diff --git a/docs/public/ru/openapi.json b/docs/public/ru/openapi.json index ec48a0a..9f0f6f5 100644 --- a/docs/public/ru/openapi.json +++ b/docs/public/ru/openapi.json @@ -11041,6 +11041,1672 @@ "x-source": "apps/core/src/settings/routes.ts" } }, + "/settings/extension-registry": { + "get": { + "tags": [ + "settings" + ], + "operationId": "get__settings_extension_registry", + "summary": "GET /settings/extension-registry", + "description": "Человек: plugins/read либо update; superuser bypass; только настроенные пакеты. Источник: apps/core/src/plugins/registry-routes.ts", + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ], + "parameters": [ + { + "name": "page", + "in": "query", + "schema": { + "type": "integer", + "minimum": 1, + "default": 1 + } + }, + { + "name": "limit", + "in": "query", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 20 + } + }, + { + "name": "search", + "in": "query", + "schema": { + "type": "string", + "maxLength": 120 + } + }, + { + "name": "status", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "category", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "sort", + "in": "query", + "schema": { + "enum": [ + "title", + "version", + "status" + ] + } + } + ], + "responses": { + "200": { + "description": "Успех; private, no-store", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": false, + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + }, + "packageName": { + "type": "string" + }, + "namespace": { + "type": [ + "string", + "null" + ] + }, + "title": { + "type": "string" + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "category": { + "type": [ + "string", + "null" + ] + }, + "publisher": { + "anyOf": [ + { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "id", + "name" + ] + }, + { + "type": "null" + } + ] + }, + "version": { + "type": "string" + }, + "latestAvailableVersion": { + "type": [ + "string", + "null" + ] + }, + "manifestVersion": { + "const": 1 + }, + "status": { + "enum": [ + "healthy", + "disabled", + "restart_required", + "incompatible", + "failed" + ] + }, + "desiredState": { + "enum": [ + "enabled", + "disabled" + ] + }, + "actualState": { + "enum": [ + "enabled", + "disabled", + "failed" + ] + }, + "instanceId": { + "type": "string" + }, + "pendingRestart": { + "type": "boolean" + }, + "lastError": { + "type": [ + "string", + "null" + ] + }, + "loaded": { + "type": "boolean" + }, + "desiredEnabled": { + "type": "boolean" + }, + "restartRequired": { + "type": "boolean" + }, + "compatible": { + "type": "boolean" + }, + "issues": { + "type": "array", + "items": { + "type": "string" + } + }, + "capabilities": { + "type": "array", + "items": { + "type": "string" + } + }, + "dependencies": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "optionalDependencies": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "compatibility": { + "anyOf": [ + { + "type": "object", + "additionalProperties": false, + "required": [ + "collaborative" + ], + "properties": { + "collaborative": { + "type": "string" + }, + "node": { + "type": "string" + } + } + }, + { + "type": "null" + } + ] + }, + "actions": { + "type": "object", + "additionalProperties": false, + "properties": { + "enable": { + "type": "boolean" + }, + "disable": { + "type": "boolean" + }, + "configure": { + "type": "boolean" + } + }, + "required": [ + "enable", + "disable", + "configure" + ] + } + }, + "required": [ + "id", + "packageName", + "namespace", + "title", + "description", + "category", + "publisher", + "version", + "latestAvailableVersion", + "manifestVersion", + "status", + "desiredState", + "actualState", + "instanceId", + "pendingRestart", + "lastError", + "loaded", + "desiredEnabled", + "restartRequired", + "compatible", + "issues", + "capabilities", + "dependencies", + "optionalDependencies", + "compatibility", + "actions" + ] + } + }, + "page": { + "type": "integer" + }, + "limit": { + "type": "integer" + }, + "total": { + "type": "integer" + } + }, + "required": [ + "data", + "page", + "limit", + "total" + ] + } + } + } + }, + "default": { + "description": "Ошибка авторизации, проверки или конфликта" + } + }, + "x-contract-level": "documented", + "x-source": "apps/core/src/plugins/registry-routes.ts" + } + }, + "/settings/extension-registry/{id}": { + "get": { + "tags": [ + "settings" + ], + "operationId": "get__settings_extension_registry_id", + "summary": "GET /settings/extension-registry/:id", + "description": "Человек: plugins/read либо update; superuser bypass. Источник: apps/core/src/plugins/registry-routes.ts", + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Base64url идентификатор имени пакета" + } + ], + "responses": { + "200": { + "description": "Успех; private, no-store", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": false, + "properties": { + "data": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + }, + "packageName": { + "type": "string" + }, + "namespace": { + "type": [ + "string", + "null" + ] + }, + "title": { + "type": "string" + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "category": { + "type": [ + "string", + "null" + ] + }, + "publisher": { + "anyOf": [ + { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "id", + "name" + ] + }, + { + "type": "null" + } + ] + }, + "version": { + "type": "string" + }, + "latestAvailableVersion": { + "type": [ + "string", + "null" + ] + }, + "manifestVersion": { + "const": 1 + }, + "status": { + "enum": [ + "healthy", + "disabled", + "restart_required", + "incompatible", + "failed" + ] + }, + "desiredState": { + "enum": [ + "enabled", + "disabled" + ] + }, + "actualState": { + "enum": [ + "enabled", + "disabled", + "failed" + ] + }, + "instanceId": { + "type": "string" + }, + "pendingRestart": { + "type": "boolean" + }, + "lastError": { + "type": [ + "string", + "null" + ] + }, + "loaded": { + "type": "boolean" + }, + "desiredEnabled": { + "type": "boolean" + }, + "restartRequired": { + "type": "boolean" + }, + "compatible": { + "type": "boolean" + }, + "issues": { + "type": "array", + "items": { + "type": "string" + } + }, + "capabilities": { + "type": "array", + "items": { + "type": "string" + } + }, + "dependencies": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "optionalDependencies": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "compatibility": { + "anyOf": [ + { + "type": "object", + "additionalProperties": false, + "required": [ + "collaborative" + ], + "properties": { + "collaborative": { + "type": "string" + }, + "node": { + "type": "string" + } + } + }, + { + "type": "null" + } + ] + }, + "actions": { + "type": "object", + "additionalProperties": false, + "properties": { + "enable": { + "type": "boolean" + }, + "disable": { + "type": "boolean" + }, + "configure": { + "type": "boolean" + } + }, + "required": [ + "enable", + "disable", + "configure" + ] + } + }, + "required": [ + "id", + "packageName", + "namespace", + "title", + "description", + "category", + "publisher", + "version", + "latestAvailableVersion", + "manifestVersion", + "status", + "desiredState", + "actualState", + "instanceId", + "pendingRestart", + "lastError", + "loaded", + "desiredEnabled", + "restartRequired", + "compatible", + "issues", + "capabilities", + "dependencies", + "optionalDependencies", + "compatibility", + "actions" + ] + } + }, + "required": [ + "data" + ] + } + } + } + }, + "400": { + "description": "Ошибка авторизации, проверки или конфликта" + }, + "401": { + "description": "Ошибка авторизации, проверки или конфликта" + }, + "403": { + "description": "Ошибка авторизации, проверки или конфликта" + }, + "404": { + "description": "Ошибка авторизации, проверки или конфликта" + }, + "409": { + "description": "Ошибка авторизации, проверки или конфликта" + }, + "default": { + "description": "Ошибка авторизации, проверки или конфликта" + } + }, + "x-contract-level": "documented", + "x-source": "apps/core/src/plugins/registry-routes.ts" + } + }, + "/settings/extension-registry/{id}/dependencies": { + "get": { + "tags": [ + "settings" + ], + "operationId": "get__settings_extension_registry_id_dependencies", + "summary": "GET /settings/extension-registry/:id/dependencies", + "description": "Человек: plugins/read либо update; superuser bypass. Источник: apps/core/src/plugins/registry-routes.ts", + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Base64url идентификатор имени пакета" + } + ], + "responses": { + "200": { + "description": "Успех; private, no-store", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": false, + "properties": { + "data": { + "type": "object", + "additionalProperties": false, + "properties": { + "required": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "optional": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "required": [ + "required", + "optional" + ] + } + }, + "required": [ + "data" + ] + } + } + } + }, + "400": { + "description": "Ошибка авторизации, проверки или конфликта" + }, + "401": { + "description": "Ошибка авторизации, проверки или конфликта" + }, + "403": { + "description": "Ошибка авторизации, проверки или конфликта" + }, + "404": { + "description": "Ошибка авторизации, проверки или конфликта" + }, + "409": { + "description": "Ошибка авторизации, проверки или конфликта" + }, + "default": { + "description": "Ошибка авторизации, проверки или конфликта" + } + }, + "x-contract-level": "documented", + "x-source": "apps/core/src/plugins/registry-routes.ts" + } + }, + "/settings/extension-registry/{id}/disable": { + "post": { + "tags": [ + "settings" + ], + "operationId": "post__settings_extension_registry_id_disable", + "summary": "POST /settings/extension-registry/:id/disable", + "description": "Человек: plugins/update; superuser bypass; сохраняет желаемое состояние до перезапуска. Источник: apps/core/src/plugins/registry-routes.ts", + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Base64url идентификатор имени пакета" + } + ], + "responses": { + "200": { + "description": "Успех; private, no-store", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": false, + "properties": { + "data": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + }, + "packageName": { + "type": "string" + }, + "namespace": { + "type": [ + "string", + "null" + ] + }, + "title": { + "type": "string" + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "category": { + "type": [ + "string", + "null" + ] + }, + "publisher": { + "anyOf": [ + { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "id", + "name" + ] + }, + { + "type": "null" + } + ] + }, + "version": { + "type": "string" + }, + "latestAvailableVersion": { + "type": [ + "string", + "null" + ] + }, + "manifestVersion": { + "const": 1 + }, + "status": { + "enum": [ + "healthy", + "disabled", + "restart_required", + "incompatible", + "failed" + ] + }, + "desiredState": { + "enum": [ + "enabled", + "disabled" + ] + }, + "actualState": { + "enum": [ + "enabled", + "disabled", + "failed" + ] + }, + "instanceId": { + "type": "string" + }, + "pendingRestart": { + "type": "boolean" + }, + "lastError": { + "type": [ + "string", + "null" + ] + }, + "loaded": { + "type": "boolean" + }, + "desiredEnabled": { + "type": "boolean" + }, + "restartRequired": { + "type": "boolean" + }, + "compatible": { + "type": "boolean" + }, + "issues": { + "type": "array", + "items": { + "type": "string" + } + }, + "capabilities": { + "type": "array", + "items": { + "type": "string" + } + }, + "dependencies": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "optionalDependencies": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "compatibility": { + "anyOf": [ + { + "type": "object", + "additionalProperties": false, + "required": [ + "collaborative" + ], + "properties": { + "collaborative": { + "type": "string" + }, + "node": { + "type": "string" + } + } + }, + { + "type": "null" + } + ] + }, + "actions": { + "type": "object", + "additionalProperties": false, + "properties": { + "enable": { + "type": "boolean" + }, + "disable": { + "type": "boolean" + }, + "configure": { + "type": "boolean" + } + }, + "required": [ + "enable", + "disable", + "configure" + ] + }, + "changed": { + "type": "boolean" + } + }, + "required": [ + "id", + "packageName", + "namespace", + "title", + "description", + "category", + "publisher", + "version", + "latestAvailableVersion", + "manifestVersion", + "status", + "desiredState", + "actualState", + "instanceId", + "pendingRestart", + "lastError", + "loaded", + "desiredEnabled", + "restartRequired", + "compatible", + "issues", + "capabilities", + "dependencies", + "optionalDependencies", + "compatibility", + "actions", + "changed" + ] + } + }, + "required": [ + "data" + ] + } + } + } + }, + "400": { + "description": "Ошибка авторизации, проверки или конфликта" + }, + "401": { + "description": "Ошибка авторизации, проверки или конфликта" + }, + "403": { + "description": "Ошибка авторизации, проверки или конфликта" + }, + "404": { + "description": "Ошибка авторизации, проверки или конфликта" + }, + "409": { + "description": "Ошибка авторизации, проверки или конфликта" + }, + "default": { + "description": "Ошибка авторизации, проверки или конфликта" + } + }, + "x-contract-level": "documented", + "x-source": "apps/core/src/plugins/registry-routes.ts" + } + }, + "/settings/extension-registry/{id}/enable": { + "post": { + "tags": [ + "settings" + ], + "operationId": "post__settings_extension_registry_id_enable", + "summary": "POST /settings/extension-registry/:id/enable", + "description": "Человек: plugins/update; superuser bypass; сохраняет желаемое состояние до перезапуска. Источник: apps/core/src/plugins/registry-routes.ts", + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Base64url идентификатор имени пакета" + } + ], + "responses": { + "200": { + "description": "Успех; private, no-store", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": false, + "properties": { + "data": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + }, + "packageName": { + "type": "string" + }, + "namespace": { + "type": [ + "string", + "null" + ] + }, + "title": { + "type": "string" + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "category": { + "type": [ + "string", + "null" + ] + }, + "publisher": { + "anyOf": [ + { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "id", + "name" + ] + }, + { + "type": "null" + } + ] + }, + "version": { + "type": "string" + }, + "latestAvailableVersion": { + "type": [ + "string", + "null" + ] + }, + "manifestVersion": { + "const": 1 + }, + "status": { + "enum": [ + "healthy", + "disabled", + "restart_required", + "incompatible", + "failed" + ] + }, + "desiredState": { + "enum": [ + "enabled", + "disabled" + ] + }, + "actualState": { + "enum": [ + "enabled", + "disabled", + "failed" + ] + }, + "instanceId": { + "type": "string" + }, + "pendingRestart": { + "type": "boolean" + }, + "lastError": { + "type": [ + "string", + "null" + ] + }, + "loaded": { + "type": "boolean" + }, + "desiredEnabled": { + "type": "boolean" + }, + "restartRequired": { + "type": "boolean" + }, + "compatible": { + "type": "boolean" + }, + "issues": { + "type": "array", + "items": { + "type": "string" + } + }, + "capabilities": { + "type": "array", + "items": { + "type": "string" + } + }, + "dependencies": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "optionalDependencies": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "compatibility": { + "anyOf": [ + { + "type": "object", + "additionalProperties": false, + "required": [ + "collaborative" + ], + "properties": { + "collaborative": { + "type": "string" + }, + "node": { + "type": "string" + } + } + }, + { + "type": "null" + } + ] + }, + "actions": { + "type": "object", + "additionalProperties": false, + "properties": { + "enable": { + "type": "boolean" + }, + "disable": { + "type": "boolean" + }, + "configure": { + "type": "boolean" + } + }, + "required": [ + "enable", + "disable", + "configure" + ] + }, + "changed": { + "type": "boolean" + } + }, + "required": [ + "id", + "packageName", + "namespace", + "title", + "description", + "category", + "publisher", + "version", + "latestAvailableVersion", + "manifestVersion", + "status", + "desiredState", + "actualState", + "instanceId", + "pendingRestart", + "lastError", + "loaded", + "desiredEnabled", + "restartRequired", + "compatible", + "issues", + "capabilities", + "dependencies", + "optionalDependencies", + "compatibility", + "actions", + "changed" + ] + } + }, + "required": [ + "data" + ] + } + } + } + }, + "400": { + "description": "Ошибка авторизации, проверки или конфликта" + }, + "401": { + "description": "Ошибка авторизации, проверки или конфликта" + }, + "403": { + "description": "Ошибка авторизации, проверки или конфликта" + }, + "404": { + "description": "Ошибка авторизации, проверки или конфликта" + }, + "409": { + "description": "Ошибка авторизации, проверки или конфликта" + }, + "default": { + "description": "Ошибка авторизации, проверки или конфликта" + } + }, + "x-contract-level": "documented", + "x-source": "apps/core/src/plugins/registry-routes.ts" + } + }, + "/settings/extension-registry/{id}/health": { + "get": { + "tags": [ + "settings" + ], + "operationId": "get__settings_extension_registry_id_health", + "summary": "GET /settings/extension-registry/:id/health", + "description": "Человек: plugins/read либо update; snapshot текущего процесса. Источник: apps/core/src/plugins/registry-routes.ts", + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Base64url идентификатор имени пакета" + } + ], + "responses": { + "200": { + "description": "Успех; private, no-store", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": false, + "properties": { + "data": { + "type": "object", + "additionalProperties": false, + "properties": { + "status": { + "type": "string" + }, + "issues": { + "type": "array", + "items": { + "type": "string" + } + }, + "restartRequired": { + "type": "boolean" + }, + "desiredState": { + "enum": [ + "enabled", + "disabled" + ] + }, + "actualState": { + "enum": [ + "enabled", + "disabled", + "failed" + ] + }, + "pendingRestart": { + "type": "boolean" + }, + "lastError": { + "type": [ + "string", + "null" + ] + }, + "instanceId": { + "type": "string" + } + }, + "required": [ + "status", + "issues", + "restartRequired", + "desiredState", + "actualState", + "pendingRestart", + "lastError", + "instanceId" + ] + } + }, + "required": [ + "data" + ] + } + } + } + }, + "400": { + "description": "Ошибка авторизации, проверки или конфликта" + }, + "401": { + "description": "Ошибка авторизации, проверки или конфликта" + }, + "403": { + "description": "Ошибка авторизации, проверки или конфликта" + }, + "404": { + "description": "Ошибка авторизации, проверки или конфликта" + }, + "409": { + "description": "Ошибка авторизации, проверки или конфликта" + }, + "default": { + "description": "Ошибка авторизации, проверки или конфликта" + } + }, + "x-contract-level": "documented", + "x-source": "apps/core/src/plugins/registry-routes.ts" + } + }, + "/settings/extension-registry/{id}/history": { + "get": { + "tags": [ + "settings" + ], + "operationId": "get__settings_extension_registry_id_history", + "summary": "GET /settings/extension-registry/:id/history", + "description": "Человек: plugins/read либо update; superuser bypass. Источник: apps/core/src/plugins/registry-routes.ts", + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Base64url идентификатор имени пакета" + } + ], + "responses": { + "200": { + "description": "Успех; private, no-store", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": false, + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + }, + "package_name": { + "type": "string" + }, + "action": { + "type": "string" + }, + "result": { + "type": "string" + }, + "error_code": { + "type": [ + "string", + "null" + ] + }, + "error_message": { + "type": [ + "string", + "null" + ] + }, + "actor_id": { + "type": [ + "string", + "null" + ] + }, + "instance_id": { + "type": [ + "string", + "null" + ] + }, + "created_at": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "id", + "package_name", + "action", + "result", + "error_code", + "error_message", + "actor_id", + "instance_id", + "created_at" + ] + } + } + }, + "required": [ + "data" + ] + } + } + } + }, + "400": { + "description": "Ошибка авторизации, проверки или конфликта" + }, + "401": { + "description": "Ошибка авторизации, проверки или конфликта" + }, + "403": { + "description": "Ошибка авторизации, проверки или конфликта" + }, + "404": { + "description": "Ошибка авторизации, проверки или конфликта" + }, + "409": { + "description": "Ошибка авторизации, проверки или конфликта" + }, + "default": { + "description": "Ошибка авторизации, проверки или конфликта" + } + }, + "x-contract-level": "documented", + "x-source": "apps/core/src/plugins/registry-routes.ts" + } + }, + "/settings/extension-registry/{id}/permissions": { + "get": { + "tags": [ + "settings" + ], + "operationId": "get__settings_extension_registry_id_permissions", + "summary": "GET /settings/extension-registry/:id/permissions", + "description": "Человек: plugins/read либо update; superuser bypass. Источник: apps/core/src/plugins/registry-routes.ts", + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Base64url идентификатор имени пакета" + } + ], + "responses": { + "200": { + "description": "Успех; private, no-store", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": false, + "properties": { + "data": { + "type": "object", + "additionalProperties": false, + "properties": { + "declared": { + "type": "array", + "items": { + "type": "string" + } + }, + "approvalIssue": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "declared", + "approvalIssue" + ] + } + }, + "required": [ + "data" + ] + } + } + } + }, + "400": { + "description": "Ошибка авторизации, проверки или конфликта" + }, + "401": { + "description": "Ошибка авторизации, проверки или конфликта" + }, + "403": { + "description": "Ошибка авторизации, проверки или конфликта" + }, + "404": { + "description": "Ошибка авторизации, проверки или конфликта" + }, + "409": { + "description": "Ошибка авторизации, проверки или конфликта" + }, + "default": { + "description": "Ошибка авторизации, проверки или конфликта" + } + }, + "x-contract-level": "documented", + "x-source": "apps/core/src/plugins/registry-routes.ts" + } + }, + "/settings/extension-registry/{id}/versions": { + "get": { + "tags": [ + "settings" + ], + "operationId": "get__settings_extension_registry_id_versions", + "summary": "GET /settings/extension-registry/:id/versions", + "description": "Человек: plugins/read либо update; только установленная версия. Источник: apps/core/src/plugins/registry-routes.ts", + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Base64url идентификатор имени пакета" + } + ], + "responses": { + "200": { + "description": "Успех; private, no-store", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": false, + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "version": { + "type": "string" + }, + "source": { + "const": "project-package" + }, + "installed": { + "type": "boolean" + } + }, + "required": [ + "version", + "source", + "installed" + ] + } + } + }, + "required": [ + "data" + ] + } + } + } + }, + "400": { + "description": "Ошибка авторизации, проверки или конфликта" + }, + "401": { + "description": "Ошибка авторизации, проверки или конфликта" + }, + "403": { + "description": "Ошибка авторизации, проверки или конфликта" + }, + "404": { + "description": "Ошибка авторизации, проверки или конфликта" + }, + "409": { + "description": "Ошибка авторизации, проверки или конфликта" + }, + "default": { + "description": "Ошибка авторизации, проверки или конфликта" + } + }, + "x-contract-level": "documented", + "x-source": "apps/core/src/plugins/registry-routes.ts" + } + }, "/settings/integrations": { "get": { "tags": [ diff --git a/docs/reference/cli-guide.md b/docs/reference/cli-guide.md index f295b17..198e9e8 100644 --- a/docs/reference/cli-guide.md +++ b/docs/reference/cli-guide.md @@ -35,6 +35,13 @@ For CI, provide ASMBLYR_ACCESS_TOKEN or --token-stdin and an explicit Core API o /api root. Credentials are never written to generated files. HTTPS is required except on localhost; HTTP redirects are rejected. +The extension commands use the registry API and require an explicit human access +token with `plugins/read` or `plugins/update`. The browser's schema-only consent +token cannot use them. Run `asm extensions list`, then use the returned ID with +`asm extensions info `. `asm extensions enable --yes` and +`asm extensions disable --yes` save the desired state; restart Core to apply +it. The CLI cannot install, update or remove package artifacts. + connect creates asmblyr.config.json, asmblyr.schema.json and asmblyr.schema.ts. It preserves existing files; schema pull refreshes an existing connection. --config, --schema-file and --types-file select relative project paths. diff --git a/docs/reference/endpoints.json b/docs/reference/endpoints.json index 08bc725..94ff348 100644 --- a/docs/reference/endpoints.json +++ b/docs/reference/endpoints.json @@ -583,6 +583,42 @@ "access": "Человек: plugins/read ИЛИ update; superuser bypass", "public": false }, + "GET /settings/extension-registry": { + "access": "Человек: plugins/read либо update; superuser bypass; только настроенные пакеты", + "public": false + }, + "GET /settings/extension-registry/:id": { + "access": "Человек: plugins/read либо update; superuser bypass", + "public": false + }, + "GET /settings/extension-registry/:id/versions": { + "access": "Человек: plugins/read либо update; только установленная версия", + "public": false + }, + "GET /settings/extension-registry/:id/dependencies": { + "access": "Человек: plugins/read либо update; superuser bypass", + "public": false + }, + "GET /settings/extension-registry/:id/permissions": { + "access": "Человек: plugins/read либо update; superuser bypass", + "public": false + }, + "GET /settings/extension-registry/:id/health": { + "access": "Человек: plugins/read либо update; snapshot текущего процесса", + "public": false + }, + "GET /settings/extension-registry/:id/history": { + "access": "Человек: plugins/read либо update; superuser bypass", + "public": false + }, + "POST /settings/extension-registry/:id/enable": { + "access": "Человек: plugins/update; superuser bypass; сохраняет желаемое состояние до перезапуска", + "public": false + }, + "POST /settings/extension-registry/:id/disable": { + "access": "Человек: plugins/update; superuser bypass; сохраняет желаемое состояние до перезапуска", + "public": false + }, "GET /settings/plugins/:namespace": { "access": "Человек: plugins/read ИЛИ update; superuser bypass", "public": false diff --git a/docs/reference/kit-lifecycle.md b/docs/reference/kit-lifecycle.md index 83b9066..7fdf4ed 100644 --- a/docs/reference/kit-lifecycle.md +++ b/docs/reference/kit-lifecycle.md @@ -82,7 +82,7 @@ Import shadcn directly from kit/ui/<component>. Admin/plugins share full p Panels mount on first open and persist across tab changes. Render failures are isolated from the record form. New unsaved records have no panels. Active packages are checked through authenticated /extensions. -Next generates static imports and Tailwind sources from enabled exports at startup/build. Development reads ui/index.ts; production/npm use built ./ui. Package-list changes require dev restart or production UI rebuild; TSX uses Fast Refresh. React is a peer dependency. Use .ts/.tsx relative imports with rewriteRelativeImportExtensions:true, and include ui/**/\* and shared/**/\*. +Next generates deferred import descriptors and Tailwind sources from configured exports at startup/build. The browser imports only packages enabled by Core. An import failure is isolated from other modules and the host form; standalone plugin pages show a loading state or a retry action. Development reads ui/index.ts; production/npm use built ./ui. Package-list changes require dev restart or production UI rebuild; TSX uses Fast Refresh. React is a peer dependency. Use .ts/.tsx relative imports with rewriteRelativeImportExtensions:true, and include ui/**/\* and shared/**/\*. Packages/plugin-comments is the complete example. Server packages must never enter UI imports. @@ -105,7 +105,7 @@ Routes use /extensions/<namespace>/<id>. The host adds pages under A PluginPageProps.request is the same namespace-limited HTTP client. Shared Button/Textarea come from Kit. The shell isolates render errors. Authenticated /extensions supplies enabled packages to layout, menu, pages, and panels. -Users must satisfy existing admin admission rules. Separate page permissions are unsupported; each plugin API enforces its domain rights. Missing/disabled plugins are hidden. Enabling requires rebuilding/restarting UI and Core. +Users must satisfy existing admin admission rules. Separate page permissions are unsupported; each plugin API enforces its domain rights. Missing/disabled plugins are hidden. Changing registry activation requires restarting Core and refreshing existing browser tabs. Already executed JavaScript cannot be unloaded from a running tab. Adding packages still requires rebuilding UI and Core. Only fixed pages are supported, without nested/dynamic routes. Pages also receive preparedAction/onStateChange. UseAction(contract, props, { path, initialInput }) connects form schema, ordinary requests, prepared results, and dirty/busy host state. Overview reads only the current caller's context. See [field editors](kit-fields.md). diff --git a/docs/reference/routes.md b/docs/reference/routes.md index 8ebf277..6513eea 100644 --- a/docs/reference/routes.md +++ b/docs/reference/routes.md @@ -1,6 +1,6 @@ # HTTP route matrix -Generated from Core and the reviewed access catalog. 247 declarations. +Generated from Core and the reviewed access catalog. 256 declarations. This describes access boundaries; integration tests verify enforcement. Dynamic plugin routes and internal oidc-provider endpoints are not listed individually. @@ -173,6 +173,15 @@ This describes access boundaries; integration tests verify enforcement. Dynamic | `GET /settings/assistant` | Human with assistant/read or update; superuser bypass | `apps/core/src/settings/routes.ts` | | `PUT /settings/assistant` | Human with assistant/update; superuser bypass | `apps/core/src/settings/routes.ts` | | `GET /settings/assistant/telemetry` | Human with assistant/read or update; superuser bypass | `apps/core/src/settings/routes.ts` | +| `GET /settings/extension-registry` | Human with plugins/read or update; superuser bypass; configured packages only | `apps/core/src/plugins/registry-routes.ts` | +| `GET /settings/extension-registry/:id` | Human with plugins/read or update; superuser bypass | `apps/core/src/plugins/registry-routes.ts` | +| `GET /settings/extension-registry/:id/dependencies` | Human with plugins/read or update; superuser bypass | `apps/core/src/plugins/registry-routes.ts` | +| `POST /settings/extension-registry/:id/disable` | Human with plugins/update; superuser bypass; saves desired state until restart | `apps/core/src/plugins/registry-routes.ts` | +| `POST /settings/extension-registry/:id/enable` | Human with plugins/update; superuser bypass; saves desired state until restart | `apps/core/src/plugins/registry-routes.ts` | +| `GET /settings/extension-registry/:id/health` | Human with plugins/read or update; current process snapshot | `apps/core/src/plugins/registry-routes.ts` | +| `GET /settings/extension-registry/:id/history` | Human with plugins/read or update; superuser bypass | `apps/core/src/plugins/registry-routes.ts` | +| `GET /settings/extension-registry/:id/permissions` | Human with plugins/read or update; superuser bypass | `apps/core/src/plugins/registry-routes.ts` | +| `GET /settings/extension-registry/:id/versions` | Human with plugins/read or update; installed version only | `apps/core/src/plugins/registry-routes.ts` | | `GET /settings/integrations` | Active human superuser only; safe parameters and key-presence flags, without secret values | `apps/core/src/integrations/routes.ts` | | `PUT /settings/integrations/:section` | Superuser only; environment groups cannot change; revision checks and atomic re-encryption on protection changes | `apps/core/src/integrations/routes.ts` | | `POST /settings/integrations/:section/test` | Superuser only; credential rate limit; test without saving settings | `apps/core/src/integrations/routes.ts` | diff --git a/docs/reference/sdk-guide.md b/docs/reference/sdk-guide.md index 055b92a..378783c 100644 --- a/docs/reference/sdk-guide.md +++ b/docs/reference/sdk-guide.md @@ -4,6 +4,8 @@ Typed Collaborative HTTP client for browsers and Node.js 22+. Every operation calls the main API. Runtime dependencies do not include H3, Knex, PostgreSQL, the TypeScript compiler, or a plugin loader. Use the separate @asmblyr-collaborative/kit to develop extensions. +`client.extensions` reads the trusted extension registry with `list`, `get`, `versions`, `dependencies`, `permissions`, `health`, and `history`. `enable` and `disable` change desired activation and return `restartRequired`; Core must restart before server code is loaded or unloaded. The methods require a human `plugins` settings grant (`read` for inspection, `update` for changes). Package installation and updates remain deployment operations. + ## User profile diff --git a/docs/ru/architecture/extension-registry.md b/docs/ru/architecture/extension-registry.md new file mode 100644 index 0000000..826d3d5 --- /dev/null +++ b/docs/ru/architecture/extension-registry.md @@ -0,0 +1,139 @@ +# Архитектура реестра расширений + +Единица расширения — npm/workspace-пакет из корневого списка `asmblyr.plugins`. +Core проверяет manifest до импорта, затем загружает серверные маршруты, hooks и +настройки в доверенный процесс Node.js. `plugin.ts` остаётся `definePlugin({})`. +Список `asmblyr.pluginPermissions` отдельно одобряет запрошенные capabilities. + +Рабочая область pnpm содержит Core в `apps/core`, интерфейс Next.js в `apps/ui`, +Kit в `packages/kit`, HTTP SDK в `packages/sdk` и CLI в `packages/cli`. +Маршруты Core используют Fastify и существующие права раздела настроек; +миграции Knex владеют таблицами Core в `public`. Существующий редактор +проверяет обычные настройки плагина. Тесты запускаются через Node test runner, +а интеграционные — через `scripts/test.mjs` с отдельной базой PostgreSQL. +Сборка пакетов создаёт индексы маршрутов и UI, поэтому активация использует +имеющееся обнаружение в source и production. + +## Метаданные и зависимости + +Manifest версии 1 сохраняет `namespace` и `capabilities`. Дополнительные поля +описаны в [руководстве](../features/plugins.md). Версия пакета и диапазоны +зависимостей проверяются через semver. Обязательные зависимости должны быть +настроены, включены и совместимы; циклы блокируют активацию. Необязательные +зависимости не блокируют запуск. Метаданные читаются без исполнения кода. + +Manifest находится в `package.json` пакета под ключом `asmblyr.manifest`. +Действительный пример использует доверенный пакет Comments; корневой проект +должен также указать его в `asmblyr.plugins` и одобрить все заявленные +capabilities: + +```json +{ + "name": "@asmblyr-collaborative/plugin-comments", + "version": "0.0.0", + "asmblyr": { + "manifest": { + "version": 1, + "namespace": "comments", + "capabilities": [ + "identity.profile", + "items.read", + "collections.manage", + "storage.own", + "hooks.items", + "hooks.collections", + "settings", + "notifications" + ] + } + } +} +``` + +`name` — имя npm-пакета, `version` — semver, `manifest.version` равно `1`. +Поля `title`, `description`, `category` и `publisher: { id, name }` +необязательны и служат только для показа: подтверждение издателя из них не +следует. `compatibility.collaborative` и `compatibility.node` содержат semver +диапазоны. `dependencies` и `optionalDependencies` сопоставляют имена +пакетов диапазонам. Capabilities должны входить в контракт Kit и отдельно +одобряться проектом. Исполняемые entrypoints определяются exports пакета, +а не метаданными manifest. Неизвестные поля блокируют импорт. + +## Состояние и жизненный цикл + +Административный API использует `/settings/extension-registry`; существующий +`GET /extensions` остаётся маршрутом обнаружения UI-плагинов. Таблица +`asmblyr_extension_states` хранит желаемое включение, а +`asmblyr_extension_history` — время, действие и пользователя без секретов. +Транзакционная advisory lock сериализует изменения всего графа зависимостей. + +Поскольку серверный JavaScript нельзя надёжно выгрузить, включение и отключение +действуют после перезапуска Core. До него API явно возвращает +`pendingRestart`. После сверки при запуске API показывает `desiredState` из БД, +`actualState` обслужившего запрос процесса, его `instanceId` и локальную +`lastError`. Ошибки предыдущих процессов остаются в истории с `instance_id`. +Единого фактического состояния кластера нет: при rolling restart разные +реплики могут отвечать по-разному. Для проверки всех реплик обращайтесь к +каждой напрямую. Ошибка manifest, одобрения или импорта фиксируется в истории; +Core продолжает работу с остальными пакетами и пропускает обязательные +зависимые пакеты. Ошибка регистрации уже импортированного плагина всё ещё может +сорвать запуск Core. Отключение не удаляет таблицы и настройки плагина. Установка, +обновление и удаление npm-артефакта остаются действиями доверенного процесса +развёртывания; API не выполняет npm и installation scripts. + +## Доступ и граница доверия + +Для чтения нужен `plugins/read` или `plugins/update`; изменение состояния +требует `plugins/update`. Пакеты работают с доступом процесса Core и не +изолированы. Недоверенный сторонний пакет можно будет активировать только после +создания отдельной процессной и браузерной изоляции. `worker_threads`, VM и +dynamic import для этого недостаточны. Секретные поля настроек пока не +поддерживаются: текущий editor получает обычные значения, а отдельное внешнее +хранилище секретов ещё не интегрировано. + +Будущий изолированный runtime должен принимать проверенный неизменяемый +артефакт и утверждённые capabilities, запускать код вне процесса Core, +предоставлять ограниченный протокол запросов, сообщать о состоянии и +останавливаться без доступа к окружению Core и учётным данным БД. Хост должен +проксировать только разрешённые операции с теми же правами пользователя, что +HTTP и MCP. Для браузерного UI понадобится отдельный origin. Это граница +будущего контракта, а не доступный сейчас runtime. + +Диагностика показывает текущую загрузку и результаты проверок, а не +доступность внешних сервисов. Удалённого каталога версий и автоматической +установки пока нет. При ошибке запуска восстановите доверенный пакет или +конфигурацию, выполните миграции и перезапустите Core. + +## API + +`GET /settings/extension-registry` возвращает постраничный список с параметрами +`page`, `limit`, `search`, `status`, `category`, `sort`. Маршруты +`GET /settings/extension-registry/:id` и вложенные `/versions`, +`/dependencies`, `/permissions`, `/health`, `/history` возвращают подробности. +`POST /settings/extension-registry/:id/enable` и `/disable` меняют желаемое +состояние. `id` — base64url имени пакета из списка. Для чтения нужен +`plugins/read` или `plugins/update`, для изменений — `plugins/update`. +Ошибки: `400` — запрос, `401` — нет входа, `403` — недостаточно прав, +`404` — неизвестный ID, `409` — конфликт зависимостей или разрешений. +Схемы ответов опубликованы в генерируемом OpenAPI. + +Для локальной проверки через Docker запустите +`docker run --rm --name collaborative-registry-pg -e POSTGRES_PASSWORD=localtest -p 127.0.0.1:55439:5432 -d postgres:16`, +укажите `TEST_DATABASE_ADMIN_URL=postgresql://postgres:localtest@127.0.0.1:55439/postgres` +и выполните `node scripts/test.mjs core-integration`. Остановите контейнер +командой `docker stop collaborative-registry-pg`. Если Docker недоступен, +запустите локальный PostgreSQL на loopback и задайте тот же URL. +Runner создаёт, мигрирует и удаляет +одноразовую БД `asmblyr_test_*`; production-сервер использовать нельзя. +События `extension.validation.failed`, `extension.enable.requested`, +`extension.disable.requested`, `extension.startup.succeeded`, +`extension.startup.failed` и `extension.reconciliation.completed` пишет +существующий логгер Core. + +## Версия Core и загрузка интерфейса + +Совместимость проверяется по версии установленного Core, а не проекта-хоста. При запуске из исходников используется apps/core/package.json; ASMBLYR_CORE_VERSION задаёт версию релизного образа. CI передаёт тег без префикса v, а для main/PR добавляет к версии пакета метаданные коммита. Некорректная версия и заглушка 0.0.0 останавливают запуск. DEPLOYMENT_VERSION остаётся отдельным идентификатором согласованного развёртывания Core/UI. + +Браузерный реестр содержит отложенные импорты. Входной UI-модуль исполняется только для пакета, включённого в Core. Параллельные загрузки используют общий промис; после ошибки загрузку можно повторить. При смене enabled устаревший результат игнорируется, а отключённые определения сразу исключаются без замены формы хоста. Недоступный редактор поля использует обычное поле ввода и сохраняет черновик. Отдельные страницы показывают загрузку и ошибку импорта с повтором вместо преждевременного 404. + +После изменения активности перезапустите Core и обновите открытые вкладки браузера. Уже выполненный JavaScript невозможно выгрузить из работающей вкладки. Код интерфейса остаётся доверенным и не изолирован песочницей. diff --git a/docs/ru/features/plugins.md b/docs/ru/features/plugins.md index b5eaae3..3b44c9b 100644 --- a/docs/ru/features/plugins.md +++ b/docs/ru/features/plugins.md @@ -6,7 +6,13 @@ Плагин — npm/workspace-пакет. Рабочие пакеты проекта находятся в `packages`, демонстрационные — в `examples/plugins`. Обнаружение идёт по списку пакетов `asmblyr.plugins` в корневом `package.json`, а не по имени каталога. Подключённый пакет должен быть установлен. Установка из UI пока не реализована. -В разделе «Настройки → Расширения» показывается список плагинов. Нажатие на строку или название открывает штатный правый редактор с настройками выбранного плагина и разрешёнными возможностями. Закрытие несохранённого черновика требует подтверждения; во время сохранения редактор блокирует закрытие. Плагины без параметров также открываются для просмотра возможностей. +В разделе «Настройки → Расширения» показаны настроенные пакеты с поиском, фильтрами, совместимостью, зависимостями, возможностями, историей операций и настройками. Нажатие на название открывает правый редактор. Закрытие несохранённых настроек требует подтверждения; во время сохранения закрытие блокируется. Плагины без параметров также открываются для просмотра. + +Сводка содержит четыре счётчика: «Установлено», «Активно», «Отключено» и «Проблемы». Все показатели рассчитаны по настроенным пакетам; счётчики каталога и обновлений не показываются. + +Администратор с правом `plugins/update` может включить или отключить настроенный пакет. API сохраняет `desiredState` и возвращает `pendingRestart`: маршруты и hooks текущего процесса продолжают работать до перезапуска Core. После перезапуска сверка обновляет `actualState`, `lastError` и `instanceId` обслужившего запрос процесса. Конфликт зависимостей или отсутствие одобрения возможностей блокирует включение. Данные плагина при отключении сохраняются. Для просмотра нужны `plugins/read` либо `plugins/update`. + +Установка, обновление и удаление пакетов выполняются доверенным процессом развёртывания с изменением корневого списка пакетов, lockfile и артефактов. Core не устанавливает произвольные npm-пакеты через API. Внешнего каталога версий пока нет, поэтому центр показывает только установленную версию. @@ -19,6 +25,10 @@ - Browser entry: отдельный `./ui`, страницы и field interfaces. - `defineModelContext` + аннотация: явная публикация handler во внутреннем MCP/ассистенте. +Существующий контракт `asmblyr.manifest.version: 1` сохраняется. Необязательные поля метаданных в `package.json`: `title`, `description`, `category`, `publisher: { id, name }`, `compatibility: { collaborative, node? }`, `dependencies` и `optionalDependencies`. Имя и установленная версия берутся из самого npm-пакета. Диапазоны версий используют semver. Старые тестовые manifest v1 без версии пакета получают `0.0.0`. Обязательные зависимости должны быть настроены, включены, совместимы по версии и не образовывать цикл. Необязательные зависимости не блокируют запуск. Подробнее — [архитектура реестра](../architecture/extension-registry.md). + +Доверенный пакет Comments показывает поддерживаемый сценарий с `definePlugin({})`, файловыми маршрутами, hooks и настройками без нового файла регистрации. + `@asmblyr-collaborative/kit` — серверный/браузерный контракт расширений. `@asmblyr-collaborative/sdk` — HTTP-клиент, не доступ к Knex. `useItems(event)` всегда использует права caller; `storage.own` — привилегированное хранилище собственных таблиц, перед ним плагин обязан проверять доменный доступ. Сгенерированные Kit model handlers экспортируют входные и выходные JSON-контракты @@ -80,3 +90,20 @@ Core; новые права на данные не выдаются. Подро ## Граница доверия Доверяйте устанавливаемому пакету как коду Core: server plugin может импортировать Node-модули и обращаться к окружению, browser plugin работает в контексте админки. Для недоверенных пакетов нужна отдельная процессная/браузерная изоляция. + +Центр расширений читает только метаданные настроенных пакетов. Он не запускает сторонние пакеты, не выдаёт новые capabilities и не меняет одобрения в конфигурации проекта. Текущие настройки не поддерживают секретные поля: для них необходимо внешнее хранилище секретов и новый контракт. Диагностика отражает фактическое состояние текущего процесса, ошибки загрузки и ожидание перезапуска, но не проверяет внешние сервисы и не агрегирует состояние реплик. История операций и запусков не содержит значений настроек. Подробнее — [архитектура реестра](../architecture/extension-registry.md). + +Если Core не запускается из-за недоступного пакета, проверьте сообщение запуска, восстановите доверенный артефакт или конфигурацию и перезапустите процесс. При конфликте зависимостей сначала включите необходимые пакеты или установите совместимую доверенную версию через развёртывание. После смены желаемого состояния перезапустите Core и обновите страницу. + +## Обновление и локальная проверка + +Существующие manifest v1 и записи `asmblyr.plugins` остаются действительными. Перед запуском нового Core примените добавочные миграции: первая создаёт `asmblyr_extension_states` и `asmblyr_extension_history`, вторая разрешает события запуска с пустым actor и ID процесса. Пустая таблица состояния означает, что все настроенные пакеты по-прежнему включены. Таблицы и настройки плагинов не меняются. Откат отказывается удалять непустую историю запусков и непустые таблицы состояния или истории. + +```sh +pnpm install --frozen-lockfile +pnpm db:migrate +pnpm build +pnpm dev +``` + +Войдите с правом `plugins/read` и откройте «Настройки → Расширения». С правом `plugins/update` отключите доверенный пакет, проверьте ожидание перезапуска, перезапустите Core и убедитесь, что пакет отключён. Затем включите его и снова перезапустите Core. [SDK](../reference/sdk-guide.md) и [CLI](../reference/cli-guide.md) используют тот же API реестра. Типизированные маршруты есть в [HTTP-справочнике](../reference/http.md). diff --git a/docs/ru/index.md b/docs/ru/index.md index 82fdbb0..51936e5 100644 --- a/docs/ru/index.md +++ b/docs/ru/index.md @@ -48,6 +48,7 @@ - [Локальная разработка и проверки](development/local-development.md) - [Архитектура](development/architecture.md) - [Архитектура Collaborative Live](architecture/realtime.md) +- [Архитектура реестра расширений](architecture/extension-registry.md) - [Первое расширение](development/first-extension.md) - [Архитектура ассистента и model context](development/assistant-architecture.md) - [Опубликованные пакеты](reference/packages.md) diff --git a/docs/ru/reference/cli-guide.md b/docs/ru/reference/cli-guide.md index 63dcb06..12d4b32 100644 --- a/docs/ru/reference/cli-guide.md +++ b/docs/ru/reference/cli-guide.md @@ -34,6 +34,14 @@ pnpm exec asm schema check --offline Credentials не сохраняются в генерируемых файлах. Требуется HTTPS, кроме localhost; HTTP-редиректы отклоняются. +Команды расширений используют API реестра и требуют явный пользовательский токен +с правом `plugins/read` или `plugins/update`. Токен браузерного согласия с +`schema:read` для них не подходит. Выполните `asm extensions list`, затем +`asm extensions info ` с полученным ID. Команды +`asm extensions enable --yes` и `asm extensions disable --yes` +сохраняют желаемое состояние; для применения нужен перезапуск Core. CLI не +устанавливает, не обновляет и не удаляет npm-пакеты. + Connect создаёт asmblyr.config.json, asmblyr.schema.json и asmblyr.schema.ts, не перезаписывая существующие файлы. Schema pull обновляет подключение. --config, --schema-file и --types-file выбирают относительные пути проекта. diff --git a/docs/ru/reference/kit-lifecycle.md b/docs/ru/reference/kit-lifecycle.md index ecfeaab..f5823e5 100644 --- a/docs/ru/reference/kit-lifecycle.md +++ b/docs/ru/reference/kit-lifecycle.md @@ -120,8 +120,10 @@ Host передаёт `RecordPanelProps`: Сбой рендера изолируется от формы записи. Новой несохранённой записи вкладки не предоставляются. Активные пакеты сверяются с аутентифицированным `/extensions`. -Next при запуске/сборке создаёт статические импорты из exports включённых пакетов -и источники Tailwind для их UI. Локальная разработка читает `ui/index.ts`, production +Next при запуске/сборке создаёт дескрипторы отложенного импорта из exports настроенных +пакетов и источники Tailwind для их UI. Браузер импортирует только пакеты, включённые +в Core. Ошибка импорта не блокирует другие модули и форму хоста; отдельная страница +расширения показывает загрузку или действие повтора. Локальная разработка читает `ui/index.ts`, production и npm — собранный `./ui`. Изменение списка пакетов требует перезапуска dev-сервера; production требует пересборки UI. Изменения TSX используют обычный Fast Refresh. React — peer dependency. Для UI используйте `.ts`/`.tsx` в относительных импортах @@ -160,7 +162,9 @@ namespace HTTP-клиент, что у вкладок. `Button` и `Textarea` д Пользователь должен войти и иметь доступ к админке по её существующим правилам. Отдельные разрешения на UI-страницы пока не реализованы; API каждого плагина обязан сам проверять предметные права. Отсутствующий или отключённый плагин не отображается. -Включение пакета требует сборки/перезапуска UI и Core. +Изменение активности в реестре требует перезапуска Core и обновления открытых +вкладок браузера. Уже выполненный JavaScript невозможно выгрузить из работающей +вкладки. Добавление пакета по-прежнему требует пересборки UI и Core. Пока поддержаны фиксированные страницы без вложенных и динамических маршрутов. Страница получает также `preparedAction` и `onStateChange`. diff --git a/docs/ru/reference/routes.md b/docs/ru/reference/routes.md index 03ff5ac..dc8ddde 100644 --- a/docs/ru/reference/routes.md +++ b/docs/ru/reference/routes.md @@ -1,6 +1,6 @@ # Матрица HTTP-маршрутов -Сгенерировано из Core и проверенного каталога доступа. 247 деклараций. +Сгенерировано из Core и проверенного каталога доступа. 256 деклараций. Это описание границ; их исполнение проверяют интеграционные тесты. Динамические маршруты плагинов и внутренние endpoints oidc-provider не перечисляются отдельно. @@ -173,6 +173,15 @@ | `GET /settings/assistant` | Человек: assistant/read ИЛИ update; superuser bypass | `apps/core/src/settings/routes.ts` | | `PUT /settings/assistant` | Человек: assistant/update; superuser bypass | `apps/core/src/settings/routes.ts` | | `GET /settings/assistant/telemetry` | Человек: assistant/read ИЛИ update; superuser bypass | `apps/core/src/settings/routes.ts` | +| `GET /settings/extension-registry` | Человек: plugins/read либо update; superuser bypass; только настроенные пакеты | `apps/core/src/plugins/registry-routes.ts` | +| `GET /settings/extension-registry/:id` | Человек: plugins/read либо update; superuser bypass | `apps/core/src/plugins/registry-routes.ts` | +| `GET /settings/extension-registry/:id/dependencies` | Человек: plugins/read либо update; superuser bypass | `apps/core/src/plugins/registry-routes.ts` | +| `POST /settings/extension-registry/:id/disable` | Человек: plugins/update; superuser bypass; сохраняет желаемое состояние до перезапуска | `apps/core/src/plugins/registry-routes.ts` | +| `POST /settings/extension-registry/:id/enable` | Человек: plugins/update; superuser bypass; сохраняет желаемое состояние до перезапуска | `apps/core/src/plugins/registry-routes.ts` | +| `GET /settings/extension-registry/:id/health` | Человек: plugins/read либо update; snapshot текущего процесса | `apps/core/src/plugins/registry-routes.ts` | +| `GET /settings/extension-registry/:id/history` | Человек: plugins/read либо update; superuser bypass | `apps/core/src/plugins/registry-routes.ts` | +| `GET /settings/extension-registry/:id/permissions` | Человек: plugins/read либо update; superuser bypass | `apps/core/src/plugins/registry-routes.ts` | +| `GET /settings/extension-registry/:id/versions` | Человек: plugins/read либо update; только установленная версия | `apps/core/src/plugins/registry-routes.ts` | | `GET /settings/integrations` | Только активная человеческая сессия superuser; безопасные параметры и наличие ключей, без значений секретов | `apps/core/src/integrations/routes.ts` | | `PUT /settings/integrations/:section` | Только superuser; полный запрет изменения env-группы, revision, атомарное перешифрование при смене защиты | `apps/core/src/integrations/routes.ts` | | `POST /settings/integrations/:section/test` | Только superuser; credential rate limit, проверка соединения без сохранения настроек | `apps/core/src/integrations/routes.ts` | diff --git a/docs/ru/reference/sdk-guide.md b/docs/ru/reference/sdk-guide.md index 801ed45..e772011 100644 --- a/docs/ru/reference/sdk-guide.md +++ b/docs/ru/reference/sdk-guide.md @@ -7,6 +7,13 @@ компилятора TypeScript или загрузчика плагинов во время исполнения. Для разработки расширений используется отдельный `@asmblyr-collaborative/kit`. +`client.extensions` читает реестр доверенных расширений методами `list`, `get`, +`versions`, `dependencies`, `permissions`, `health` и `history`. Методы +`enable` и `disable` меняют желаемое состояние и возвращают `restartRequired`: +для загрузки или выгрузки серверного кода нужен перезапуск Core. Чтение требует +права раздела `plugins/read`, изменение — `plugins/update`. Установка и +обновление пакетов выполняются через доверенное развёртывание. + ## Профиль пользователя diff --git a/packages/cli/README.md b/packages/cli/README.md index 470cdf2..e9fdd0d 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -39,6 +39,13 @@ For CI, provide ASMBLYR_ACCESS_TOKEN or --token-stdin and an explicit Core API o /api root. Credentials are never written to generated files. HTTPS is required except on localhost; HTTP redirects are rejected. +The extension commands use the registry API and require an explicit human access +token with `plugins/read` or `plugins/update`. The browser's schema-only consent +token cannot use them. Run `asm extensions list`, then use the returned ID with +`asm extensions info `. `asm extensions enable --yes` and +`asm extensions disable --yes` save the desired state; restart Core to apply +it. The CLI cannot install, update or remove package artifacts. + connect creates asmblyr.config.json, asmblyr.schema.json and asmblyr.schema.ts. It preserves existing files; schema pull refreshes an existing connection. --config, --schema-file and --types-file select relative project paths. diff --git a/packages/cli/README.ru.md b/packages/cli/README.ru.md index e420587..f79e29f 100644 --- a/packages/cli/README.ru.md +++ b/packages/cli/README.ru.md @@ -38,6 +38,14 @@ pnpm exec asm schema check --offline Credentials не сохраняются в генерируемых файлах. Требуется HTTPS, кроме localhost; HTTP-редиректы отклоняются. +Команды расширений используют API реестра и требуют явный пользовательский токен +с правом `plugins/read` или `plugins/update`. Токен браузерного согласия с +`schema:read` для них не подходит. Выполните `asm extensions list`, затем +`asm extensions info ` с полученным ID. Команды +`asm extensions enable --yes` и `asm extensions disable --yes` +сохраняют желаемое состояние; для применения нужен перезапуск Core. CLI не +устанавливает, не обновляет и не удаляет npm-пакеты. + Connect создаёт asmblyr.config.json, asmblyr.schema.json и asmblyr.schema.ts, не перезаписывая существующие файлы. Schema pull обновляет подключение. --config, --schema-file и --types-file выбирают относительные пути проекта. diff --git a/packages/cli/src/arguments.mjs b/packages/cli/src/arguments.mjs index 7387c05..4ff8092 100644 --- a/packages/cli/src/arguments.mjs +++ b/packages/cli/src/arguments.mjs @@ -1,6 +1,6 @@ /** * @param {string[]} args - * @returns {{command: "help" | "connect" | "schema pull" | "schema check" | "schema generate", flags: Record}} + * @returns {{command: "help" | "connect" | "schema pull" | "schema check" | "schema generate" | "extensions list" | "extensions info" | "extensions enable" | "extensions disable", flags: Record, extensionId?: string}} */ export function parseArguments(args) { const flags = {}; @@ -21,7 +21,9 @@ export function parseArguments(args) { throw new Error(`Missing value for --${key}`); } flags[key] = args[++i]; - } else if (["token-stdin", "offline", "no-browser", "help"].includes(key)) { + } else if ( + ["token-stdin", "offline", "no-browser", "help", "yes"].includes(key) + ) { flags[key] = true; } else { throw new Error( @@ -30,16 +32,31 @@ export function parseArguments(args) { } } const input = words.join(" "); - const command = input === "generate" ? "schema generate" : input; + const extensionCommand = + words[0] === "extensions" && + ["info", "enable", "disable"].includes(words[1]) && + words.length === 3; + const extensionId = extensionCommand ? words[2] : undefined; + const selected = extensionCommand ? `extensions ${words[1]}` : input; + const command = selected === "generate" ? "schema generate" : selected; if (flags.help || !command) { return { command: "help", flags }; } if ( - !["connect", "schema pull", "schema check", "schema generate"].includes( - command, - ) + ![ + "connect", + "schema pull", + "schema check", + "schema generate", + "extensions list", + "extensions info", + "extensions enable", + "extensions disable", + ].includes(command) ) { - throw new Error("Use connect, generate, schema pull, or schema check"); + throw new Error( + "Use connect, generate, schema pull/check, or extensions list/info/enable/disable", + ); } const allowed = command === "connect" @@ -53,14 +70,23 @@ export function parseArguments(args) { ] : command === "schema generate" ? ["config"] - : [ - "config", - "token-stdin", - "no-browser", - ...(command === "schema check" ? ["offline"] : []), - ]; + : command.startsWith("extensions ") + ? [ + "config", + "token-stdin", + ...(command === "extensions enable" || + command === "extensions disable" + ? ["yes"] + : []), + ] + : [ + "config", + "token-stdin", + "no-browser", + ...(command === "schema check" ? ["offline"] : []), + ]; if (Object.keys(flags).some((key) => !allowed.includes(key))) { throw new Error("Flag is not supported for this command"); } - return { command, flags }; + return { command, flags, ...(extensionId ? { extensionId } : {}) }; } diff --git a/packages/cli/src/main.mjs b/packages/cli/src/main.mjs old mode 100644 new mode 100755 diff --git a/packages/cli/src/run.mjs b/packages/cli/src/run.mjs index 76b6dda..fd239a0 100644 --- a/packages/cli/src/run.mjs +++ b/packages/cli/src/run.mjs @@ -14,7 +14,12 @@ asm schema pull [--config asmblyr.config.json] asm generate [--config asmblyr.config.json] asm schema generate [--config asmblyr.config.json] asm schema check [--offline] [--config asmblyr.config.json] +asm extensions list [--config asmblyr.config.json] +asm extensions info [--config asmblyr.config.json] +asm extensions enable --yes [--config asmblyr.config.json] +asm extensions disable --yes [--config asmblyr.config.json] Credentials: browser sign-in by default; ASMBLYR_ACCESS_TOKEN or --token-stdin for CI. +Extension commands require an existing human token with plugins permissions; browser schema-only login is insufficient. --no-browser prints the sign-in URL. Credentials are never saved. Requires Node.js 22+. connect verifies access, then writes a credential-free config, schema and TypeScript types. generate rebuilds TypeScript from the saved snapshot, without network or credentials. @@ -38,6 +43,29 @@ function verified(input) { } return snapshot; } + +async function suppliedToken(flags, env, stdin) { + let token = env.ASMBLYR_ACCESS_TOKEN; + if (flags["token-stdin"]) { + const chunks = []; + let length = 0; + for await (const chunk of stdin) { + length += chunk.length; + if (length > 16384) { + throw new Error("Credential input is too large"); + } + chunks.push(chunk); + } + token = Buffer.concat(chunks.map((chunk) => Buffer.from(chunk))) + .toString("utf8") + .trim(); + } + if (token && (/\s/.test(token) || token.length > 16384)) { + throw new Error("Set ASMBLYR_ACCESS_TOKEN or provide --token-stdin"); + } + return token; +} + export async function run( args, { @@ -50,7 +78,7 @@ export async function run( openBrowser, } = {}, ) { - const { command, flags } = parseArguments(args); + const { command, flags, extensionId } = parseArguments(args); if (command === "help") { out(help); return 0; @@ -93,6 +121,52 @@ export async function run( } else { config = await readConfig(cwd, file); } + if (command.startsWith("extensions ")) { + const token = await suppliedToken(flags, env, stdin); + if (!token) { + throw new Error( + "Extension commands require ASMBLYR_ACCESS_TOKEN or --token-stdin with plugins permissions", + ); + } + if ( + (command === "extensions enable" || command === "extensions disable") && + !flags.yes + ) { + throw new Error( + "Changing extension activation requires --yes; Core restart is required", + ); + } + const extensions = createClient({ + baseUrl: config.url, + accessToken: token, + fetch, + timeoutMs: 15000, + }).extensions; + let result; + if (command === "extensions list") { + const data = []; + let page = 1; + let total = 0; + do { + const chunk = await extensions.list({ limit: 100, page }); + data.push(...chunk.data); + total = chunk.total; + page += 1; + if (!chunk.data.length) { + break; + } + } while (data.length < total); + result = { data, total }; + } else if (command === "extensions info") { + result = await extensions.get(extensionId); + } else if (command === "extensions enable") { + result = await extensions.enable(extensionId); + } else { + result = await extensions.disable(extensionId); + } + out(JSON.stringify(result, null, 2)); + return 0; + } if (command === "schema generate") { const saved = verified( JSON.parse(await readFile(outputPath(cwd, config.schemaFile), "utf8")), @@ -103,21 +177,7 @@ export async function run( } let current; if (!flags.offline) { - let token = env.ASMBLYR_ACCESS_TOKEN; - if (flags["token-stdin"]) { - const chunks = []; - let length = 0; - for await (const chunk of stdin) { - length += chunk.length; - if (length > 16384) { - throw new Error("Credential input is too large"); - } - chunks.push(chunk); - } - token = Buffer.concat(chunks.map((chunk) => Buffer.from(chunk))) - .toString("utf8") - .trim(); - } + let token = await suppliedToken(flags, env, stdin); if (!token && !flags["token-stdin"]) { const connection = await login(config.url, { fetch, diff --git a/packages/cli/test/cli.test.mjs b/packages/cli/test/cli.test.mjs index bfb90ae..452c74c 100644 --- a/packages/cli/test/cli.test.mjs +++ b/packages/cli/test/cli.test.mjs @@ -187,3 +187,46 @@ test("stdin credentials, remote schema changes and HTTP errors stay scoped and r }, ); }); + +test("extension CLI uses the registry API and requires explicit mutation confirmation", async (t) => { + const cwd = await mkdtemp(path.join(os.tmpdir(), "asmblyr-cli-")); + t.after(() => rm(cwd, { recursive: true, force: true })); + await writeFile( + path.join(cwd, "asmblyr.config.json"), + JSON.stringify({ + version: 1, + url: "https://example.test/api", + schemaFile: "asmblyr.schema.json", + typesFile: "asmblyr.schema.ts", + }), + ); + const calls = []; + const output = []; + const options = { + cwd, + env: { ASMBLYR_ACCESS_TOKEN: "human-token" }, + out: (value) => output.push(value), + fetch: async (url, init) => { + calls.push([url, init.method]); + assert.equal(init.headers.get("authorization"), "Bearer human-token"); + return Response.json({ data: [], page: 1, limit: 100, total: 0 }); + }, + }; + assert.equal(await run(["extensions", "list"], options), 0); + await assert.rejects(run(["extensions", "disable", "abc"], options), /--yes/); + assert.equal( + await run(["extensions", "disable", "abc", "--yes"], options), + 0, + ); + assert.deepEqual(calls, [ + [ + "https://example.test/api/settings/extension-registry?limit=100&page=1", + "GET", + ], + [ + "https://example.test/api/settings/extension-registry/abc/disable", + "POST", + ], + ]); + assert.ok(output.every((value) => !value.includes("human-token"))); +}); diff --git a/packages/contracts/locales/ui.en.json b/packages/contracts/locales/ui.en.json index ca7b39f..c233b61 100644 --- a/packages/contracts/locales/ui.en.json +++ b/packages/contracts/locales/ui.en.json @@ -2222,5 +2222,59 @@ "copy.15a3156b": "Add {{count}} files", "copy.6bcdb067": "{{count}} file total", "copy.0bd2e4f5": "{{count}} files total", - "copy.ec09c48b": "{{count}} files total" + "copy.ec09c48b": "{{count}} files total", + "copy.06da4077": "This process keeps its current state until Core restarts.", + "copy.09543e4b": "A trusted deployment process installs and updates packages.", + "copy.09b2603f": "Extension code is not loaded in this process.", + "copy.119c7544": "Permissions", + "copy.127087f1": "Desired", + "copy.15012614": "Operation unavailable", + "copy.15d6c625": "Optional", + "copy.1f6b5169": "All statuses", + "copy.21311e3a": "Active", + "copy.21c1c98a": "Extensions that depend on this one", + "copy.223d31dd": "On this Core", + "copy.2b0663b2": "No extensions found.", + "copy.2c76c313": "Change saved. Restart Core.", + "copy.2ed3644c": "Category", + "copy.35bb7665": "Open extension {{value0}}", + "copy.3635d9fc": "No configured extensions.", + "copy.4294daf2": "Loading extension interface…", + "copy.4742d170": "Restart required", + "copy.4a7602d9": "State saved.", + "copy.4fffd48e": "Operation history", + "copy.5200a277": "State and diagnostics", + "copy.62556d50": "Inactive", + "copy.631ce03a": "Sort", + "copy.67f18639": "Could not load the extension interface.", + "copy.682507e9": "Search extensions", + "copy.69a202cd": "Installed trusted packages, their state, dependencies, and permissions.", + "copy.6b312f47": "Extension code is loaded in this process.", + "copy.70aced92": "Issues", + "copy.76bcd4b4": "Enable this extension after Core restarts?", + "copy.786d0916": "Capabilities are approved in the project configuration before Core starts.", + "copy.7c1ce28c": "Startup failed", + "copy.7ccf32b1": "No external version catalog is connected.", + "copy.8477d9ad": "All categories", + "copy.856e5952": "Publisher", + "copy.91694594": "Could not change the extension state", + "copy.9d31a7a5": "By name", + "copy.a07a2904": "No description provided.", + "copy.a2a8e49a": "Available version", + "copy.a558d01d": "Configuration", + "copy.a66f9bbe": "By version", + "copy.acd63063": "Extension Center", + "copy.b12c3bab": "Settings are unavailable for this extension.", + "copy.d0995079": "Change saved. Restart Core and refresh the page.", + "copy.d8d8fc00": "Disable this extension after Core restarts?", + "copy.da1e6694": "Catalog not connected", + "copy.e661ef1a": "Version", + "copy.e8f54db9": "No operations yet.", + "copy.eae3cc97": "Versions and compatibility", + "copy.f1ed405c": "Dependencies", + "copy.f6cc039a": "Installed version", + "copy.f94e2c5c": "No required dependencies.", + "copy.fd8c73cd": "Enable", + "copy.fdee34b0": "In this process", + "copy.ff5a4df2": "By status" } diff --git a/packages/contracts/locales/ui.ru.json b/packages/contracts/locales/ui.ru.json index 665f192..b74c6ea 100644 --- a/packages/contracts/locales/ui.ru.json +++ b/packages/contracts/locales/ui.ru.json @@ -2222,5 +2222,59 @@ "copy.15a3156b": "Добавить {{count}} файлов", "copy.6bcdb067": "Всего {{count}} файл", "copy.0bd2e4f5": "Всего {{count}} файла", - "copy.ec09c48b": "Всего {{count}} файлов" + "copy.ec09c48b": "Всего {{count}} файлов", + "copy.06da4077": "Текущий процесс продолжает прежний режим до перезапуска Core.", + "copy.09543e4b": "Установка и обновление пакета выполняются доверенным процессом развёртывания.", + "copy.09b2603f": "Код расширения не загружен в текущем процессе.", + "copy.119c7544": "Разрешения", + "copy.127087f1": "Требуется", + "copy.15012614": "Операция недоступна", + "copy.15d6c625": "Необязательные", + "copy.1f6b5169": "Все статусы", + "copy.21311e3a": "Активно", + "copy.21c1c98a": "Зависят от этого расширения", + "copy.223d31dd": "В этом Core", + "copy.2b0663b2": "Расширения не найдены.", + "copy.2c76c313": "Изменение сохранено. Перезапустите Core.", + "copy.2ed3644c": "Категория", + "copy.35bb7665": "Открыть расширение {{value0}}", + "copy.3635d9fc": "Нет настроенных расширений.", + "copy.4294daf2": "Загрузка интерфейса расширения…", + "copy.4742d170": "Нужен перезапуск", + "copy.4a7602d9": "Состояние сохранено.", + "copy.4fffd48e": "История операций", + "copy.5200a277": "Состояние и диагностика", + "copy.62556d50": "Неактивно", + "copy.631ce03a": "Сортировка", + "copy.67f18639": "Не удалось загрузить интерфейс расширения.", + "copy.682507e9": "Поиск расширений", + "copy.69a202cd": "Установленные доверенные пакеты, их состояние, зависимости и разрешения.", + "copy.6b312f47": "Код расширения загружен в текущем процессе.", + "copy.70aced92": "Проблемы", + "copy.76bcd4b4": "Включить расширение после перезапуска Core?", + "copy.786d0916": "Разрешения утверждаются в конфигурации проекта до запуска Core.", + "copy.7c1ce28c": "Ошибка запуска", + "copy.7ccf32b1": "Внешний каталог версий не подключён.", + "copy.8477d9ad": "Все категории", + "copy.856e5952": "Издатель", + "copy.91694594": "Не удалось изменить состояние расширения", + "copy.9d31a7a5": "По названию", + "copy.a07a2904": "Описание не указано.", + "copy.a2a8e49a": "Доступна версия", + "copy.a558d01d": "Конфигурация", + "copy.a66f9bbe": "По версии", + "copy.acd63063": "Центр расширений", + "copy.b12c3bab": "Настройки недоступны для этого расширения.", + "copy.d0995079": "Изменение сохранено. Перезапустите Core и обновите страницу.", + "copy.d8d8fc00": "Отключить расширение после перезапуска Core?", + "copy.da1e6694": "Каталог не подключён", + "copy.e661ef1a": "Версия", + "copy.e8f54db9": "Операций пока нет.", + "copy.eae3cc97": "Версии и совместимость", + "copy.f1ed405c": "Зависимости", + "copy.f6cc039a": "Установлена версия", + "copy.f94e2c5c": "Обязательных зависимостей нет.", + "copy.fd8c73cd": "Включить", + "copy.fdee34b0": "В процессе", + "copy.ff5a4df2": "По статусу" } diff --git a/packages/contracts/src/extension-registry.d.ts b/packages/contracts/src/extension-registry.d.ts new file mode 100644 index 0000000..b84c9c8 --- /dev/null +++ b/packages/contracts/src/extension-registry.d.ts @@ -0,0 +1,57 @@ +export interface ExtensionEntry { + id: string; + packageName: string; + namespace: string | null; + title: string; + description: string | null; + category: string | null; + publisher: { id: string; name: string } | null; + version: string; + latestAvailableVersion: string | null; + manifestVersion: 1; + status: + | "healthy" + | "disabled" + | "restart_required" + | "incompatible" + | "failed"; + /** The state requested in PostgreSQL. */ + desiredState: "enabled" | "disabled"; + /** State in the Core process that served this response. */ + actualState: "enabled" | "disabled" | "failed"; + instanceId: string; + pendingRestart: boolean; + lastError: string | null; + loaded: boolean; + desiredEnabled: boolean; + restartRequired: boolean; + compatible: boolean; + issues: string[]; + capabilities: string[]; + dependencies: Record; + optionalDependencies: Record; + compatibility: { + collaborative: string; + node?: string; + } | null; + actions: { enable: boolean; disable: boolean; configure: boolean }; +} + +export interface ExtensionHistoryEntry { + id: string; + package_name: string; + action: "enable" | "disable" | "startup" | "validation"; + result: "restart_required" | "blocked" | "succeeded" | "failed"; + error_code: string | null; + error_message: string | null; + actor_id: string | null; + instance_id: string | null; + created_at: string; +} + +export interface ExtensionListResult { + data: ExtensionEntry[]; + page: number; + limit: number; + total: number; +} diff --git a/packages/contracts/src/index.d.ts b/packages/contracts/src/index.d.ts index 317b56d..c14d1ae 100644 --- a/packages/contracts/src/index.d.ts +++ b/packages/contracts/src/index.d.ts @@ -65,6 +65,11 @@ export type { UserProfileExtensionResult, } from "./users.js"; export type { ApiErrorBody } from "./api-error.js"; +export type { + ExtensionEntry, + ExtensionHistoryEntry, + ExtensionListResult, +} from "./extension-registry.js"; export type { UserFieldSelfAccess, UserFormConfiguration, diff --git a/packages/kit/LIFECYCLE.md b/packages/kit/LIFECYCLE.md index d4936b7..2557dff 100644 --- a/packages/kit/LIFECYCLE.md +++ b/packages/kit/LIFECYCLE.md @@ -86,7 +86,7 @@ Import shadcn directly from kit/ui/<component>. Admin/plugins share full p Panels mount on first open and persist across tab changes. Render failures are isolated from the record form. New unsaved records have no panels. Active packages are checked through authenticated /extensions. -Next generates static imports and Tailwind sources from enabled exports at startup/build. Development reads ui/index.ts; production/npm use built ./ui. Package-list changes require dev restart or production UI rebuild; TSX uses Fast Refresh. React is a peer dependency. Use .ts/.tsx relative imports with rewriteRelativeImportExtensions:true, and include ui/**/\* and shared/**/\*. +Next generates deferred import descriptors and Tailwind sources from configured exports at startup/build. The browser imports only packages enabled by Core. An import failure is isolated from other modules and the host form; standalone plugin pages show a loading state or a retry action. Development reads ui/index.ts; production/npm use built ./ui. Package-list changes require dev restart or production UI rebuild; TSX uses Fast Refresh. React is a peer dependency. Use .ts/.tsx relative imports with rewriteRelativeImportExtensions:true, and include ui/**/\* and shared/**/\*. Packages/plugin-comments is the complete example. Server packages must never enter UI imports. @@ -109,7 +109,7 @@ Routes use /extensions/<namespace>/<id>. The host adds pages under A PluginPageProps.request is the same namespace-limited HTTP client. Shared Button/Textarea come from Kit. The shell isolates render errors. Authenticated /extensions supplies enabled packages to layout, menu, pages, and panels. -Users must satisfy existing admin admission rules. Separate page permissions are unsupported; each plugin API enforces its domain rights. Missing/disabled plugins are hidden. Enabling requires rebuilding/restarting UI and Core. +Users must satisfy existing admin admission rules. Separate page permissions are unsupported; each plugin API enforces its domain rights. Missing/disabled plugins are hidden. Changing registry activation requires restarting Core and refreshing existing browser tabs. Already executed JavaScript cannot be unloaded from a running tab. Adding packages still requires rebuilding UI and Core. Only fixed pages are supported, without nested/dynamic routes. Pages also receive preparedAction/onStateChange. UseAction(contract, props, { path, initialInput }) connects form schema, ordinary requests, prepared results, and dirty/busy host state. Overview reads only the current caller's context. See [field editors](FIELDS.md). diff --git a/packages/kit/LIFECYCLE.ru.md b/packages/kit/LIFECYCLE.ru.md index 79b716f..6110153 100644 --- a/packages/kit/LIFECYCLE.ru.md +++ b/packages/kit/LIFECYCLE.ru.md @@ -124,8 +124,10 @@ Host передаёт `RecordPanelProps`: Сбой рендера изолируется от формы записи. Новой несохранённой записи вкладки не предоставляются. Активные пакеты сверяются с аутентифицированным `/extensions`. -Next при запуске/сборке создаёт статические импорты из exports включённых пакетов -и источники Tailwind для их UI. Локальная разработка читает `ui/index.ts`, production +Next при запуске/сборке создаёт дескрипторы отложенного импорта из exports настроенных +пакетов и источники Tailwind для их UI. Браузер импортирует только пакеты, включённые +в Core. Ошибка импорта не блокирует другие модули и форму хоста; отдельная страница +расширения показывает загрузку или действие повтора. Локальная разработка читает `ui/index.ts`, production и npm — собранный `./ui`. Изменение списка пакетов требует перезапуска dev-сервера; production требует пересборки UI. Изменения TSX используют обычный Fast Refresh. React — peer dependency. Для UI используйте `.ts`/`.tsx` в относительных импортах @@ -164,7 +166,9 @@ namespace HTTP-клиент, что у вкладок. `Button` и `Textarea` д Пользователь должен войти и иметь доступ к админке по её существующим правилам. Отдельные разрешения на UI-страницы пока не реализованы; API каждого плагина обязан сам проверять предметные права. Отсутствующий или отключённый плагин не отображается. -Включение пакета требует сборки/перезапуска UI и Core. +Изменение активности в реестре требует перезапуска Core и обновления открытых +вкладок браузера. Уже выполненный JavaScript невозможно выгрузить из работающей +вкладки. Добавление пакета по-прежнему требует пересборки UI и Core. Пока поддержаны фиксированные страницы без вложенных и динамических маршрутов. Страница получает также `preparedAction` и `onStateChange`. diff --git a/packages/kit/bin/plugin.mjs b/packages/kit/bin/plugin.mjs old mode 100644 new mode 100755 diff --git a/packages/kit/src/node/ui-registry.ts b/packages/kit/src/node/ui-registry.ts index 3d8f3c3..ac0eba8 100644 --- a/packages/kit/src/node/ui-registry.ts +++ b/packages/kit/src/node/ui-registry.ts @@ -5,7 +5,7 @@ import { fileURLToPath } from "node:url"; import { parsePluginNamespace } from "./collections.js"; import { isLocalPluginPackage } from "./local-package.js"; -/** Generate static imports without ever executing server plugin entry points. */ +/** Discover browser modules without importing their executable entry points. */ export async function generateUiRegistry( projectPackage: URL, output: string, @@ -25,10 +25,9 @@ export async function generateUiRegistry( throw new Error("Invalid configured UI plugins"); const resolve = createRequire(projectPackage).resolve; const projectDirectory = path.dirname(fileURLToPath(projectPackage)); - const imports: string[] = []; const entries: string[] = []; const sources: string[] = []; - for (const [index, name] of names.entries()) { + for (const name of names) { const metadataPath = await realpath(resolve(`${name}/package.json`)); const metadata = JSON.parse(await readFile(metadataPath, "utf8")); if (!metadata.exports?.["./ui"]) continue; @@ -49,11 +48,8 @@ export async function generateUiRegistry( .relative(path.dirname(output), entry) .replaceAll("\\", "/") .replace(/\.tsx?$/, ""); - imports.push( - `import plugin${index} from ${JSON.stringify(importPath.startsWith(".") ? importPath : `./${importPath}`)};`, - ); entries.push( - `{ packageName: ${JSON.stringify(name)}, namespace: ${JSON.stringify(namespace)}, definition: plugin${index} }`, + `{ packageName: ${JSON.stringify(name)}, namespace: ${JSON.stringify(namespace)}, load: () => import(${JSON.stringify(importPath.startsWith(".") ? importPath : `./${importPath}`)}) }`, ); const cssPath = path .relative(path.dirname(output), path.dirname(entry)) @@ -62,7 +58,7 @@ export async function generateUiRegistry( `@source ${JSON.stringify(cssPath.startsWith(".") ? cssPath : `./${cssPath}`)};`, ); } - const content = `// Generated from enabled package exports. Do not edit.\n"use client";\nimport type { UiPluginDefinition } from "@asmblyr-collaborative/kit/ui";\n${imports.join("\n")}\nexport const uiPlugins: { packageName: string; namespace: string; definition: UiPluginDefinition }[] = [${entries.join(",\n")}];\n`; + const content = `// Generated from configured package exports. Activation is checked before load.\n"use client";\nimport type { UiPluginDefinition } from "@asmblyr-collaborative/kit/ui";\nexport const uiPluginModules: { packageName: string; namespace: string; load: () => Promise<{ default: UiPluginDefinition }> }[] = [${entries.join(",\n")}];\n`; await mkdir(path.dirname(output), { recursive: true }); const previous = await readFile(output, "utf8").catch(() => ""); if (previous !== content) await writeFile(output, content); diff --git a/packages/sdk/README.md b/packages/sdk/README.md index d578037..93ab7a1 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -8,6 +8,8 @@ Typed Collaborative HTTP client for browsers and Node.js 22+. Every operation calls the main API. Runtime dependencies do not include H3, Knex, PostgreSQL, the TypeScript compiler, or a plugin loader. Use the separate @asmblyr-collaborative/kit to develop extensions. +`client.extensions` reads the trusted extension registry with `list`, `get`, `versions`, `dependencies`, `permissions`, `health`, and `history`. `enable` and `disable` change desired activation and return `restartRequired`; Core must restart before server code is loaded or unloaded. The methods require a human `plugins` settings grant (`read` for inspection, `update` for changes). Package installation and updates remain deployment operations. + ## User profile diff --git a/packages/sdk/README.ru.md b/packages/sdk/README.ru.md index f446dd9..37e10c3 100644 --- a/packages/sdk/README.ru.md +++ b/packages/sdk/README.ru.md @@ -11,6 +11,13 @@ компилятора TypeScript или загрузчика плагинов во время исполнения. Для разработки расширений используется отдельный `@asmblyr-collaborative/kit`. +`client.extensions` читает реестр доверенных расширений методами `list`, `get`, +`versions`, `dependencies`, `permissions`, `health` и `history`. Методы +`enable` и `disable` меняют желаемое состояние и возвращают `restartRequired`: +для загрузки или выгрузки серверного кода нужен перезапуск Core. Чтение требует +права раздела `plugins/read`, изменение — `plugins/update`. Установка и +обновление пакетов выполняются через доверенное развёртывание. + ## Профиль пользователя diff --git a/packages/sdk/src/client.ts b/packages/sdk/src/client.ts index 96e9533..57ab5a1 100644 --- a/packages/sdk/src/client.ts +++ b/packages/sdk/src/client.ts @@ -20,10 +20,12 @@ import type { ClientSchema, FieldDefinitions } from "./query/definition.js"; import { createFluentClient, type FluentClient } from "./query/client.js"; import { createPluginsClient, type PluginsClient } from "./plugins.js"; import { createUsersClient, type UsersClient } from "./users.js"; +import { createExtensionsClient, type ExtensionsClient } from "./extensions.js"; export interface AsmblyrClient { readonly items: ItemsClient; readonly users: UsersClient; + readonly extensions: ExtensionsClient; readonly presence: PresenceClient; readonly realtime: ReturnType; readonly notifications: NotificationsClient; @@ -71,6 +73,7 @@ export function createClient( : {}), items, users: createUsersClient(transport), + extensions: createExtensionsClient(transport), presence: createPresenceClient(transport), realtime: createRealtimeClient(options, transport), notifications: createNotificationsClient(transport), diff --git a/packages/sdk/src/extensions.ts b/packages/sdk/src/extensions.ts new file mode 100644 index 0000000..98e6517 --- /dev/null +++ b/packages/sdk/src/extensions.ts @@ -0,0 +1,101 @@ +import type { + ExtensionEntry, + ExtensionHistoryEntry, + ExtensionListResult, +} from "@asmblyr-collaborative/contracts"; +import type { RequestOptions } from "./options.js"; +import type { Transport } from "./transport.js"; + +export interface ExtensionListOptions { + page?: number; + limit?: number; + search?: string; + status?: ExtensionEntry["status"]; + category?: string; + sort?: "title" | "version" | "status"; +} + +export interface ExtensionsClient { + list( + options?: ExtensionListOptions, + request?: RequestOptions, + ): Promise; + get(id: string, request?: RequestOptions): Promise<{ data: ExtensionEntry }>; + versions( + id: string, + request?: RequestOptions, + ): Promise<{ + data: { version: string; source: "project-package"; installed: true }[]; + }>; + dependencies( + id: string, + request?: RequestOptions, + ): Promise<{ + data: { + required: Record; + optional: Record; + }; + }>; + permissions( + id: string, + request?: RequestOptions, + ): Promise<{ data: { declared: string[]; approvalIssue: string | null } }>; + health( + id: string, + request?: RequestOptions, + ): Promise<{ + data: { + status: ExtensionEntry["status"]; + issues: string[]; + restartRequired: boolean; + desiredState: ExtensionEntry["desiredState"]; + actualState: ExtensionEntry["actualState"]; + pendingRestart: boolean; + lastError: string | null; + instanceId: string; + }; + }>; + history( + id: string, + request?: RequestOptions, + ): Promise<{ data: ExtensionHistoryEntry[] }>; + enable( + id: string, + request?: RequestOptions, + ): Promise<{ data: ExtensionEntry & { changed: boolean } }>; + disable( + id: string, + request?: RequestOptions, + ): Promise<{ data: ExtensionEntry & { changed: boolean } }>; +} + +export function createExtensionsClient(transport: Transport): ExtensionsClient { + const base = "/settings/extension-registry"; + const path = (id: string) => `${base}/${encodeURIComponent(id)}`; + return { + list(options = {}, request) { + const query = new URLSearchParams(); + for (const [key, value] of Object.entries(options)) { + if (value !== undefined) { + query.set(key, String(value)); + } + } + return transport.get(base, query, request); + }, + get: (id, request) => transport.get(path(id), undefined, request), + versions: (id, request) => + transport.get(`${path(id)}/versions`, undefined, request), + dependencies: (id, request) => + transport.get(`${path(id)}/dependencies`, undefined, request), + permissions: (id, request) => + transport.get(`${path(id)}/permissions`, undefined, request), + health: (id, request) => + transport.get(`${path(id)}/health`, undefined, request), + history: (id, request) => + transport.get(`${path(id)}/history`, undefined, request), + enable: (id, request) => + transport.write("POST", `${path(id)}/enable`, undefined, request), + disable: (id, request) => + transport.write("POST", `${path(id)}/disable`, undefined, request), + }; +} diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index d39bc19..aa6f023 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -3,6 +3,7 @@ export type { RealtimeConnection, RealtimeLock } from "./realtime.js"; export type { UsersClient } from "./users.js"; export { ApiError } from "./error.js"; export type { PluginsClient, PluginPaths } from "./plugins.js"; +export type { ExtensionsClient, ExtensionListOptions } from "./extensions.js"; export { defineSchema, type ClientSchema, diff --git a/packages/sdk/test/extensions.test.mjs b/packages/sdk/test/extensions.test.mjs new file mode 100644 index 0000000..f21fe84 --- /dev/null +++ b/packages/sdk/test/extensions.test.mjs @@ -0,0 +1,36 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createClient } from "../dist/index.js"; + +test("extension client uses registry paths and preserves permission failures", async () => { + const seen = []; + const client = createClient({ + baseUrl: "https://example.test/api", + accessToken: "admin-token", + fetch: async (url, init) => { + seen.push([url, init.method]); + assert.equal(init.headers.get("authorization"), "Bearer admin-token"); + if (init.method === "POST") { + return Response.json( + { code: "ACCESS_DENIED", message: "Denied" }, + { status: 403 }, + ); + } + return Response.json({ data: [], page: 1, limit: 20, total: 0 }); + }, + }); + assert.equal((await client.extensions.list({ status: "disabled" })).total, 0); + await assert.rejects(client.extensions.enable("@example/plugin"), { + status: 403, + }); + assert.deepEqual(seen, [ + [ + "https://example.test/api/settings/extension-registry?status=disabled", + "GET", + ], + [ + "https://example.test/api/settings/extension-registry/%40example%2Fplugin/enable", + "POST", + ], + ]); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 88a1e32..70623ef 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -105,6 +105,9 @@ importers: sanitize-html: specifier: ^2.17.7 version: 2.17.7 + semver: + specifier: ^7.8.5 + version: 7.8.5 devDependencies: '@asmblyr-collaborative/sdk': specifier: workspace:* @@ -121,6 +124,9 @@ importers: '@types/sanitize-html': specifier: ^2.16.1 version: 2.16.1 + '@types/semver': + specifier: ^7.8.0 + version: 7.8.0 eslint: specifier: ^9.39.5 version: 9.39.5(jiti@2.7.0) @@ -2938,6 +2944,9 @@ packages: '@types/sanitize-html@2.16.1': resolution: {integrity: sha512-n9wjs8bCOTyN/ynwD8s/nTcTreIHB1vf31vhLMGqUPNHaweKC4/fAl4Dj+hUlCTKYgm4P3k83fmiFfzkZ6sgMA==} + '@types/semver@7.8.0': + resolution: {integrity: sha512-1mAINjtQCXXeLkJ9ehXkwOcBpqtLxiVtKhpUf83DdRNdQKV0iXZpaHYqRr7nj+wvxuJzoAmAwXI+sCNMv1CzLQ==} + '@types/send@1.2.1': resolution: {integrity: sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==} @@ -9081,6 +9090,8 @@ snapshots: dependencies: htmlparser2: 10.1.0 + '@types/semver@7.8.0': {} + '@types/send@1.2.1': dependencies: '@types/node': 20.19.43 diff --git a/scripts/docs/extension-registry-contract.mjs b/scripts/docs/extension-registry-contract.mjs new file mode 100644 index 0000000..40f0056 --- /dev/null +++ b/scripts/docs/extension-registry-contract.mjs @@ -0,0 +1,171 @@ +const string = { type: "string" }; +const boolean = { type: "boolean" }; +const nullableString = { type: ["string", "null"] }; +const object = (properties) => ({ + type: "object", + additionalProperties: false, + properties, + required: Object.keys(properties), +}); +const stringMap = { type: "object", additionalProperties: string }; +const strings = { type: "array", items: string }; +const response = (data) => ({ + description: "Успех; private, no-store", + content: { "application/json": { schema: object({ data }) } }, +}); +const error = { description: "Ошибка авторизации, проверки или конфликта" }; +const id = { + name: "id", + in: "path", + required: true, + schema: string, + description: "Base64url идентификатор имени пакета", +}; +const query = (name, schema) => ({ name, in: "query", schema }); +const entry = object({ + id: string, + packageName: string, + namespace: nullableString, + title: string, + description: nullableString, + category: nullableString, + publisher: { + anyOf: [object({ id: string, name: string }), { type: "null" }], + }, + version: string, + latestAvailableVersion: nullableString, + manifestVersion: { const: 1 }, + status: { + enum: ["healthy", "disabled", "restart_required", "incompatible", "failed"], + }, + desiredState: { enum: ["enabled", "disabled"] }, + actualState: { enum: ["enabled", "disabled", "failed"] }, + instanceId: string, + pendingRestart: boolean, + lastError: nullableString, + loaded: boolean, + desiredEnabled: boolean, + restartRequired: boolean, + compatible: boolean, + issues: strings, + capabilities: strings, + dependencies: stringMap, + optionalDependencies: stringMap, + compatibility: { + anyOf: [ + { + type: "object", + additionalProperties: false, + required: ["collaborative"], + properties: { collaborative: string, node: string }, + }, + { type: "null" }, + ], + }, + actions: object({ enable: boolean, disable: boolean, configure: boolean }), +}); + +export function extensionRegistryContract(key) { + const prefix = "/settings/extension-registry"; + if (key === `GET ${prefix}`) { + return { + parameters: [ + query("page", { type: "integer", minimum: 1, default: 1 }), + query("limit", { + type: "integer", + minimum: 1, + maximum: 100, + default: 20, + }), + query("search", { type: "string", maxLength: 120 }), + query("status", string), + query("category", string), + query("sort", { enum: ["title", "version", "status"] }), + ], + responses: { + 200: { + description: "Успех; private, no-store", + content: { + "application/json": { + schema: object({ + data: { type: "array", items: entry }, + page: { type: "integer" }, + limit: { type: "integer" }, + total: { type: "integer" }, + }), + }, + }, + }, + default: error, + }, + }; + } + if ( + !key.startsWith(`GET ${prefix}/:id`) && + !key.startsWith(`POST ${prefix}/:id`) + ) { + return null; + } + let data; + if (key === `GET ${prefix}/:id`) { + data = entry; + } else if (key === `GET ${prefix}/:id/versions`) { + data = { + type: "array", + items: object({ + version: string, + source: { const: "project-package" }, + installed: boolean, + }), + }; + } else if (key === `GET ${prefix}/:id/dependencies`) { + data = object({ required: stringMap, optional: stringMap }); + } else if (key === `GET ${prefix}/:id/permissions`) { + data = object({ declared: strings, approvalIssue: nullableString }); + } else if (key === `GET ${prefix}/:id/health`) { + data = object({ + status: string, + issues: strings, + restartRequired: boolean, + desiredState: { enum: ["enabled", "disabled"] }, + actualState: { enum: ["enabled", "disabled", "failed"] }, + pendingRestart: boolean, + lastError: nullableString, + instanceId: string, + }); + } else if (key === `GET ${prefix}/:id/history`) { + data = { + type: "array", + items: object({ + id: string, + package_name: string, + action: string, + result: string, + error_code: nullableString, + error_message: nullableString, + actor_id: nullableString, + instance_id: nullableString, + created_at: { type: "string", format: "date-time" }, + }), + }; + } else if ( + key === `POST ${prefix}/:id/enable` || + key === `POST ${prefix}/:id/disable` + ) { + data = object({ ...entry.properties, changed: boolean }); + } else { + return null; + } + return { + parameters: [id], + responses: { + 200: response(data), + 400: error, + 401: error, + 403: error, + 404: error, + 409: error, + default: error, + }, + }; +} diff --git a/scripts/docs/http-contracts.mjs b/scripts/docs/http-contracts.mjs index 88c5a8a..629623b 100644 --- a/scripts/docs/http-contracts.mjs +++ b/scripts/docs/http-contracts.mjs @@ -21,6 +21,7 @@ import { systemCollectionsContract } from "./system-collections-contract.mjs"; import { userFormContract } from "./user-form-contract.mjs"; import { oauthPolicyContract } from "./oauth-policy-contract.mjs"; import { userReferenceContract } from "./user-reference-contract.mjs"; +import { extensionRegistryContract } from "./extension-registry-contract.mjs"; import { mcpContract } from "./mcp-contract.mjs"; import { tableViewsContract } from "./table-views-contract.mjs"; const object = { type: "object", additionalProperties: true }; @@ -77,6 +78,8 @@ const settingsSectionList = { }; export function operationContract(key) { + const extensionRegistry = extensionRegistryContract(key); + if (extensionRegistry) return extensionRegistry; const tableViews = tableViewsContract(key); if (tableViews) { return tableViews; diff --git a/scripts/docs/locales/http.en.json b/scripts/docs/locales/http.en.json index 2b8d32e..ab76fc0 100644 --- a/scripts/docs/locales/http.en.json +++ b/scripts/docs/locales/http.en.json @@ -215,5 +215,13 @@ "Public CLI discovery; configured admin consent URL only. No credential.": "Public CLI discovery; configured admin consent URL only. No credential.", "Active human session approves a 60-second one-use PKCE S256 code for an exact loopback callback.": "Active human session approves a 60-second one-use PKCE S256 code for an exact loopback callback.", "One-use code + PKCE verifier + exact callback. Issues 10-minute schema:read access only; rate-limited.": "One-use code + PKCE verifier + exact callback. Issues 10-minute schema:read access only; rate-limited.", - "Человеческая сессия: superuser или чтение раздела users; только ID и подпись пользователя, ограниченные поиск и пагинация; сервисные ключи запрещены": "Human session: superuser or users-section read; only user IDs/labels with bounded search/pagination; service keys forbidden" + "Человеческая сессия: superuser или чтение раздела users; только ID и подпись пользователя, ограниченные поиск и пагинация; сервисные ключи запрещены": "Human session: superuser or users-section read; only user IDs/labels with bounded search/pagination; service keys forbidden", + "Человек: plugins/read либо update; superuser bypass; только настроенные пакеты": "Human with plugins/read or update; superuser bypass; configured packages only", + "Человек: plugins/read либо update; superuser bypass": "Human with plugins/read or update; superuser bypass", + "Человек: plugins/read либо update; только установленная версия": "Human with plugins/read or update; installed version only", + "Человек: plugins/read либо update; snapshot текущего процесса": "Human with plugins/read or update; current process snapshot", + "Человек: plugins/update; superuser bypass; сохраняет желаемое состояние до перезапуска": "Human with plugins/update; superuser bypass; saves desired state until restart", + "Успех; private, no-store": "Success; private, no-store", + "Ошибка авторизации, проверки или конфликта": "Authorization, validation, or conflict error", + "Base64url идентификатор имени пакета": "Base64url identifier of the package name" } diff --git a/scripts/test.mjs b/scripts/test.mjs index 77afb78..a7e1076 100644 --- a/scripts/test.mjs +++ b/scripts/test.mjs @@ -130,6 +130,7 @@ async function main() { "core-all", "core-unit", "core-integration", + "core-registry", "core-assistant", "core-mcp", "core-sso", @@ -281,6 +282,13 @@ async function main() { if (suite === "core-security") { return name === "test/http-access-surface.integration.test.ts"; } + if (suite === "core-registry") { + return [ + "test/extension-registry.test.ts", + "test/extension-registry.integration.test.ts", + "test/http-access-surface.integration.test.ts", + ].includes(name); + } if (suite === "core-unit") return !name.endsWith(".integration.test.ts"); if (suite === "core-integration") return name.endsWith(".integration.test.ts");