All checks were successful
CD / Promote to Int (push) Successful in 9s
CI / Auth e2e pack (push) Successful in 3m56s
CI / Import/export fidelity gate (push) Successful in 43s
CI / Lint, typecheck, test (push) Successful in 2m53s
CI / Build container images (push) Has been skipped
CD / Build and push images (push) Successful in 3m9s
CD / Smoke tests against Test (push) Successful in 1m9s
CD / Deploy to Test (push) Successful in 12s
The powerful end of the plugin spectrum (ADR 0008 extension point `block`): - Shared schema: the reserved `plugin_block` node — a block atom carrying pluginId, blockType, and the block data as a JSON object. Its DOM shape round-trips the full state in data attributes (clipboard-safe), markdown maps to a reserved fence (```dorfteich-plugin <plugin>/<type> + data JSON body, fence-escalated when the payload contains backticks), and the content-cache HTML renders a data-carrying neutral placeholder until the export fallbacks land (#79). - Editor: a React NodeView hosts the #73 sandbox — render lifecycle on mount, an edit affordance switching the frame to the plugin's edit mode, and the blockData capability persisting through node attrs (a normal editor transaction, so Yjs replicates it; writes are refused on read-only editors, and the plugin's own attr echo is suppressed so its edit UI never resets mid-typing). Collaborator changes re-invoke the current lifecycle, keeping frames live. The page surface (ids, openPage) flows through a React context like the wikilink pattern; the toolbar gets an insert picker fed from the active code plugins' block extension points. - Fallback: GET /plugins/:id/fallback resolves the manifest fallback from the stored snapshot — it survives uninstall as a tombstone, image fallbacks degrade to neutral once assets are gone. Signed-in only. - e2e plugin-blocks.spec.ts covers all four acceptance criteria: insert → edit → reload round-trip, live two-user collab, disable → fallback → re-enable without document mutation, and copy/paste within and across pages (the markdown clipboard carries the reserved fence). getBlock (cross-page block embedding) stays deferred as in #74: the schema has no per-block ids yet. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
195 lines
6.0 KiB
TypeScript
195 lines
6.0 KiB
TypeScript
import { strToU8, zipSync } from 'fflate';
|
|
|
|
/**
|
|
* Fixture plugins for the sandbox security pack (issue #73). These are the
|
|
* permanent regression assets ADR 0008 asks for: a well-behaved plugin, a
|
|
* malicious one probing every escape hatch, and one that simply hangs. They
|
|
* speak the raw `dorfteich.plugin.rpc/1` protocol on purpose — no SDK import —
|
|
* so the assets stay self-contained and keep working even if the SDK bundle
|
|
* format changes.
|
|
*/
|
|
|
|
export function pluginZip(manifest: Record<string, unknown>, bundleSource: string): Buffer {
|
|
return Buffer.from(
|
|
zipSync({
|
|
'manifest.json': strToU8(JSON.stringify(manifest)),
|
|
'plugin.js': strToU8(bundleSource),
|
|
}),
|
|
);
|
|
}
|
|
|
|
export function fixtureManifest(
|
|
id: string,
|
|
name: string,
|
|
permissions: string[],
|
|
): Record<string, unknown> {
|
|
return {
|
|
id,
|
|
name,
|
|
version: '1.0.0',
|
|
apiVersion: '1',
|
|
kind: 'code',
|
|
extensionPoints: [{ type: 'pageTool', id: 'main', title: { de: name, en: name } }],
|
|
permissions,
|
|
fallback: { type: 'text', value: `[${name}]` },
|
|
license: 'MIT',
|
|
};
|
|
}
|
|
|
|
/** Manifest of a block-type plugin (issue #76): one `block` extension point,
|
|
* `blockData` to persist, `ui` to resize, and a text fallback for when it is
|
|
* disabled while its blocks still exist in documents. */
|
|
export function blockManifest(id: string, name: string): Record<string, unknown> {
|
|
return {
|
|
id,
|
|
name,
|
|
version: '1.0.0',
|
|
apiVersion: '1',
|
|
kind: 'code',
|
|
extensionPoints: [{ type: 'block', id: 'note', title: { de: name, en: name } }],
|
|
permissions: ['blockData', 'ui'],
|
|
fallback: { type: 'text', value: `[${name}]` },
|
|
license: 'MIT',
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Block fixture behavior (issue #76), deterministic for assertions:
|
|
* - `render` shows `block:<text|empty>`;
|
|
* - `edit` appends `+e` to the stored text via `blockData.setData` (a real
|
|
* host round-trip that lands in the node attrs) and shows `editing:<text>`.
|
|
*/
|
|
export const BLOCK_PLUGIN_SOURCE = `
|
|
const PROTOCOL = 'dorfteich.plugin.rpc/1';
|
|
let seq = 0;
|
|
function respond(id) {
|
|
window.parent.postMessage({ protocol: PROTOCOL, type: 'response', id, ok: true }, '*');
|
|
}
|
|
function call(method, params) {
|
|
seq += 1;
|
|
window.parent.postMessage(
|
|
{ protocol: PROTOCOL, type: 'request', id: 'blk-' + seq, method, params },
|
|
'*',
|
|
);
|
|
}
|
|
window.addEventListener('message', (event) => {
|
|
const msg = event.data;
|
|
if (!msg || msg.protocol !== PROTOCOL || msg.type !== 'request') return;
|
|
const data = (msg.params && msg.params.data) || {};
|
|
if (msg.method === 'render') {
|
|
document.body.textContent = 'block:' + (data.text || 'empty');
|
|
respond(msg.id);
|
|
} else if (msg.method === 'edit') {
|
|
const next = { text: (data.text || '') + '+e' };
|
|
call('setData', next);
|
|
document.body.textContent = 'editing:' + next.text;
|
|
respond(msg.id);
|
|
} else if (msg.method === 'destroy') {
|
|
respond(msg.id);
|
|
}
|
|
});
|
|
`;
|
|
|
|
/** Answers `render`, shows a marker, and resizes its frame via `ui.resize`. */
|
|
export const WELL_BEHAVED_SOURCE = `
|
|
const PROTOCOL = 'dorfteich.plugin.rpc/1';
|
|
window.addEventListener('message', (event) => {
|
|
const msg = event.data;
|
|
if (!msg || msg.protocol !== PROTOCOL || msg.type !== 'request') return;
|
|
if (msg.method === 'render') {
|
|
document.body.textContent = 'plugin-ok';
|
|
window.parent.postMessage(
|
|
{ protocol: PROTOCOL, type: 'response', id: msg.id, ok: true },
|
|
'*',
|
|
);
|
|
window.parent.postMessage(
|
|
{ protocol: PROTOCOL, type: 'request', id: 'resize-1', method: 'resize', params: 321 },
|
|
'*',
|
|
);
|
|
}
|
|
});
|
|
`;
|
|
|
|
/**
|
|
* Probes every boundary the sandbox must hold: parent DOM, cookies, storage,
|
|
* same-origin and cross-origin network, and an undeclared capability. Each
|
|
* verdict lands in the frame DOM as
|
|
* `<div data-probe="<name>" data-result="blocked|LEAKED">` for the test to
|
|
* read — a LEAKED value is a security regression.
|
|
*/
|
|
export const MALICIOUS_SOURCE = `
|
|
const PROTOCOL = 'dorfteich.plugin.rpc/1';
|
|
|
|
function report(name, result) {
|
|
const el = document.createElement('div');
|
|
el.dataset.probe = name;
|
|
el.dataset.result = result;
|
|
document.body.appendChild(el);
|
|
}
|
|
|
|
function probeSync(name, attempt) {
|
|
try {
|
|
attempt();
|
|
report(name, 'LEAKED');
|
|
} catch {
|
|
report(name, 'blocked');
|
|
}
|
|
}
|
|
|
|
async function probeFetch(name, url) {
|
|
try {
|
|
await fetch(url);
|
|
report(name, 'LEAKED');
|
|
} catch {
|
|
report(name, 'blocked');
|
|
}
|
|
}
|
|
|
|
function probeUndeclaredCapability() {
|
|
return new Promise((resolve) => {
|
|
const id = 'undeclared-1';
|
|
const timer = setTimeout(() => {
|
|
report('undeclaredCapability', 'LEAKED');
|
|
resolve();
|
|
}, 3000);
|
|
window.addEventListener('message', function onReply(event) {
|
|
const msg = event.data;
|
|
if (!msg || msg.protocol !== PROTOCOL || msg.type !== 'response' || msg.id !== id) return;
|
|
window.removeEventListener('message', onReply);
|
|
clearTimeout(timer);
|
|
const rejected = !msg.ok && msg.error && msg.error.code === 'capability_not_permitted';
|
|
report('undeclaredCapability', rejected ? 'blocked' : 'LEAKED');
|
|
resolve();
|
|
});
|
|
window.parent.postMessage(
|
|
{ protocol: PROTOCOL, type: 'request', id, method: 'listPages' },
|
|
'*',
|
|
);
|
|
});
|
|
}
|
|
|
|
window.addEventListener('message', async (event) => {
|
|
const msg = event.data;
|
|
if (!msg || msg.protocol !== PROTOCOL || msg.type !== 'request') return;
|
|
if (msg.method !== 'render') return;
|
|
window.parent.postMessage(
|
|
{ protocol: PROTOCOL, type: 'response', id: msg.id, ok: true },
|
|
'*',
|
|
);
|
|
probeSync('parentDom', () => window.parent.document.title);
|
|
probeSync('cookie', () => document.cookie);
|
|
probeSync('localStorage', () => window.localStorage.getItem('x'));
|
|
await probeFetch('fetchSameOrigin', '/api/v1/healthz');
|
|
await probeFetch('fetchExternal', 'https://example.com/');
|
|
await probeUndeclaredCapability();
|
|
report('done', 'blocked');
|
|
});
|
|
`;
|
|
|
|
/** Loads fine but never answers anything — must hit the timeout placeholder. */
|
|
export const HUNG_SOURCE = `
|
|
window.addEventListener('message', () => {
|
|
// Deliberately silent: the host deadline must handle this plugin.
|
|
});
|
|
`;
|