Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions apps/core/migrations/20261010120000_extension_registry.cjs
Original file line number Diff line number Diff line change
@@ -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");
};
20 changes: 20 additions & 0 deletions apps/core/migrations/20261010130000_extension_runtime_audit.cjs
Original file line number Diff line number Diff line change
@@ -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");
};
4 changes: 3 additions & 1 deletion apps/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,14 +41,16 @@
"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:*",
"@types/node": "^20.19.43",
"@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",
Expand Down
21 changes: 21 additions & 0 deletions apps/core/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -101,6 +103,10 @@ interface AppOptions {
sso?: SsoService;
oauth?: OAuthConfig;
plugins?: readonly LoadedPlugin[];
registryPackages?: readonly RegistryPackage[];
registryFailures?: ReadonlyMap<string, string>;
registryInstanceId?: string;
coreVersion?: string;
integrations?: IntegrationOptions;
googleProtocol?: GoogleOAuthProtocol;
}
Expand All @@ -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",
Expand Down Expand Up @@ -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" }));
Expand Down
2 changes: 2 additions & 0 deletions apps/core/src/plugins/definition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<string, unknown> {
Expand Down
176 changes: 158 additions & 18 deletions apps/core/src/plugins/load.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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._-]*$/;

Expand Down Expand Up @@ -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<RegistryPackage[]> {
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) ||
Expand All @@ -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<LoadedPlugin[]> {
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: {
Expand All @@ -118,15 +210,37 @@ export async function loadPlugins(
hooks: PendingHook[];
settings?: URL;
capabilities: PluginCapability[];
registry: RegistryPackage;
}[] = [];
const namespaces = new Set<string>();
const failed = new Set<string>();

// 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,
Expand Down Expand Up @@ -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`,
{
Expand All @@ -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;
}
Loading