From 1dc00a0937a7a279144488665a1bfb9a8176101c Mon Sep 17 00:00:00 2001 From: Aleksandr Minkin Date: Sat, 10 Oct 2026 21:48:34 +0300 Subject: [PATCH 01/15] feat(core): persist extension activation and operation history Add Core-owned tables for desired package state and administrator operations. Keep the migration additive so existing plugin data survives upgrades, and refuse rollback once the new tables contain records. --- .../20261010120000_extension_registry.cjs | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 apps/core/migrations/20261010120000_extension_registry.cjs 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"); +}; From 684edb9dd4ed0690177d20fc98560432cce71ac9 Mon Sep 17 00:00:00 2001 From: Aleksandr Minkin Date: Sat, 10 Oct 2026 21:48:38 +0300 Subject: [PATCH 02/15] feat(core): record process startup outcomes in extension history Allow system generated history entries without an administrator actor and attach the serving instance ID. This makes startup failures traceable while protecting populated runtime history from destructive rollback. --- ...20261010130000_extension_runtime_audit.cjs | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 apps/core/migrations/20261010130000_extension_runtime_audit.cjs 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"); +}; From 60a1ae6a0e947c35d15477b209ad4a16009fe203 Mon Sep 17 00:00:00 2001 From: Aleksandr Minkin Date: Sat, 10 Oct 2026 21:48:45 +0300 Subject: [PATCH 03/15] feat(contracts): expose typed extension registry responses Define the package snapshot, history entry, and paginated list shared by Core, SDK, and UI. Include the startup instance ID in history because the API returns it for process-specific diagnostics. --- .../contracts/src/extension-registry.d.ts | 57 +++++++++++++++++++ packages/contracts/src/index.d.ts | 5 ++ 2 files changed, 62 insertions(+) create mode 100644 packages/contracts/src/extension-registry.d.ts 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 78acd6f..808fa6e 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 { NotificationItem, NotificationResult } from "./notifications.js"; export type { SchemaValueType, From e20201548041a4576609359aa0d5977e367b8546 Mon Sep 17 00:00:00 2001 From: Aleksandr Minkin Date: Sat, 10 Oct 2026 21:49:33 +0300 Subject: [PATCH 04/15] feat(core): validate trusted extension manifests and dependency graphs Parse optional registry metadata with semantic version validation, while retaining versionless v1 fixtures. Resolve required dependencies in load order and report missing, incompatible, cyclic, or duplicate namespace declarations before importing plugin code. --- apps/core/package.json | 4 +- apps/core/src/plugins/registry-manifest.ts | 90 +++++++++++++++++ apps/core/src/plugins/registry-resolver.ts | 108 +++++++++++++++++++++ pnpm-lock.yaml | 11 +++ 4 files changed, 212 insertions(+), 1 deletion(-) create mode 100644 apps/core/src/plugins/registry-manifest.ts create mode 100644 apps/core/src/plugins/registry-resolver.ts diff --git a/apps/core/package.json b/apps/core/package.json index ca77244..b86ae5c 100644 --- a/apps/core/package.json +++ b/apps/core/package.json @@ -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/plugins/registry-manifest.ts b/apps/core/src/plugins/registry-manifest.ts new file mode 100644 index 0000000..d2f0d43 --- /dev/null +++ b/apps/core/src/plugins/registry-manifest.ts @@ -0,0 +1,90 @@ +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 ( + parsed.asmblyr.manifest.dependencies?.[expectedName] || + 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-resolver.ts b/apps/core/src/plugins/registry-resolver.ts new file mode 100644 index 0000000..73370ce --- /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 = {}; + 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/pnpm-lock.yaml b/pnpm-lock.yaml index f0b9ec4..516f665 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) @@ -2952,6 +2958,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==} @@ -9100,6 +9109,8 @@ snapshots: dependencies: htmlparser2: 10.1.0 + '@types/semver@7.8.0': {} + '@types/send@1.2.1': dependencies: '@types/node': 20.19.43 From 34da5b67609f705f1689ba3519b51151c7915407 Mon Sep 17 00:00:00 2001 From: Aleksandr Minkin Date: Sat, 10 Oct 2026 21:49:37 +0300 Subject: [PATCH 05/15] feat(core): reconcile configured plugins before loading code Read only allowlisted package metadata, honor disabled packages, and load dependencies in order. Isolate package failures for registry reporting while preserving the strict loader path used by existing callers. --- apps/core/src/plugins/definition.ts | 2 + apps/core/src/plugins/load.ts | 176 +++++++++++++++++++++++++--- 2 files changed, 160 insertions(+), 18 deletions(-) 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..239bbbf 100644 --- a/apps/core/src/plugins/load.ts +++ b/apps/core/src/plugins/load.ts @@ -44,6 +44,11 @@ import { sourceEndpoints, type PendingEndpoint, } from "./route-index.js"; +import { + parseRegistryPackage, + type RegistryPackage, +} from "./registry-manifest.js"; +import { resolveRegistryPackages } from "./registry-resolver.js"; const packageNamePattern = /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/; @@ -75,6 +80,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 +148,54 @@ function validateManifest(value: unknown, name: string): string | undefined { export async function loadPlugins( projectPackage: URL, - { sourcePlugins = false }: { sourcePlugins?: boolean } = {}, + { + sourcePlugins = false, + disabledPackages = [], + registryPackages, + onFailure, + }: { + sourcePlugins?: boolean; + disabledPackages?: readonly string[]; + registryPackages?: readonly RegistryPackage[]; + 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, + typeof project.version === "string" ? project.version : "0.0.0", + ); + 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; } From 78801a3e1e51fc3cb9229f0ca406efd597321b29 Mon Sep 17 00:00:00 2001 From: Aleksandr Minkin Date: Sat, 10 Oct 2026 21:49:46 +0300 Subject: [PATCH 06/15] feat(core): transact extension state changes and audit outcomes Store desired activation under a PostgreSQL advisory transaction lock and reject dependency conflicts before changing state. Record administrator requests and process startup results without deleting package data. --- apps/core/src/plugins/registry-state.ts | 202 ++++++++++++++++++++++++ 1 file changed, 202 insertions(+) create mode 100644 apps/core/src/plugins/registry-state.ts 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; + } +} From e19e7a13261038eaf3a5558987baef2375c7b2bf Mon Sep 17 00:00:00 2001 From: Aleksandr Minkin Date: Sat, 10 Oct 2026 21:49:50 +0300 Subject: [PATCH 07/15] feat(core): serve permission gated extension registry endpoints Expose list, detail, health, dependencies, permissions, versions, history, and activation operations. Validate list filters, keep responses process-specific, and require plugin settings read or update permission for each route. --- apps/core/src/plugins/registry-query.ts | 111 +++++++++ apps/core/src/plugins/registry-routes.ts | 285 +++++++++++++++++++++++ 2 files changed, 396 insertions(+) create mode 100644 apps/core/src/plugins/registry-query.ts create mode 100644 apps/core/src/plugins/registry-routes.ts 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-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), + ); +} From 530869ed0281eae262424eb6278611facb1b263a Mon Sep 17 00:00:00 2001 From: Aleksandr Minkin Date: Sat, 10 Oct 2026 21:49:54 +0300 Subject: [PATCH 08/15] feat(core): reconcile desired extension state at startup Wire registry routes into Core, skip disabled packages before import, and report loaded or failed packages for the serving instance. Persist startup audit events and log failures after Core begins serving. --- apps/core/src/app.ts | 21 +++++++++++ apps/core/src/server.ts | 83 +++++++++++++++++++++++++++++++++++++---- 2 files changed, 97 insertions(+), 7 deletions(-) diff --git a/apps/core/src/app.ts b/apps/core/src/app.ts index 67e99fe..a8e80a2 100644 --- a/apps/core/src/app.ts +++ b/apps/core/src/app.ts @@ -61,6 +61,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"; @@ -101,6 +103,10 @@ interface AppOptions { sso?: SsoService; oauth?: OAuthConfig; plugins?: readonly LoadedPlugin[]; + registryPackages?: readonly RegistryPackage[]; + registryFailures?: ReadonlyMap; + registryInstanceId?: string; + coreVersion?: string; integrations?: IntegrationOptions; googleProtocol?: GoogleOAuthProtocol; } @@ -119,6 +125,12 @@ export function createApp({ sso = new SsoService([]), oauth, plugins = [], + registryPackages = plugins.flatMap((plugin) => + plugin.registry ? [plugin.registry] : [], + ), + registryFailures = new Map(), + registryInstanceId = randomUUID(), + coreVersion = "0.0.0", operationLimits = defaultOperationLimits, passkeys = { rpId: "localhost", @@ -287,6 +299,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/server.ts b/apps/core/src/server.ts index 99441c8..95064e9 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 { readFile } from "node:fs/promises"; 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 projectMetadata = JSON.parse(await readFile(projectPackage, "utf8")); +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, + onFailure: (name, error) => registryFailures.set(name, error), +}); const app = createApp({ trustProxy: process.env.TRUST_PROXY?.split(",") .map((value) => value.trim()) @@ -41,13 +72,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: projectMetadata.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); @@ -99,6 +144,7 @@ const cleanupTimer = setInterval(async () => { cleanupTimer.unref(); app.addHook("onClose", async () => { clearInterval(cleanupTimer); + await registryDatabase?.destroy(); await maintenance?.destroy(); }); let stopping = false; @@ -114,6 +160,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); From d457e8a9572ed7daab467c4ef9c1895feb3ec5e5 Mon Sep 17 00:00:00 2001 From: Aleksandr Minkin Date: Sat, 10 Oct 2026 21:50:01 +0300 Subject: [PATCH 09/15] test(core): cover registry validation, access and state transitions Exercise metadata and dependency validation plus query parsing in unit tests. Add disposable database coverage for RBAC, concurrent activation, history, startup reconciliation and failure snapshots; expose it as the core-registry test suite. --- .../extension-registry.integration.test.ts | 235 ++++++++++++++++++ apps/core/test/extension-registry.test.ts | 161 ++++++++++++ scripts/test.mjs | 8 + 3 files changed, 404 insertions(+) create mode 100644 apps/core/test/extension-registry.integration.test.ts create mode 100644 apps/core/test/extension-registry.test.ts 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..3d58383 --- /dev/null +++ b/apps/core/test/extension-registry.integration.test.ts @@ -0,0 +1,235 @@ +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("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..01d3fbf --- /dev/null +++ b/apps/core/test/extension-registry.test.ts @@ -0,0 +1,161 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +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("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/scripts/test.mjs b/scripts/test.mjs index d05daa6..0dbd901 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-sso", "core-browser", @@ -279,6 +280,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"); From f3945d31bc6d83ed83554a274b4c190bd57e07c0 Mon Sep 17 00:00:00 2001 From: Aleksandr Minkin Date: Sat, 10 Oct 2026 21:50:11 +0300 Subject: [PATCH 10/15] feat(sdk): add typed extension registry client Expose registry inspection and enable or disable methods through the shared authenticated transport. Document required plugin grants and restart semantics in both package guides, and test paths plus permission failures. --- packages/sdk/README.md | 2 + packages/sdk/README.ru.md | 7 ++ packages/sdk/src/client.ts | 3 + packages/sdk/src/extensions.ts | 101 ++++++++++++++++++++++++++ packages/sdk/src/index.ts | 1 + packages/sdk/test/extensions.test.mjs | 36 +++++++++ 6 files changed, 150 insertions(+) create mode 100644 packages/sdk/src/extensions.ts create mode 100644 packages/sdk/test/extensions.test.mjs 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", + ], + ]); +}); From ba5b96f9e483be4121490ce7abbce399ced5df37 Mon Sep 17 00:00:00 2001 From: Aleksandr Minkin Date: Sat, 10 Oct 2026 21:50:20 +0300 Subject: [PATCH 11/15] feat(cli): inspect and toggle extensions through registry API Add list, info, enable, and disable commands using an explicit human token. Require --yes for activation changes, preserve credential handling, and document restart behavior with focused CLI coverage. --- packages/cli/README.md | 7 +++ packages/cli/README.ru.md | 8 +++ packages/cli/src/arguments.mjs | 54 ++++++++++++++------ packages/cli/src/main.mjs | 0 packages/cli/src/run.mjs | 92 ++++++++++++++++++++++++++++------ packages/cli/test/cli.test.mjs | 43 ++++++++++++++++ 6 files changed, 174 insertions(+), 30 deletions(-) mode change 100644 => 100755 packages/cli/src/main.mjs 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"))); +}); From 2a69f04c3fcce152af472d22b04d2e18c05ba708 Mon Sep 17 00:00:00 2001 From: Aleksandr Minkin Date: Sat, 10 Oct 2026 21:50:32 +0300 Subject: [PATCH 12/15] feat(ui): show extension status, controls and history Replace the simple plugin settings list with a searchable registry view, detail controls, dependency and capability information, and operation history. Preserve settings editing, read-only access, loading feedback, and English/Russian UI copy. --- .../admin/settings/plugins/loading.tsx | 23 ++ .../(admin)/admin/settings/plugins/page.tsx | 15 + .../settings/plugins/extension-history.tsx | 64 +++ .../settings/plugins/plugin-editor-dialog.tsx | 366 +++++++++++++++--- .../settings/plugins/plugins-workspace.tsx | 311 ++++++++++++--- packages/contracts/locales/ui.en.json | 54 ++- packages/contracts/locales/ui.ru.json | 54 ++- 7 files changed, 784 insertions(+), 103 deletions(-) create mode 100644 apps/ui/src/app/(admin)/admin/settings/plugins/loading.tsx create mode 100644 apps/ui/src/components/admin/settings/plugins/extension-history.tsx 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 f178069..c73d9bc 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,319 @@ "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/collections/editor-dialog"; import { Badge } from "@/components/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"); + } + setHistory(result.data); + 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); + } + } 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 && ( -

