Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/sandbox-plugin-site-context.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"emdash": patch
---

Fixes sandboxed plugins receiving empty site metadata and relative URLs from `ctx.url()`.
16 changes: 2 additions & 14 deletions packages/core/src/astro/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ import {
import { setI18nConfig } from "../i18n/config.js";
import type { Database, Storage } from "../index.js";
import { createPublicMediaUrlResolver } from "../media/url.js";
import type { SandboxRunner } from "../plugins/sandbox/types.js";
import type { SandboxRunnerFactory } from "../plugins/sandbox/types.js";
import type { ResolvedPlugin } from "../plugins/types.js";
import { invalidateUrlPatternCache } from "../query.js";
import {
Expand Down Expand Up @@ -200,19 +200,7 @@ function buildDependencies(config: EmDashConfig): RuntimeDependencies {
sandboxEnabled: sandboxModule.sandboxEnabled as boolean,
sandboxBypassed: (sandboxModule.sandboxBypassed as boolean) ?? false,
sandboxedPluginEntries: (virtualSandboxedPlugins as SandboxedPluginEntry[]) || [],
createSandboxRunner: sandboxModule.createSandboxRunner as
| ((opts: {
db: Kysely<Database>;
mediaStorage?: {
upload(options: {
key: string;
body: Uint8Array;
contentType: string;
}): Promise<unknown>;
delete(key: string): Promise<unknown>;
};
}) => SandboxRunner)
| null,
createSandboxRunner: sandboxModule.createSandboxRunner as SandboxRunnerFactory | null,
mediaProviderEntries: (virtualMediaProviders as MediaProviderEntry[]) || [],
};
/* eslint-enable typescript-eslint/no-unsafe-type-assertion */
Expand Down
87 changes: 49 additions & 38 deletions packages/core/src/emdash-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,12 @@ import {
markContentMediaUsageCollectionStale,
refreshContentMediaUsageAfterWrite,
} from "./media/usage/content-refresh.js";
import type { SandboxedPluginInstance, SandboxRunner } from "./plugins/sandbox/types.js";
import { createSandboxRunnerOptions } from "./plugins/sandbox/runner-options.js";
import type {
SandboxedPluginInstance,
SandboxRunner,
SandboxRunnerFactory,
} from "./plugins/sandbox/types.js";
import type {
ResolvedPlugin,
MediaItem,
Expand Down Expand Up @@ -310,16 +315,8 @@ export interface RuntimeDependencies {
/** Media provider entries from virtual module */
mediaProviderEntries?: MediaProviderEntry[];
sandboxedPluginEntries: SandboxedPluginEntry[];
/** Factory function matching SandboxRunnerFactory signature */
createSandboxRunner:
| ((opts: {
db: Kysely<Database>;
mediaStorage?: {
upload(options: { key: string; body: Uint8Array; contentType: string }): Promise<unknown>;
delete(key: string): Promise<unknown>;
};
}) => SandboxRunner)
| null;
/** Factory function supplied by the active platform adapter. */
createSandboxRunner: SandboxRunnerFactory | null;
}

/**
Expand Down Expand Up @@ -1390,7 +1387,7 @@ export class EmDashRuntime {

// Load sandboxed plugins (build-time, sandbox runner path)
const sandboxedPlugins = await phase("rt.sandbox", "Sandboxed plugins", () =>
EmDashRuntime.loadSandboxedPlugins(deps, db, storage),
EmDashRuntime.loadSandboxedPlugins(deps, db, storage, siteInfo),
);

// Cold-start: load marketplace- and registry-installed plugins from
Expand All @@ -1408,6 +1405,7 @@ export class EmDashRuntime {
storage,
deps,
sandboxedPlugins,
siteInfo,
),
),
);
Expand All @@ -1423,6 +1421,7 @@ export class EmDashRuntime {
storage,
deps,
sandboxedPlugins,
siteInfo,
),
),
);
Expand Down Expand Up @@ -1832,6 +1831,7 @@ export class EmDashRuntime {
deps: RuntimeDependencies,
db: Kysely<Database>,
mediaStorage?: Storage | null,
siteInfo?: { siteName?: string; siteUrl?: string; locale?: string },
): Promise<Map<string, SandboxedPluginInstance>> {
// Return cached plugins if already loaded
if (sandboxedPluginCache.size > 0) {
Expand All @@ -1845,20 +1845,25 @@ export class EmDashRuntime {

// Create sandbox runner if not exists
if (!sandboxRunner && deps.createSandboxRunner) {
sandboxRunner = deps.createSandboxRunner({
db,
mediaStorage: mediaStorage
? {
upload: (opts) =>
mediaStorage.upload({
key: opts.key,
body: opts.body,
contentType: opts.contentType,
}),
delete: (key) => mediaStorage.delete(key),
}
: undefined,
});
sandboxRunner = deps.createSandboxRunner(
createSandboxRunnerOptions(
{
db,
mediaStorage: mediaStorage
? {
upload: (opts) =>
mediaStorage.upload({
key: opts.key,
body: opts.body,
contentType: opts.contentType,
}),
delete: (key) => mediaStorage.delete(key),
}
: undefined,
},
siteInfo,
),
);
}

if (!sandboxRunner) {
Expand Down Expand Up @@ -1941,22 +1946,28 @@ export class EmDashRuntime {
storage: Storage,
deps: RuntimeDependencies,
cache: Map<string, SandboxedPluginInstance>,
siteInfo?: { siteName?: string; siteUrl?: string; locale?: string },
): Promise<void> {
// Ensure sandbox runner exists with media storage wired up.
// (storage here is the media Storage adapter from the runtime.)
if (!sandboxRunner && deps.createSandboxRunner) {
sandboxRunner = deps.createSandboxRunner({
db,
mediaStorage: {
upload: (opts) =>
storage.upload({
key: opts.key,
body: opts.body,
contentType: opts.contentType,
}),
delete: (key) => storage.delete(key),
},
});
sandboxRunner = deps.createSandboxRunner(
createSandboxRunnerOptions(
{
db,
mediaStorage: {
upload: (opts) =>
storage.upload({
key: opts.key,
body: opts.body,
contentType: opts.contentType,
}),
delete: (key) => storage.delete(key),
},
},
siteInfo,
),
);
}
// In sandbox bypass mode, marketplace plugins are loaded in-process
// BEFORE pipeline creation by EmDashRuntime.create(). Skip here.
Expand Down
16 changes: 16 additions & 0 deletions packages/core/src/plugins/sandbox/runner-options.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { createSiteInfo, type SiteInfoOptions } from "../context.js";
import type { SandboxOptions } from "./types.js";

/**
* Build platform sandbox options with the same normalized site context used
* by trusted plugin hooks and routes.
*/
export function createSandboxRunnerOptions(
options: Omit<SandboxOptions, "siteInfo">,
siteInfo?: SiteInfoOptions,
): SandboxOptions {
return {
...options,
siteInfo: createSiteInfo(siteInfo ?? {}),
};
}
45 changes: 45 additions & 0 deletions packages/core/tests/integration/runtime/create.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { describe, expect, it, vi } from "vitest";

import { DEFAULT_COMMENT_MODERATOR_PLUGIN_ID } from "../../../src/comments/moderator.js";
import { runMigrations } from "../../../src/database/migrations/runner.js";
import { OptionsRepository } from "../../../src/database/repositories/options.js";
import type { Database as EmDashDatabase } from "../../../src/database/types.js";
import { EmDashRuntime } from "../../../src/emdash-runtime.js";
import type { RuntimeDependencies } from "../../../src/emdash-runtime.js";
Expand Down Expand Up @@ -114,6 +115,50 @@ describe("EmDashRuntime.create — cold boot", () => {
}
});

it("passes normalized site information to the sandbox runner", async () => {
const sqlite = new Database(":memory:");
const setupDb = new Kysely<EmDashDatabase>({
dialect: new SqliteDialect({ database: sqlite }),
});
await runMigrations(setupDb);
const options = new OptionsRepository(setupDb);
await options.set("emdash:setup_complete", true);
await options.set("emdash:site_title", "Example Site");
await options.set("emdash:site_url", "https://example.com/");
await options.set("emdash:locale", "nl");

const runner = {
isAvailable: () => true,
isHealthy: () => true,
load: vi.fn(),
setEmailSend: vi.fn(),
terminateAll: vi.fn(),
};
const createSandboxRunner = vi.fn(() => runner as never);
const deps: RuntimeDependencies = {
...createDeps(),
createDialect: () => new SqliteDialect({ database: sqlite }),
sandboxEnabled: true,
createSandboxRunner,
};

const runtime = await EmDashRuntime.create(deps);
try {
expect(createSandboxRunner).toHaveBeenCalledWith(
expect.objectContaining({
siteInfo: {
name: "Example Site",
url: "https://example.com",
locale: "nl",
},
}),
);
} finally {
await runtime.stopCron();
await setupDb.destroy();
}
});

// The init read phase feeds plugin enablement: a plugin marked inactive in
// _plugin_state must be excluded from the pipeline, so its exclusive hook is
// never auto-selected. A shared DB seeds the _plugin_state row before
Expand Down
38 changes: 38 additions & 0 deletions packages/core/tests/unit/plugins/sandbox-runner-options.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { describe, expect, it, vi } from "vitest";

import { createSandboxRunnerOptions } from "../../../src/plugins/sandbox/runner-options.js";

describe("createSandboxRunnerOptions", () => {
it("normalizes configured site information for sandbox contexts", () => {
const db = {} as never;
const options = createSandboxRunnerOptions(
{ db },
{
siteName: "Example Site",
siteUrl: "https://example.com/",
locale: "nl",
},
);

expect(options).toEqual({
db,
siteInfo: {
name: "Example Site",
url: "https://example.com",
locale: "nl",
},
});
});

it("preserves runner options while applying site defaults", () => {
const db = {} as never;
const upload = vi.fn();
const remove = vi.fn();
const mediaStorage = { upload, delete: remove };
const options = createSandboxRunnerOptions({ db, mediaStorage });

expect(options.db).toBe(db);
expect(options.mediaStorage).toBe(mediaStorage);
expect(options.siteInfo).toEqual({ name: "", url: "", locale: "en" });
});
});
Loading