- {copy("Дополнительные возможности не запрошены. ")} + {!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 3e6abc3..1254536 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 "@/components/ui/badge"; import { Table, TableBody, @@ -16,87 +18,270 @@ 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, + available: null, + 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, + updates: null, + }; return (
- {plugins.length ? ( -
+
+ {( + [ + ["Установлено", counts.installed], + ["Доступно", counts.available], + ["Активно", counts.active], + ["Отключено", counts.disabled], + ["Проблемы", counts.failed], + ["Обновления", counts.updates], + ] as const + ).map(([label, count]) => ( +
+

{copy(label)}

+

+ {count ?? "—"} +

+ {(label === "Обновления" || label === "Доступно") && ( +

+ {copy("Каталог не подключён")} +

+ )} +
+ ))} +
+
+ + + + {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 +290,35 @@ 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" && + selection.packageName in candidate.dependencies, + ) + .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/packages/contracts/locales/ui.en.json b/packages/contracts/locales/ui.en.json index fe9f750..1b4efe0 100644 --- a/packages/contracts/locales/ui.en.json +++ b/packages/contracts/locales/ui.en.json @@ -2190,5 +2190,57 @@ "copy.836426d3": "Remove row", "copy.2ed86957": "Show to the profile owner", "copy.8298baf8": "Add row", - "copy.688d1e21": "Configured rows are visible to user managers. The checkbox also lets the profile owner see the selected value without granting access to the whole directory. These values cannot be edited in the profile." + "copy.688d1e21": "Configured rows are visible to user managers. The checkbox also lets the profile owner see the selected value without granting access to the whole directory. These values cannot be edited in the profile.", + "copy.4fffd48e": "Operation history", + "copy.e8f54db9": "No operations yet.", + "copy.21c1c98a": "Extensions that depend on this one", + "copy.76bcd4b4": "Enable this extension after Core restarts?", + "copy.d8d8fc00": "Disable this extension after Core restarts?", + "copy.91694594": "Could not change the extension state", + "copy.2c76c313": "Change saved. Restart Core.", + "copy.4a7602d9": "State saved.", + "copy.15012614": "Operation unavailable", + "copy.acd63063": "Extension Center", + "copy.a07a2904": "No description provided.", + "copy.4742d170": "Restart required", + "copy.70aced92": "Issues", + "copy.7c1ce28c": "Startup failed", + "copy.21311e3a": "Active", + "copy.e661ef1a": "Version", + "copy.127087f1": "Desired", + "copy.223d31dd": "On this Core", + "copy.62556d50": "Inactive", + "copy.06da4077": "This process keeps its current state until Core restarts.", + "copy.fd8c73cd": "Enable", + "copy.09543e4b": "A trusted deployment process installs and updates packages.", + "copy.eae3cc97": "Versions and compatibility", + "copy.f6cc039a": "Installed version", + "copy.a2a8e49a": "Available version", + "copy.7ccf32b1": "No external version catalog is connected.", + "copy.f1ed405c": "Dependencies", + "copy.f94e2c5c": "No required dependencies.", + "copy.15d6c625": "Optional", + "copy.119c7544": "Permissions", + "copy.1f037f09": "No additional capabilities requested.", + "copy.786d0916": "Capabilities are approved in the project configuration before Core starts.", + "copy.a558d01d": "Configuration", + "copy.b12c3bab": "Settings are unavailable for this extension.", + "copy.5200a277": "State and diagnostics", + "copy.6b312f47": "Extension code is loaded in this process.", + "copy.09b2603f": "Extension code is not loaded in this process.", + "copy.69a202cd": "Installed trusted packages, their state, dependencies, and permissions.", + "copy.da1e6694": "Catalog not connected", + "copy.682507e9": "Search extensions", + "copy.1f6b5169": "All statuses", + "copy.2ed3644c": "Category", + "copy.8477d9ad": "All categories", + "copy.631ce03a": "Sort", + "copy.9d31a7a5": "By name", + "copy.a66f9bbe": "By version", + "copy.ff5a4df2": "By status", + "copy.fdee34b0": "In this process", + "copy.856e5952": "Publisher", + "copy.35bb7665": "Open extension {{value0}}", + "copy.2b0663b2": "No extensions found.", + "copy.3635d9fc": "No configured extensions." } diff --git a/packages/contracts/locales/ui.ru.json b/packages/contracts/locales/ui.ru.json index 8c69adc..4ae4982 100644 --- a/packages/contracts/locales/ui.ru.json +++ b/packages/contracts/locales/ui.ru.json @@ -2190,5 +2190,57 @@ "copy.836426d3": "Удалить строку", "copy.2ed86957": "Показывать владельцу профиля", "copy.8298baf8": "Добавить строку", - "copy.688d1e21": "Настроенные строки видны управляющим пользователями. Галочка дополнительно разрешает владельцу видеть выбранное значение без доступа ко всему справочнику. Изменять эти данные в профиле нельзя." + "copy.688d1e21": "Настроенные строки видны управляющим пользователями. Галочка дополнительно разрешает владельцу видеть выбранное значение без доступа ко всему справочнику. Изменять эти данные в профиле нельзя.", + "copy.4fffd48e": "История операций", + "copy.e8f54db9": "Операций пока нет.", + "copy.21c1c98a": "Зависят от этого расширения", + "copy.76bcd4b4": "Включить расширение после перезапуска Core?", + "copy.d8d8fc00": "Отключить расширение после перезапуска Core?", + "copy.91694594": "Не удалось изменить состояние расширения", + "copy.2c76c313": "Изменение сохранено. Перезапустите Core.", + "copy.4a7602d9": "Состояние сохранено.", + "copy.15012614": "Операция недоступна", + "copy.acd63063": "Центр расширений", + "copy.a07a2904": "Описание не указано.", + "copy.4742d170": "Нужен перезапуск", + "copy.70aced92": "Проблемы", + "copy.7c1ce28c": "Ошибка запуска", + "copy.21311e3a": "Активно", + "copy.e661ef1a": "Версия", + "copy.127087f1": "Требуется", + "copy.223d31dd": "В этом Core", + "copy.62556d50": "Неактивно", + "copy.06da4077": "Текущий процесс продолжает прежний режим до перезапуска Core.", + "copy.fd8c73cd": "Включить", + "copy.09543e4b": "Установка и обновление пакета выполняются доверенным процессом развёртывания.", + "copy.eae3cc97": "Версии и совместимость", + "copy.f6cc039a": "Установлена версия", + "copy.a2a8e49a": "Доступна версия", + "copy.7ccf32b1": "Внешний каталог версий не подключён.", + "copy.f1ed405c": "Зависимости", + "copy.f94e2c5c": "Обязательных зависимостей нет.", + "copy.15d6c625": "Необязательные", + "copy.119c7544": "Разрешения", + "copy.1f037f09": "Дополнительные возможности не запрошены.", + "copy.786d0916": "Разрешения утверждаются в конфигурации проекта до запуска Core.", + "copy.a558d01d": "Конфигурация", + "copy.b12c3bab": "Настройки недоступны для этого расширения.", + "copy.5200a277": "Состояние и диагностика", + "copy.6b312f47": "Код расширения загружен в текущем процессе.", + "copy.09b2603f": "Код расширения не загружен в текущем процессе.", + "copy.69a202cd": "Установленные доверенные пакеты, их состояние, зависимости и разрешения.", + "copy.da1e6694": "Каталог не подключён", + "copy.682507e9": "Поиск расширений", + "copy.1f6b5169": "Все статусы", + "copy.2ed3644c": "Категория", + "copy.8477d9ad": "Все категории", + "copy.631ce03a": "Сортировка", + "copy.9d31a7a5": "По названию", + "copy.a66f9bbe": "По версии", + "copy.ff5a4df2": "По статусу", + "copy.fdee34b0": "В процессе", + "copy.856e5952": "Издатель", + "copy.35bb7665": "Открыть расширение {{value0}}", + "copy.2b0663b2": "Расширения не найдены.", + "copy.3635d9fc": "Нет настроенных расширений." } From bcdf380e51925e7a2bcc5b1994144d1879248a4f Mon Sep 17 00:00:00 2001 From: Aleksandr Minkin Date: Sat, 10 Oct 2026 21:50:38 +0300 Subject: [PATCH 13/15] docs: explain trusted extension registry and lifecycle Document package metadata, desired versus loaded state, permission boundaries, startup reconciliation, deployment-only installation, and additive upgrade behavior in both language guides and architecture pages. --- docs/architecture/extension-registry.md | 165 +++++++++++++++++++++ docs/features/plugins.md | 27 +++- docs/index.md | 1 + docs/ru/architecture/extension-registry.md | 131 ++++++++++++++++ docs/ru/features/plugins.md | 27 +++- docs/ru/index.md | 1 + 6 files changed, 350 insertions(+), 2 deletions(-) create mode 100644 docs/architecture/extension-registry.md create mode 100644 docs/ru/architecture/extension-registry.md diff --git a/docs/architecture/extension-registry.md b/docs/architecture/extension-registry.md new file mode 100644 index 0000000..f77ec14 --- /dev/null +++ b/docs/architecture/extension-registry.md @@ -0,0 +1,165 @@ +# 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. diff --git a/docs/features/plugins.md b/docs/features/plugins.md index c57010e..e214b66 100644 --- a/docs/features/plugins.md +++ b/docs/features/plugins.md @@ -4,7 +4,11 @@ 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. + +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 +20,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 +63,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 cb32bdd..99b6023 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/ru/architecture/extension-registry.md b/docs/ru/architecture/extension-registry.md new file mode 100644 index 0000000..1b6b543 --- /dev/null +++ b/docs/ru/architecture/extension-registry.md @@ -0,0 +1,131 @@ +# Архитектура реестра расширений + +Единица расширения — 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. diff --git a/docs/ru/features/plugins.md b/docs/ru/features/plugins.md index b5eaae3..e04a03e 100644 --- a/docs/ru/features/plugins.md +++ b/docs/ru/features/plugins.md @@ -6,7 +6,11 @@ Плагин — 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 +23,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 +88,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) From 788c7c8488e1cb25c2e09dd7cc1812007a3c3d98 Mon Sep 17 00:00:00 2001 From: Aleksandr Minkin Date: Sat, 10 Oct 2026 21:50:47 +0300 Subject: [PATCH 14/15] docs(api): publish typed extension registry HTTP contracts Catalog nine permission gated routes and generate English/Russian OpenAPI schemas from their response shapes. Refresh the route matrix and package guide copies so SDK and CLI behavior matches the published API. --- docs/public/openapi.json | 1666 ++++++++++++++++++ docs/public/ru/openapi.json | 1666 ++++++++++++++++++ docs/reference/cli-guide.md | 7 + docs/reference/endpoints.json | 36 + docs/reference/routes.md | 11 +- docs/reference/sdk-guide.md | 2 + docs/ru/reference/cli-guide.md | 8 + docs/ru/reference/routes.md | 11 +- docs/ru/reference/sdk-guide.md | 7 + scripts/docs/extension-registry-contract.mjs | 171 ++ scripts/docs/http-contracts.mjs | 3 + scripts/docs/locales/http.en.json | 10 +- 12 files changed, 3595 insertions(+), 3 deletions(-) create mode 100644 scripts/docs/extension-registry-contract.mjs diff --git a/docs/public/openapi.json b/docs/public/openapi.json index 6f36e98..1cec5b0 100644 --- a/docs/public/openapi.json +++ b/docs/public/openapi.json @@ -10044,6 +10044,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 9748a82..990bf52 100644 --- a/docs/public/ru/openapi.json +++ b/docs/public/ru/openapi.json @@ -10044,6 +10044,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 ad698cf..9a1b577 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/routes.md b/docs/reference/routes.md index a6c24ec..c90bdff 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. 226 declarations. +Generated from Core and the reviewed access catalog. 235 declarations. This describes access boundaries; integration tests verify enforcement. Dynamic plugin routes and internal oidc-provider endpoints are not listed individually. @@ -160,6 +160,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/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/routes.md b/docs/ru/reference/routes.md index bd7b623..d00c1ac 100644 --- a/docs/ru/reference/routes.md +++ b/docs/ru/reference/routes.md @@ -1,6 +1,6 @@ # Матрица HTTP-маршрутов -Сгенерировано из Core и проверенного каталога доступа. 226 деклараций. +Сгенерировано из Core и проверенного каталога доступа. 235 деклараций. Это описание границ; их исполнение проверяют интеграционные тесты. Динамические маршруты плагинов и внутренние endpoints oidc-provider не перечисляются отдельно. @@ -160,6 +160,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/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 0c4431d..34ebc31 100644 --- a/scripts/docs/http-contracts.mjs +++ b/scripts/docs/http-contracts.mjs @@ -20,6 +20,7 @@ import { profileDisplayContract } from "./profile-display-contract.mjs"; import { systemCollectionsContract } from "./system-collections-contract.mjs"; import { oauthPolicyContract } from "./oauth-policy-contract.mjs"; import { userReferenceContract } from "./user-reference-contract.mjs"; +import { extensionRegistryContract } from "./extension-registry-contract.mjs"; const object = { type: "object", additionalProperties: true }; const json = (schema) => ({ "application/json": { schema } }); const response = (schema, description = "Успех") => ({ @@ -74,6 +75,8 @@ const settingsSectionList = { }; export function operationContract(key) { + const extensionRegistry = extensionRegistryContract(key); + if (extensionRegistry) return extensionRegistry; const userReference = userReferenceContract(key); if (userReference) return userReference; const oauthPolicy = oauthPolicyContract(key); 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" } From 409e5295ed801793a6e1cc1ff1e1903ebd285079 Mon Sep 17 00:00:00 2001 From: Aleksandr Minkin Date: Sat, 10 Oct 2026 21:50:51 +0300 Subject: [PATCH 15/15] fix(kit): mark plugin builder entrypoint executable Set the package bin script executable so direct POSIX invocation works after publication. The script contents and builder behavior stay the same. --- packages/kit/bin/plugin.mjs | 0 1 file changed, 0 insertions(+), 0 deletions(-) mode change 100644 => 100755 packages/kit/bin/plugin.mjs diff --git a/packages/kit/bin/plugin.mjs b/packages/kit/bin/plugin.mjs old mode 100644 new mode 100755