update ui

This commit is contained in:
Moritz Jung 2026-05-27 22:01:39 +02:00
parent 1167694ee8
commit c564c13d8a
18 changed files with 824 additions and 522 deletions

View file

@ -110,52 +110,7 @@ export class WorkerExecutionSession {
cleanupCallbacks.push(
worker.onMessage(message => {
const parsedMessage = workerToHostMessageSchema.safeParse(message);
if (!parsedMessage.success) {
settle({
status: 'validation-error',
codeHash: options.codeHash,
message: 'Worker sent an invalid message.',
permissions: [...options.grantedPermissions],
elapsedMs: this.now() - options.startedAt,
});
return;
}
if (parsedMessage.data.executionId !== executionId) {
settle({
status: 'validation-error',
codeHash: options.codeHash,
message: 'Worker sent a message for an unknown execution.',
permissions: [...options.grantedPermissions],
elapsedMs: this.now() - options.startedAt,
});
return;
}
if (parsedMessage.data.type === 'rpc-request') {
this.handleRpcRequest(parsedMessage.data, executionId, options, abortController, () => settled, worker.postMessage.bind(worker));
return;
}
if (parsedMessage.data.ok) {
settle({
status: 'success',
codeHash: options.codeHash,
value: parsedMessage.data.value,
permissions: [...options.grantedPermissions],
elapsedMs: this.now() - options.startedAt,
});
return;
}
settle({
status: 'runtime-error',
codeHash: options.codeHash,
message: parsedMessage.data.error.message,
permissions: [...options.grantedPermissions],
elapsedMs: this.now() - options.startedAt,
});
this.handleWorkerMessage(message, executionId, options, abortController, () => settled, worker.postMessage.bind(worker), settle);
}),
worker.onError(error => {
settle({
@ -178,6 +133,51 @@ export class WorkerExecutionSession {
});
}
private handleWorkerMessage(
message: unknown,
executionId: string,
options: WorkerExecutionSessionOptions,
abortController: AbortController,
isSettled: () => boolean,
postMessage: (message: HostRpcResponseMessage) => void,
settle: (result: SafeJsExecutionResult) => void,
): void {
const parsedMessage = workerToHostMessageSchema.safeParse(message);
if (!parsedMessage.success) {
settle(this.createValidationErrorResult(options, 'Worker sent an invalid message.'));
return;
}
if (parsedMessage.data.executionId !== executionId) {
settle(this.createValidationErrorResult(options, 'Worker sent a message for an unknown execution.'));
return;
}
if (parsedMessage.data.type === 'rpc-request') {
this.handleRpcRequest(parsedMessage.data, executionId, options, abortController, isSettled, postMessage);
return;
}
if (parsedMessage.data.ok) {
settle({
status: 'success',
codeHash: options.codeHash,
value: parsedMessage.data.value,
permissions: [...options.grantedPermissions],
elapsedMs: this.now() - options.startedAt,
});
return;
}
settle({
status: 'runtime-error',
codeHash: options.codeHash,
message: parsedMessage.data.error.message,
permissions: [...options.grantedPermissions],
elapsedMs: this.now() - options.startedAt,
});
}
private handleRpcRequest(
rpcMessage: HostRpcRequestMessage,
executionId: string,
@ -245,4 +245,14 @@ export class WorkerExecutionSession {
elapsedMs: this.now() - startedAt,
};
}
private createValidationErrorResult(options: WorkerExecutionSessionOptions, message: string): SafeJsExecutionResult {
return {
status: 'validation-error',
codeHash: options.codeHash,
message,
permissions: [...options.grantedPermissions],
elapsedMs: this.now() - options.startedAt,
};
}
}

View file

@ -0,0 +1,89 @@
import type {
JsonValue,
PermissionDefinition,
PermissionId,
SafeJsValidationContext,
SafeJsValidationResult,
SafeJsValidator,
SafeJsValidatorReference,
} from '@lemons_dev/obsidian-safe-js-api';
import type { WorkerRpcBinding } from '@lemons_dev/obsidian-safe-js-api/internal';
import type { BuiltInValidatorOptions } from 'packages/obsidian/src/rpc/validators';
export interface RpcContext {
grantedPermissions: ReadonlySet<PermissionId>;
codeHash?: string;
signal?: AbortSignal;
}
export interface RpcDispatchSuccess {
ok: true;
result: JsonValue;
}
export interface RpcDispatchFailure {
ok: false;
error: {
code: string;
message: string;
};
}
export type RpcDispatchResult = RpcDispatchSuccess | RpcDispatchFailure;
export interface RpcMethodDefinition<TParams = unknown, TResult = unknown> {
method: string;
permission: PermissionId;
description: string;
usage: string;
requestValidator: SafeJsValidatorReference<TParams>;
responseValidator: SafeJsValidatorReference<TResult>;
binding: Omit<WorkerRpcBinding, 'method' | 'permission'>;
handler(params: TParams, context: RpcContext): Promise<TResult> | TResult;
}
export interface RpcRegistrationOwner {
pluginId?: string;
pluginName?: string;
}
export interface SandboxGlobalRegistration {
name: string;
description: string;
value: JsonValue;
permission?: PermissionId;
}
export interface RpcDocsMethod {
method: string;
apiPath: string;
description: string;
usage: string;
permission: PermissionId;
ownerPluginId?: string;
ownerPluginName?: string;
}
export interface RpcDocsPermission {
permission: PermissionDefinition;
methods: RpcDocsMethod[];
globals: RpcDocsGlobal[];
ownerPluginId?: string;
ownerPluginName?: string;
}
export interface RpcDocsGlobal {
name: string;
description: string;
permission?: PermissionId;
ownerPluginId?: string;
ownerPluginName?: string;
}
export interface RpcRegistryOptions {
methods?: readonly RpcMethodDefinition[];
permissionDefinitions?: readonly PermissionDefinition[];
validators: BuiltInValidatorOptions | readonly SafeJsValidator[];
}
export type { JsonValue, PermissionDefinition, PermissionId, SafeJsValidationContext, SafeJsValidationResult, SafeJsValidator, SafeJsValidatorReference };

View file

@ -1,5 +1,4 @@
import type {
JsonValue,
PermissionDefinition,
PermissionId,
SafeJsRegistration,
@ -11,80 +10,33 @@ import type {
import type { WorkerRpcBinding, WorkerSandboxGlobal } from '@lemons_dev/obsidian-safe-js-api/internal';
import { jsonValueSchema } from 'packages/obsidian/src/execution/contracts';
import { PERMISSION_DEFINITIONS, getPermissionDefinition } from 'packages/obsidian/src/permissions/permissions';
import type {
JsonValue,
RpcContext,
RpcDispatchResult,
RpcDocsPermission,
RpcMethodDefinition,
RpcRegistrationOwner,
RpcRegistryOptions,
SandboxGlobalRegistration,
} from 'packages/obsidian/src/rpc/rpc-registry-types';
import type { BuiltInValidatorOptions } from 'packages/obsidian/src/rpc/validators';
import { ValidatorRegistry, createBuiltInValidators } from 'packages/obsidian/src/rpc/validators';
export type { SafeJsRegistration } from '@lemons_dev/obsidian-safe-js-api';
export interface RpcContext {
grantedPermissions: ReadonlySet<PermissionId>;
codeHash?: string;
signal?: AbortSignal;
}
export interface RpcDispatchSuccess {
ok: true;
result: JsonValue;
}
export interface RpcDispatchFailure {
ok: false;
error: {
code: string;
message: string;
};
}
export type RpcDispatchResult = RpcDispatchSuccess | RpcDispatchFailure;
export interface RpcMethodDefinition<TParams = unknown, TResult = unknown> {
method: string;
permission: PermissionId;
description: string;
usage: string;
requestValidator: SafeJsValidatorReference<TParams>;
responseValidator: SafeJsValidatorReference<TResult>;
binding: Omit<WorkerRpcBinding, 'method' | 'permission'>;
handler(params: TParams, context: RpcContext): Promise<TResult> | TResult;
}
export interface RpcRegistrationOwner {
pluginId?: string;
pluginName?: string;
}
export interface SandboxGlobalRegistration {
name: string;
description: string;
value: JsonValue;
permission?: PermissionId;
}
export interface RpcDocsMethod {
method: string;
apiPath: string;
description: string;
usage: string;
permission: PermissionId;
ownerPluginId?: string;
ownerPluginName?: string;
}
export interface RpcDocsPermission {
permission: PermissionDefinition;
methods: RpcDocsMethod[];
globals: RpcDocsGlobal[];
ownerPluginId?: string;
ownerPluginName?: string;
}
export interface RpcDocsGlobal {
name: string;
description: string;
permission?: PermissionId;
ownerPluginId?: string;
ownerPluginName?: string;
}
export type {
RpcContext,
RpcDispatchFailure,
RpcDispatchResult,
RpcDispatchSuccess,
RpcDocsGlobal,
RpcDocsMethod,
RpcDocsPermission,
RpcMethodDefinition,
RpcRegistrationOwner,
RpcRegistryOptions,
SandboxGlobalRegistration,
} from 'packages/obsidian/src/rpc/rpc-registry-types';
export class RpcRegistry {
private readonly methods = new Map<string, RpcMethodDefinition<unknown, unknown>>();
@ -95,23 +47,19 @@ export class RpcRegistry {
private readonly sandboxGlobals = new Map<string, SandboxGlobalRegistration>();
private readonly sandboxGlobalOwners = new Map<string, RpcRegistrationOwner>();
constructor(
methods: readonly RpcMethodDefinition[] = [],
permissionDefinitions: readonly PermissionDefinition[] = PERMISSION_DEFINITIONS,
validatorOptions: BuiltInValidatorOptions | readonly SafeJsValidator[],
) {
this.validators = new ValidatorRegistry(isValidatorList(validatorOptions) ? validatorOptions : createBuiltInValidators(validatorOptions));
constructor(options: RpcRegistryOptions) {
this.validators = new ValidatorRegistry(isValidatorList(options.validators) ? options.validators : createBuiltInValidators(options.validators));
for (const permission of permissionDefinitions) {
for (const permission of options.permissionDefinitions ?? PERMISSION_DEFINITIONS) {
this.permissionDefinitions.set(permission.id, permission);
}
for (const method of methods) {
this.register(method);
for (const method of options.methods ?? []) {
this.addMethod(method);
}
}
register(method: RpcMethodDefinition<unknown, unknown>, owner: RpcRegistrationOwner = {}): void {
private addMethod(method: RpcMethodDefinition<unknown, unknown>, owner: RpcRegistrationOwner = {}): void {
if (this.methods.has(method.method)) {
throw new Error(`Duplicate RPC method '${method.method}'.`);
}
@ -162,7 +110,7 @@ export class RpcRegistry {
}
registerMethod(method: RpcMethodDefinition<unknown, unknown>, owner: RpcRegistrationOwner = {}): SafeJsRegistration {
this.register(method, owner);
this.addMethod(method, owner);
return {
unregister: (): void => {

View file

@ -15,8 +15,8 @@ import { createWorkspaceMethods } from 'packages/obsidian/src/rpc/obsidian/works
import { RpcRegistry } from 'packages/obsidian/src/rpc/rpc-registry';
export function createSafeJsRpcRegistry(app: App): RpcRegistry {
return new RpcRegistry(
[
return new RpcRegistry({
methods: [
...createCoreMethods(app),
...createHelperMethods(),
...createVaultReadMethods(app),
@ -32,7 +32,6 @@ export function createSafeJsRpcRegistry(app: App): RpcRegistry {
...createNetworkMethods(),
...createStorageMethods(app),
],
undefined,
{ getConfigDir: () => app.vault.configDir },
);
validators: { getConfigDir: () => app.vault.configDir },
});
}

View file

@ -99,7 +99,7 @@ function uniqueScriptId(baseId: string, existingIds: readonly string[]): string
return `${baseId}-${index}`;
}
function displayNameFromPath(path: string): string {
export function displayNameFromPath(path: string): string {
const fileName = path.split('/').pop() ?? path;
return fileName.replace(/\.js$/iu, '') || 'Script';
}

View file

@ -1,336 +1,3 @@
import type { App } from 'obsidian';
import { Notice, PluginSettingTab, Setting } from 'obsidian';
import type SafeJsPlugin from 'packages/obsidian/src/main';
import type { PermissionApproval, PermissionSettingsStore } from 'packages/obsidian/src/permissions/approval-store';
import {
AppPermissionStorage,
LocalStoragePermissionApprovalStore,
LocalStoragePermissionSettingsStore,
} from 'packages/obsidian/src/permissions/approval-store';
import type { SafeJsScriptConfig } from 'packages/obsidian/src/scripts/script-settings';
import { createScriptConfig, isJavaScriptVaultScriptPath, normalizeSafeJsScripts } from 'packages/obsidian/src/scripts/script-settings';
import type { VaultScriptManager } from 'packages/obsidian/src/scripts/vault-script-manager';
import type { ScriptStorageEntry } from 'packages/obsidian/src/storage/script-storage';
import { ScriptStorageManager } from 'packages/obsidian/src/storage/script-storage';
export interface SafeJsSettings {
executionTimeoutsEnabled: boolean;
executionTimeoutMs: number;
debugBlocksEnabled: boolean;
scripts: SafeJsScriptConfig[];
}
export const DEFAULT_SETTINGS: SafeJsSettings = {
executionTimeoutsEnabled: true,
executionTimeoutMs: 5000,
debugBlocksEnabled: true,
scripts: [],
};
export class SafeJsSettingTab extends PluginSettingTab {
plugin: SafeJsPlugin;
private readonly approvalStore: LocalStoragePermissionApprovalStore;
private readonly permissionSettingsStore: PermissionSettingsStore;
private readonly scriptManager?: VaultScriptManager;
private readonly staleEntryAgeMs = 30 * 24 * 60 * 60 * 1000;
constructor(
app: App,
plugin: SafeJsPlugin,
permissionSettingsStore: PermissionSettingsStore = new LocalStoragePermissionSettingsStore(new AppPermissionStorage(app)),
scriptManager?: VaultScriptManager,
) {
super(app, plugin);
this.plugin = plugin;
this.approvalStore = new LocalStoragePermissionApprovalStore(new AppPermissionStorage(app));
this.permissionSettingsStore = permissionSettingsStore;
this.scriptManager = scriptManager;
}
display(): void {
const { containerEl } = this;
containerEl.empty();
new Setting(containerEl)
.setName('Execution timeouts')
.setDesc('Cancel scripts that run longer than the configured timeout.')
.addToggle(toggle =>
toggle.setValue(this.plugin.settings.executionTimeoutsEnabled).onChange(async value => {
this.plugin.settings.executionTimeoutsEnabled = value;
await this.plugin.saveSettings();
this.display();
}),
);
new Setting(containerEl)
.setName('Execution timeout')
.setDesc(
this.plugin.settings.executionTimeoutsEnabled
? 'Maximum time a worker-backed script may run before it is cancelled.'
: 'Timeouts are disabled. This value is kept for later use.',
)
.addText(text =>
text
.setPlaceholder(String(DEFAULT_SETTINGS.executionTimeoutMs))
.setValue(String(this.plugin.settings.executionTimeoutMs))
.onChange(async value => {
const parsedValue = Number.parseInt(value, 10);
this.plugin.settings.executionTimeoutMs =
Number.isFinite(parsedValue) && parsedValue > 0 ? parsedValue : DEFAULT_SETTINGS.executionTimeoutMs;
await this.plugin.saveSettings();
}),
);
new Setting(containerEl)
.setName('Debug blocks')
.setDesc('Enable support for the safe-js-debug code block language.')
.addToggle(toggle =>
toggle.setValue(this.plugin.settings.debugBlocksEnabled).onChange(async value => {
this.plugin.settings.debugBlocksEnabled = value;
await this.plugin.saveSettings();
}),
);
new Setting(containerEl)
.setName('Auto-allow low-risk permissions')
.setDesc('Allow low-risk permissions without prompting. Approvals are still remembered per script hash on this device.')
.addToggle(toggle =>
toggle.setValue(this.permissionSettingsStore.loadAutoAllowLowRiskPermissions()).onChange(value => {
this.permissionSettingsStore.saveAutoAllowLowRiskPermissions(value);
}),
);
this.renderVaultScripts(containerEl);
this.renderApprovalStorage(containerEl);
this.renderScriptStorage(containerEl);
}
private renderVaultScripts(containerEl: HTMLElement): void {
const section = containerEl.createEl('section');
new Setting(section).setName('Vault scripts').setHeading();
section.createEl('p', {
text: `${this.plugin.settings.scripts.length} vault ${this.plugin.settings.scripts.length === 1 ? 'script is' : 'scripts are'} configured.`,
});
for (const script of this.plugin.settings.scripts) {
this.renderVaultScript(section, script);
}
this.renderAddVaultScript(section);
}
private renderVaultScript(section: HTMLElement, script: SafeJsScriptConfig): void {
new Setting(section)
.setName(script.name)
.setDesc(script.path)
.addText(text =>
text
.setPlaceholder('Script name')
.setValue(script.name)
.onChange(async value => {
script.name = value.trim() || script.path;
await this.saveScriptSettings();
}),
)
.addText(text =>
text
.setPlaceholder('Scripts/example.js')
.setValue(script.path)
.onChange(async value => {
script.path = value.trim();
await this.saveScriptSettings();
}),
)
.addToggle(toggle =>
toggle
.setTooltip('Run on startup')
.setValue(script.runOnStartup)
.onChange(async value => {
script.runOnStartup = value;
await this.saveScriptSettings();
}),
)
.addButton(button =>
button.setButtonText('Remove').onClick(async () => {
this.plugin.settings.scripts = this.plugin.settings.scripts.filter(candidate => candidate.id !== script.id);
await this.saveScriptSettings();
this.display();
}),
);
}
private renderAddVaultScript(section: HTMLElement): void {
let path = '';
let name = '';
new Setting(section)
.setName('Add script')
.setDesc('Configure a vault .js file as a command.')
.addText(text =>
text.setPlaceholder('Scripts/example.js').onChange(value => {
path = value;
}),
)
.addText(text =>
text.setPlaceholder('Command name').onChange(value => {
name = value;
}),
)
.addButton(button =>
button.setButtonText('Add').onClick(async () => {
const normalizedPath = path.trim();
if (normalizedPath === '' || !isJavaScriptVaultScriptPath(normalizedPath)) {
new Notice('Enter a vault path ending in .js.');
return;
}
if (this.plugin.settings.scripts.some(script => script.path === normalizedPath)) {
new Notice('That script is already configured.');
return;
}
this.plugin.settings.scripts = [...this.plugin.settings.scripts, createScriptConfig(normalizedPath, name, this.plugin.settings.scripts)];
await this.saveScriptSettings();
this.display();
}),
);
}
private async saveScriptSettings(): Promise<void> {
await this.plugin.saveSettings();
this.scriptManager?.reloadCommands();
}
private renderApprovalStorage(containerEl: HTMLElement): void {
const approvals = this.approvalStore.list();
const section = containerEl.createEl('section');
new Setting(section).setName('Approved script hashes').setHeading();
section.createEl('p', {
text: `${approvals.length} approved script ${approvals.length === 1 ? 'hash' : 'hashes'} are stored on this device.`,
});
new Setting(section)
.setName('Clear approved hashes')
.setDesc('Remove remembered permission approvals for changed or old scripts.')
.addButton(button =>
button.setButtonText('Clear older than 30 days').onClick(() => {
const deletedCount = this.approvalStore.deleteOlderThan(Date.now() - this.staleEntryAgeMs);
new Notice(`Cleared ${deletedCount} approved script ${deletedCount === 1 ? 'hash' : 'hashes'}.`);
this.display();
}),
)
.addButton(button =>
button.setButtonText('Clear all').onClick(() => {
const deletedCount = this.approvalStore.deleteAll();
new Notice(`Cleared ${deletedCount} approved script ${deletedCount === 1 ? 'hash' : 'hashes'}.`);
this.display();
}),
);
this.renderApprovalList(section, approvals);
}
private renderApprovalList(section: HTMLElement, approvals: PermissionApproval[]): void {
if (approvals.length === 0) {
section.createEl('p', { text: 'No approved script hashes are stored.' });
return;
}
const list = section.createEl('ul');
for (const approval of approvals.slice(0, 20)) {
const item = list.createEl('li');
item.createEl('code', { text: approval.codeHash });
item.createSpan({
text: ` - ${formatCaller(approval.callerPluginId)} - ${approval.permissions.join(', ')} - ${formatDate(approval.updatedAt)}`,
});
}
if (approvals.length > 20) {
section.createEl('p', { text: `${approvals.length - 20} more approved hashes are hidden.` });
}
}
private renderScriptStorage(containerEl: HTMLElement): void {
const entries = ScriptStorageManager.listAll(this.app);
const section = containerEl.createEl('section');
new Setting(section).setName('Script storage').setHeading();
section.createEl('p', {
text: `${entries.length} Safe JS storage ${entries.length === 1 ? 'key is' : 'keys are'} indexed on this device.`,
});
new Setting(section)
.setName('Clear script storage')
.setDesc('Remove data written through the storage API by scripts.')
.addButton(button =>
button.setButtonText('Clear older than 30 days').onClick(() => {
const deletedCount = ScriptStorageManager.deleteOlderThanAll(this.app, Date.now() - this.staleEntryAgeMs);
new Notice(`Cleared ${deletedCount} script storage ${deletedCount === 1 ? 'key' : 'keys'}.`);
this.display();
}),
)
.addButton(button =>
button.setButtonText('Clear all').onClick(() => {
const deletedCount = ScriptStorageManager.deleteAllKnown(this.app);
new Notice(`Cleared ${deletedCount} script storage ${deletedCount === 1 ? 'key' : 'keys'}.`);
this.display();
}),
);
this.renderScriptStorageList(section, entries);
}
private renderScriptStorageList(section: HTMLElement, entries: ScriptStorageEntry[]): void {
if (entries.length === 0) {
section.createEl('p', { text: 'No indexed script storage keys are stored.' });
return;
}
const list = section.createEl('ul');
for (const entry of entries.slice(0, 20)) {
const item = list.createEl('li');
item.createEl('code', { text: entry.key });
item.createSpan({ text: ` - ${formatScope(entry.scope)} - ${formatBytes(entry.sizeBytes)} - ${formatDate(entry.updatedAt)}` });
}
if (entries.length > 20) {
section.createEl('p', { text: `${entries.length - 20} more storage keys are hidden.` });
}
}
}
function formatDate(timestamp: number): string {
return new Date(timestamp).toLocaleString();
}
function formatBytes(bytes: number): string {
if (bytes < 1024) {
return `${bytes} B`;
}
return `${(bytes / 1024).toFixed(1)} KB`;
}
function formatCaller(callerPluginId: string | undefined): string {
return callerPluginId ?? 'notes';
}
function formatScope(scope: string | null): string {
return scope === null ? 'global' : `scoped ${scope.slice(0, 12)}`;
}
export function normalizeSafeJsSettings(value: unknown): SafeJsSettings {
const record = typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : {};
const executionTimeoutMs =
typeof record.executionTimeoutMs === 'number' && Number.isFinite(record.executionTimeoutMs) && record.executionTimeoutMs > 0
? record.executionTimeoutMs
: DEFAULT_SETTINGS.executionTimeoutMs;
return {
executionTimeoutsEnabled:
typeof record.executionTimeoutsEnabled === 'boolean' ? record.executionTimeoutsEnabled : DEFAULT_SETTINGS.executionTimeoutsEnabled,
executionTimeoutMs,
debugBlocksEnabled: typeof record.debugBlocksEnabled === 'boolean' ? record.debugBlocksEnabled : DEFAULT_SETTINGS.debugBlocksEnabled,
scripts: normalizeSafeJsScripts(record.scripts),
};
}
export { DEFAULT_SETTINGS, normalizeSafeJsSettings } from 'packages/obsidian/src/settings/settings-schema';
export type { SafeJsSettings } from 'packages/obsidian/src/settings/settings-schema';
export { SafeJsSettingTab } from 'packages/obsidian/src/ui/settings-tab';

View file

@ -0,0 +1,32 @@
import type { SafeJsScriptConfig } from 'packages/obsidian/src/scripts/script-settings';
import { normalizeSafeJsScripts } from 'packages/obsidian/src/scripts/script-settings';
export interface SafeJsSettings {
executionTimeoutsEnabled: boolean;
executionTimeoutMs: number;
debugBlocksEnabled: boolean;
scripts: SafeJsScriptConfig[];
}
export const DEFAULT_SETTINGS: SafeJsSettings = {
executionTimeoutsEnabled: true,
executionTimeoutMs: 5000,
debugBlocksEnabled: true,
scripts: [],
};
export function normalizeSafeJsSettings(value: unknown): SafeJsSettings {
const record = typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : {};
const executionTimeoutMs =
typeof record.executionTimeoutMs === 'number' && Number.isFinite(record.executionTimeoutMs) && record.executionTimeoutMs > 0
? record.executionTimeoutMs
: DEFAULT_SETTINGS.executionTimeoutMs;
return {
executionTimeoutsEnabled:
typeof record.executionTimeoutsEnabled === 'boolean' ? record.executionTimeoutsEnabled : DEFAULT_SETTINGS.executionTimeoutsEnabled,
executionTimeoutMs,
debugBlocksEnabled: typeof record.debugBlocksEnabled === 'boolean' ? record.debugBlocksEnabled : DEFAULT_SETTINGS.debugBlocksEnabled,
scripts: normalizeSafeJsScripts(record.scripts),
};
}

View file

@ -0,0 +1,118 @@
import type { App } from 'obsidian';
import type { TextComponent } from 'obsidian';
import { Modal, Notice, Setting } from 'obsidian';
import { isJavaScriptVaultScriptPath } from 'packages/obsidian/src/scripts/script-settings';
export interface AddVaultScriptModalValues {
name: string;
path: string;
runOnStartup: boolean;
}
export interface AddVaultScriptModalSubmitResult {
message?: string;
saved: boolean;
}
export interface AddVaultScriptModalOptions {
actionText: string;
initialValues: AddVaultScriptModalValues;
title: string;
}
export class AddVaultScriptModal extends Modal {
private readonly actionText: string;
private readonly onSubmit: (values: AddVaultScriptModalValues) => Promise<AddVaultScriptModalSubmitResult>;
private readonly title: string;
private path: string;
private name: string;
private runOnStartup: boolean;
constructor(app: App, options: AddVaultScriptModalOptions, onSubmit: (values: AddVaultScriptModalValues) => Promise<AddVaultScriptModalSubmitResult>) {
super(app);
this.actionText = options.actionText;
this.name = options.initialValues.name;
this.onSubmit = onSubmit;
this.path = options.initialValues.path;
this.runOnStartup = options.initialValues.runOnStartup;
this.title = options.title;
}
onOpen(): void {
const { contentEl } = this;
contentEl.empty();
contentEl.createEl('h2', { text: this.title });
let pathInput: TextComponent | undefined;
new Setting(contentEl)
.setName('Vault path')
.setDesc('Use a vault-relative path that ends in .js.')
.addText(text => {
pathInput = text;
return text
.setPlaceholder('Scripts/example.js')
.setValue(this.path)
.onChange(value => {
this.path = value;
});
});
new Setting(contentEl)
.setName('Command name')
.setDesc('Leave blank to use the file name.')
.addText(text =>
text
.setPlaceholder('Example script')
.setValue(this.name)
.onChange(value => {
this.name = value;
}),
);
new Setting(contentEl)
.setName('Run on startup')
.setDesc('Run this script when Obsidian finishes loading.')
.addToggle(toggle =>
toggle.setValue(this.runOnStartup).onChange(value => {
this.runOnStartup = value;
}),
);
new Setting(contentEl)
.addButton(button =>
button.setButtonText('Cancel').onClick(() => {
this.close();
}),
)
.addButton(button =>
button
.setButtonText(this.actionText)
.setCta()
.onClick(() => {
void this.submit();
}),
);
pathInput?.inputEl.focus();
}
private async submit(): Promise<void> {
const normalizedPath = this.path.trim();
if (normalizedPath === '' || !isJavaScriptVaultScriptPath(normalizedPath)) {
new Notice('Enter a vault path ending in .js.');
return;
}
const result = await this.onSubmit({
name: this.name,
path: normalizedPath,
runOnStartup: this.runOnStartup,
});
if (result.message !== undefined) {
new Notice(result.message);
}
if (result.saved) {
this.close();
}
}
}

View file

@ -0,0 +1,53 @@
import type { App } from 'obsidian';
import { PluginSettingTab } from 'obsidian';
import type SafeJsPlugin from 'packages/obsidian/src/main';
import type { PermissionSettingsStore } from 'packages/obsidian/src/permissions/approval-store';
import {
AppPermissionStorage,
LocalStoragePermissionApprovalStore,
LocalStoragePermissionSettingsStore,
} from 'packages/obsidian/src/permissions/approval-store';
import type { VaultScriptManager } from 'packages/obsidian/src/scripts/vault-script-manager';
import { RuntimeSettingsSection } from 'packages/obsidian/src/ui/settings/runtime-settings-section';
import { StoredDataSettingsSection } from 'packages/obsidian/src/ui/settings/stored-data-settings-section';
import { VaultScriptsSettingsSection } from 'packages/obsidian/src/ui/settings/vault-scripts-settings-section';
export class SafeJsSettingTab extends PluginSettingTab {
plugin: SafeJsPlugin;
private readonly approvalStore: LocalStoragePermissionApprovalStore;
private readonly permissionSettingsStore: PermissionSettingsStore;
private readonly scriptManager?: VaultScriptManager;
constructor(
app: App,
plugin: SafeJsPlugin,
permissionSettingsStore: PermissionSettingsStore = new LocalStoragePermissionSettingsStore(new AppPermissionStorage(app)),
scriptManager?: VaultScriptManager,
) {
super(app, plugin);
this.plugin = plugin;
this.approvalStore = new LocalStoragePermissionApprovalStore(new AppPermissionStorage(app));
this.permissionSettingsStore = permissionSettingsStore;
this.scriptManager = scriptManager;
}
display(): void {
const { containerEl } = this;
containerEl.empty();
new RuntimeSettingsSection(this.plugin, this.permissionSettingsStore, () => {
this.display();
}).render(containerEl);
new VaultScriptsSettingsSection(
this.app,
this.plugin,
() => {
this.display();
},
this.scriptManager,
).render(containerEl);
new StoredDataSettingsSection(this.app, this.approvalStore, () => {
this.display();
}).render(containerEl);
}
}

View file

@ -0,0 +1,78 @@
import { Setting } from 'obsidian';
import type SafeJsPlugin from 'packages/obsidian/src/main';
import type { PermissionSettingsStore } from 'packages/obsidian/src/permissions/approval-store';
import type { SafeJsSettings } from 'packages/obsidian/src/settings/settings-schema';
import { DEFAULT_SETTINGS } from 'packages/obsidian/src/settings/settings-schema';
export class RuntimeSettingsSection {
private readonly onSettingsChanged: () => void;
private readonly permissionSettingsStore: PermissionSettingsStore;
private readonly plugin: SafeJsPlugin;
constructor(plugin: SafeJsPlugin, permissionSettingsStore: PermissionSettingsStore, onSettingsChanged: () => void) {
this.onSettingsChanged = onSettingsChanged;
this.permissionSettingsStore = permissionSettingsStore;
this.plugin = plugin;
}
render(containerEl: HTMLElement): void {
this.createSection(containerEl, 'Runtime settings');
new Setting(containerEl)
.setName('Execution timeouts')
.setDesc('Cancel scripts that run longer than the configured timeout.')
.addToggle(toggle =>
toggle.setValue(this.plugin.settings.executionTimeoutsEnabled).onChange(async value => {
this.plugin.settings.executionTimeoutsEnabled = value;
await this.plugin.saveSettings();
this.onSettingsChanged();
}),
);
new Setting(containerEl)
.setName('Execution timeout')
.setDesc(this.executionTimeoutDescription(this.plugin.settings))
.addText(text =>
text
.setPlaceholder(String(DEFAULT_SETTINGS.executionTimeoutMs))
.setValue(String(this.plugin.settings.executionTimeoutMs))
.onChange(async value => {
this.plugin.settings.executionTimeoutMs = this.normalizeTimeoutMs(value);
await this.plugin.saveSettings();
}),
);
new Setting(containerEl)
.setName('Debug blocks')
.setDesc('Enable support for the safe-js-debug code block language.')
.addToggle(toggle =>
toggle.setValue(this.plugin.settings.debugBlocksEnabled).onChange(async value => {
this.plugin.settings.debugBlocksEnabled = value;
await this.plugin.saveSettings();
}),
);
new Setting(containerEl)
.setName('Auto-allow low-risk permissions')
.setDesc('Allow low-risk permissions without prompting. Approvals are still remembered per script hash on this device.')
.addToggle(toggle =>
toggle.setValue(this.permissionSettingsStore.loadAutoAllowLowRiskPermissions()).onChange(value => {
this.permissionSettingsStore.saveAutoAllowLowRiskPermissions(value);
}),
);
}
private createSection(containerEl: HTMLElement, heading: string): void {
new Setting(containerEl).setName(heading).setHeading();
}
private executionTimeoutDescription(settings: SafeJsSettings): string {
return settings.executionTimeoutsEnabled
? 'Maximum time a worker-backed script may run before it is cancelled.'
: 'Timeouts are disabled. This value is kept for later use.';
}
private normalizeTimeoutMs(value: string): number {
const parsedValue = Number.parseInt(value, 10);
return Number.isFinite(parsedValue) && parsedValue > 0 ? parsedValue : DEFAULT_SETTINGS.executionTimeoutMs;
}
}

View file

@ -0,0 +1,149 @@
import type { App } from 'obsidian';
import { Notice, Setting, SettingGroup } from 'obsidian';
import type { PermissionApproval } from 'packages/obsidian/src/permissions/approval-store';
import type { LocalStoragePermissionApprovalStore } from 'packages/obsidian/src/permissions/approval-store';
import type { ScriptStorageEntry } from 'packages/obsidian/src/storage/script-storage';
import { ScriptStorageManager } from 'packages/obsidian/src/storage/script-storage';
export class StoredDataSettingsSection {
private readonly app: App;
private readonly approvalStore: LocalStoragePermissionApprovalStore;
private readonly onStorageChanged: () => void;
private readonly staleEntryAgeMs = 30 * 24 * 60 * 60 * 1000;
constructor(app: App, approvalStore: LocalStoragePermissionApprovalStore, onStorageChanged: () => void) {
this.app = app;
this.approvalStore = approvalStore;
this.onStorageChanged = onStorageChanged;
}
render(containerEl: HTMLElement): void {
this.renderApprovalStorage(containerEl);
this.renderScriptStorage(containerEl);
}
private renderApprovalStorage(containerEl: HTMLElement): void {
const approvals = this.approvalStore.list();
this.createSection(containerEl, 'Approved script hashes');
new Setting(containerEl)
.setName('Clear approved hashes')
.setDesc('Remove remembered permission approvals for changed or old scripts.')
.addButton(button =>
button.setButtonText('Clear older than 30 days').onClick(() => {
const deletedCount = this.approvalStore.deleteOlderThan(Date.now() - this.staleEntryAgeMs);
new Notice(`Cleared ${deletedCount} approved script ${deletedCount === 1 ? 'hash' : 'hashes'}.`);
this.onStorageChanged();
}),
)
.addButton(button =>
button.setButtonText('Clear all').onClick(() => {
const deletedCount = this.approvalStore.deleteAll();
new Notice(`Cleared ${deletedCount} approved script ${deletedCount === 1 ? 'hash' : 'hashes'}.`);
this.onStorageChanged();
}),
);
this.renderApprovalList(new SettingGroup(containerEl), approvals);
}
private renderApprovalList(group: SettingGroup, approvals: PermissionApproval[]): void {
if (approvals.length === 0) {
group.addSetting(setting => {
setting.setName('No approved script hashes are stored.');
});
return;
}
for (const approval of approvals.slice(0, 20)) {
group.addSetting(setting => {
setting
.setName(approval.codeHash)
.setDesc(`${formatCaller(approval.callerPluginId)} - ${approval.permissions.join(', ')} - ${formatDate(approval.updatedAt)}`)
.addButton(button =>
button.setButtonText('Revoke').onClick(() => {
this.approvalStore.delete(approval);
new Notice(`Revoked approval for script hash ${approval.codeHash}.`);
this.onStorageChanged();
}),
);
});
}
if (approvals.length > 20) {
group.addSetting(setting => {
setting.setName(`${approvals.length - 20} more approved hashes are hidden.`);
});
}
}
private renderScriptStorage(containerEl: HTMLElement): void {
const entries = ScriptStorageManager.listAll(this.app);
this.createSection(containerEl, 'Script storage');
new Setting(containerEl)
.setName('Clear script storage')
.setDesc('Remove data written through the storage API by scripts.')
.addButton(button =>
button.setButtonText('Clear older than 30 days').onClick(() => {
const deletedCount = ScriptStorageManager.deleteOlderThanAll(this.app, Date.now() - this.staleEntryAgeMs);
new Notice(`Cleared ${deletedCount} script storage ${deletedCount === 1 ? 'key' : 'keys'}.`);
this.onStorageChanged();
}),
)
.addButton(button =>
button.setButtonText('Clear all').onClick(() => {
const deletedCount = ScriptStorageManager.deleteAllKnown(this.app);
new Notice(`Cleared ${deletedCount} script storage ${deletedCount === 1 ? 'key' : 'keys'}.`);
this.onStorageChanged();
}),
);
this.renderScriptStorageList(new SettingGroup(containerEl), entries);
}
private renderScriptStorageList(group: SettingGroup, entries: ScriptStorageEntry[]): void {
if (entries.length === 0) {
group.addSetting(setting => {
setting.setName('No indexed script storage keys are stored.');
});
return;
}
for (const entry of entries.slice(0, 20)) {
group.addSetting(setting => {
setting.setName(entry.key).setDesc(`${formatScope(entry.scope)} - ${formatBytes(entry.sizeBytes)} - ${formatDate(entry.updatedAt)}`);
});
}
if (entries.length > 20) {
group.addSetting(setting => {
setting.setName(`${entries.length - 20} more storage keys are hidden.`);
});
}
}
private createSection(containerEl: HTMLElement, heading: string): void {
new Setting(containerEl).setName(heading).setHeading();
}
}
function formatDate(timestamp: number): string {
return new Date(timestamp).toLocaleString();
}
function formatBytes(bytes: number): string {
if (bytes < 1024) {
return `${bytes} B`;
}
return `${(bytes / 1024).toFixed(1)} KB`;
}
function formatCaller(callerPluginId: string | undefined): string {
return callerPluginId ?? 'notes';
}
function formatScope(scope: string | null): string {
return scope === null ? 'global' : `scoped ${scope.slice(0, 12)}`;
}

View file

@ -0,0 +1,154 @@
import type { App } from 'obsidian';
import { Setting, SettingGroup } from 'obsidian';
import type SafeJsPlugin from 'packages/obsidian/src/main';
import type { SafeJsScriptConfig } from 'packages/obsidian/src/scripts/script-settings';
import { createScriptConfig, displayNameFromPath, isJavaScriptVaultScriptPath } from 'packages/obsidian/src/scripts/script-settings';
import type { VaultScriptManager } from 'packages/obsidian/src/scripts/vault-script-manager';
import type { AddVaultScriptModalValues } from 'packages/obsidian/src/ui/add-vault-script-modal';
import { AddVaultScriptModal } from 'packages/obsidian/src/ui/add-vault-script-modal';
export class VaultScriptsSettingsSection {
private readonly app: App;
private readonly onSettingsChanged: () => void;
private readonly plugin: SafeJsPlugin;
private readonly scriptManager?: VaultScriptManager;
constructor(app: App, plugin: SafeJsPlugin, onSettingsChanged: () => void, scriptManager?: VaultScriptManager) {
this.app = app;
this.onSettingsChanged = onSettingsChanged;
this.plugin = plugin;
this.scriptManager = scriptManager;
}
render(containerEl: HTMLElement): void {
this.createSection(containerEl, 'Vault scripts');
const group = new SettingGroup(containerEl);
for (const script of this.plugin.settings.scripts) {
this.renderVaultScript(group, script);
}
this.renderAddVaultScript(group);
}
private renderVaultScript(group: SettingGroup, script: SafeJsScriptConfig): void {
group.addSetting(setting => {
setting
.setName(script.name)
.setDesc(formatVaultScriptDescription(script))
.addButton(button =>
button.setButtonText('Edit').onClick(() => {
this.openEditVaultScriptModal(script);
}),
)
.addButton(button =>
button.setButtonText('Remove').onClick(() => {
void this.removeVaultScript(script);
}),
);
});
}
private renderAddVaultScript(group: SettingGroup): void {
group.addSetting(setting => {
setting
.setName('Add script')
.setDesc('Configure a vault .js file as a command.')
.addButton(button =>
button
.setButtonText('Add script')
.setCta()
.onClick(() => {
this.openAddVaultScriptModal();
}),
);
});
}
private openAddVaultScriptModal(): void {
new AddVaultScriptModal(
this.app,
{
actionText: 'Add',
initialValues: {
name: '',
path: '',
runOnStartup: false,
},
title: 'Add script',
},
async values => await this.addVaultScript(values),
).open();
}
private async removeVaultScript(script: SafeJsScriptConfig): Promise<void> {
this.plugin.settings.scripts = this.plugin.settings.scripts.filter(candidate => candidate.id !== script.id);
await this.saveScriptSettings();
this.onSettingsChanged();
}
private openEditVaultScriptModal(script: SafeJsScriptConfig): void {
new AddVaultScriptModal(
this.app,
{
actionText: 'Save',
initialValues: {
name: script.name,
path: script.path,
runOnStartup: script.runOnStartup,
},
title: 'Edit script',
},
async values => await this.editVaultScript(script, values),
).open();
}
private async addVaultScript(values: AddVaultScriptModalValues): Promise<{ message?: string; saved: boolean }> {
const normalizedPath = values.path.trim();
if (normalizedPath === '' || !isJavaScriptVaultScriptPath(normalizedPath)) {
return { message: 'Enter a vault path ending in .js.', saved: false };
}
if (this.plugin.settings.scripts.some(script => script.path === normalizedPath)) {
return { message: 'That script is already configured.', saved: false };
}
const script = createScriptConfig(normalizedPath, values.name, this.plugin.settings.scripts);
script.runOnStartup = values.runOnStartup;
this.plugin.settings.scripts = [...this.plugin.settings.scripts, script];
await this.saveScriptSettings();
this.onSettingsChanged();
return { saved: true };
}
private async editVaultScript(script: SafeJsScriptConfig, values: AddVaultScriptModalValues): Promise<{ message?: string; saved: boolean }> {
const normalizedPath = values.path.trim();
if (normalizedPath === '' || !isJavaScriptVaultScriptPath(normalizedPath)) {
return { message: 'Enter a vault path ending in .js.', saved: false };
}
if (this.plugin.settings.scripts.some(candidate => candidate.id !== script.id && candidate.path === normalizedPath)) {
return { message: 'That script is already configured.', saved: false };
}
script.name = values.name.trim() || displayNameFromPath(normalizedPath);
script.path = normalizedPath;
script.runOnStartup = values.runOnStartup;
await this.saveScriptSettings();
this.onSettingsChanged();
return { saved: true };
}
private async saveScriptSettings(): Promise<void> {
await this.plugin.saveSettings();
this.scriptManager?.reloadCommands();
}
private createSection(containerEl: HTMLElement, heading: string): void {
new Setting(containerEl).setName(heading).setHeading();
}
}
function formatVaultScriptDescription(script: SafeJsScriptConfig): string {
return script.runOnStartup ? `${script.path} - Runs on startup` : script.path;
}

View file

@ -53,7 +53,11 @@ function normalizeParams(binding: WorkerRpcBinding, args: unknown[]): JsonValue
}
const params = args[0] ?? {};
return isJsonValue(params) ? params : {};
if (!isJsonValue(params)) {
throw new Error('RPC object parameters must be JSON-safe.');
}
return params;
}
function normalizeNamedArgs(binding: WorkerRpcBinding, args: unknown[]): JsonValue {
@ -62,7 +66,11 @@ function normalizeNamedArgs(binding: WorkerRpcBinding, args: unknown[]): JsonVal
for (const [index, name] of argNames.entries()) {
const value = args[index];
if (value !== undefined) {
params[name] = isJsonValue(value) ? value : null;
if (!isJsonValue(value)) {
throw new Error(`RPC argument '${name}' must be JSON-safe.`);
}
params[name] = value;
}
}

View file

@ -126,8 +126,8 @@ async function waitForWorker(workerFactory: { workers: unknown[] }): Promise<voi
}
function createRegistry(): RpcRegistry {
return new RpcRegistry(
[
return new RpcRegistry({
methods: [
{
method: 'test:echo',
permission: 'test:call',
@ -143,9 +143,8 @@ function createRegistry(): RpcRegistry {
handler: params => params,
},
],
undefined,
testValidatorOptions,
);
validators: testValidatorOptions,
});
}
function createService(options: { promptApproved: boolean; store?: MemoryPermissionApprovalStore; workerFactory?: FakeWorkerFactory }) {
@ -175,8 +174,8 @@ function createLowRiskService(options: { autoAllowLowRiskPermissions: boolean })
const workerFactory = new FakeWorkerFactory();
const store = new MemoryPermissionApprovalStore();
const service = new SafeJsExecutionService({
rpcRegistry: new RpcRegistry(
[
rpcRegistry: new RpcRegistry({
methods: [
{
method: 'test:echo',
permission: 'test:call',
@ -192,7 +191,7 @@ function createLowRiskService(options: { autoAllowLowRiskPermissions: boolean })
handler: params => params,
},
],
[
permissionDefinitions: [
{
id: 'test:call',
name: 'Test calls',
@ -201,8 +200,8 @@ function createLowRiskService(options: { autoAllowLowRiskPermissions: boolean })
grantGuidance: 'Grant in tests.',
},
],
testValidatorOptions,
),
validators: testValidatorOptions,
}),
approvalStore: store,
permissionPrompt: prompt,
workerFactory,
@ -426,8 +425,8 @@ test('measures execution elapsed time after permission approval', async () => {
})(true);
const workerFactory = new FakeWorkerFactory();
const service = new SafeJsExecutionService({
rpcRegistry: new RpcRegistry(
[
rpcRegistry: new RpcRegistry({
methods: [
{
method: 'test:echo',
permission: 'test:call',
@ -446,9 +445,8 @@ test('measures execution elapsed time after permission approval', async () => {
},
},
],
undefined,
testValidatorOptions,
),
validators: testValidatorOptions,
}),
approvalStore: new MemoryPermissionApprovalStore(),
permissionPrompt: prompt,
workerFactory,

View file

@ -45,7 +45,10 @@ mock.module('obsidian', () => ({
async function createRegistry(): Promise<RpcRegistry> {
const { createHelperMethods } = await import('packages/obsidian/src/rpc/obsidian/helper-rpc');
return new RpcRegistry(createHelperMethods(), undefined, testValidatorOptions);
return new RpcRegistry({
methods: createHelperMethods(),
validators: testValidatorOptions,
});
}
test('registers helper methods under the helper permission', async () => {

View file

@ -84,7 +84,7 @@ function createPlugin(): { plugin: Plugin; unload(): void } {
test('public caller API stamps execution source with caller plugin metadata', async () => {
const executionService = new FakeExecutionService();
const registry = new RpcRegistry([], undefined, testValidatorOptions);
const registry = new RpcRegistry({ validators: testValidatorOptions });
const { plugin } = createPlugin();
const safeJsApi = new DefaultSafeJsPublicApi({
executionService: executionService as unknown as SafeJsExecutionService,
@ -106,7 +106,7 @@ test('public caller API stamps execution source with caller plugin metadata', as
test('public caller API registers owned sandbox functions and cleans them up on unload', async () => {
const executionService = new FakeExecutionService();
const registry = new RpcRegistry([], undefined, testValidatorOptions);
const registry = new RpcRegistry({ validators: testValidatorOptions });
const { plugin, unload } = createPlugin();
const callerApi = new DefaultSafeJsPublicApi({
executionService: executionService as unknown as SafeJsExecutionService,
@ -154,7 +154,7 @@ test('public caller API registers owned sandbox functions and cleans them up on
test('public caller API rejects sandbox functions with unknown permissions', () => {
const executionService = new FakeExecutionService();
const registry = new RpcRegistry([], undefined, testValidatorOptions);
const registry = new RpcRegistry({ validators: testValidatorOptions });
const { plugin } = createPlugin();
const callerApi = new DefaultSafeJsPublicApi({
executionService: executionService as unknown as SafeJsExecutionService,
@ -182,7 +182,7 @@ test('public caller API rejects sandbox functions with unknown permissions', ()
test('public caller API exposes built-in validator IDs without exposing zod', () => {
const executionService = new FakeExecutionService();
const registry = new RpcRegistry([], undefined, testValidatorOptions);
const registry = new RpcRegistry({ validators: testValidatorOptions });
const { plugin } = createPlugin();
const callerApi = new DefaultSafeJsPublicApi({
executionService: executionService as unknown as SafeJsExecutionService,

View file

@ -20,8 +20,8 @@ function echoValueValidator(value: unknown): SafeJsValidationResult<EchoValue> {
}
function createTestRegistry(): RpcRegistry {
return new RpcRegistry(
[
return new RpcRegistry({
methods: [
{
method: 'test:echo',
permission: 'test:call',
@ -38,9 +38,8 @@ function createTestRegistry(): RpcRegistry {
handler: params => params,
},
],
undefined,
testValidatorOptions,
);
validators: testValidatorOptions,
});
}
test('dispatches valid RPC calls', async () => {
@ -94,8 +93,8 @@ test('exposes worker bindings with argument metadata', () => {
});
test('generates docs from registered permission and method definitions', () => {
const registry = new RpcRegistry(
[
const registry = new RpcRegistry({
methods: [
{
method: 'test:echo',
permission: 'test:call',
@ -111,7 +110,7 @@ test('generates docs from registered permission and method definitions', () => {
handler: params => params,
},
],
[
permissionDefinitions: [
{
id: 'test:call',
name: 'Test calls',
@ -120,8 +119,8 @@ test('generates docs from registered permission and method definitions', () => {
grantGuidance: 'Grant in tests.',
},
],
testValidatorOptions,
);
validators: testValidatorOptions,
});
const docs = registry.getDocs();
expect(docs).toHaveLength(1);
@ -130,7 +129,7 @@ test('generates docs from registered permission and method definitions', () => {
});
test('registers owned custom permissions, methods, and globals', () => {
const registry = new RpcRegistry([], undefined, testValidatorOptions);
const registry = new RpcRegistry({ validators: testValidatorOptions });
registry.registerPermission(
{
id: 'plugin:call',
@ -201,7 +200,7 @@ test('registers owned custom permissions, methods, and globals', () => {
});
test('exposes standalone built-in permissions', () => {
const registry = new RpcRegistry([], undefined, testValidatorOptions);
const registry = new RpcRegistry({ validators: testValidatorOptions });
expect(registry.getKnownPermissions()).toContain('output:render-rich');
expect(registry.getDocs().find(group => group.permission.id === 'output:render-rich')).toMatchObject({
@ -244,7 +243,7 @@ test('rejects duplicate sandbox API paths and reserved globals', () => {
});
test('rejects sandbox globals with non-json values', () => {
const registry = new RpcRegistry([], undefined, testValidatorOptions);
const registry = new RpcRegistry({ validators: testValidatorOptions });
expect(() => {
registry.registerSandboxGlobal({
@ -256,7 +255,7 @@ test('rejects sandbox globals with non-json values', () => {
});
test('keeps owned permissions while sandbox globals still reference them', () => {
const registry = new RpcRegistry([], undefined, testValidatorOptions);
const registry = new RpcRegistry({ validators: testValidatorOptions });
registry.registerPermission(
{
id: 'plugin:shared',
@ -285,8 +284,8 @@ test('keeps owned permissions while sandbox globals still reference them', () =>
});
test('dispatches methods that reference built-in validators by id', async () => {
const registry = new RpcRegistry(
[
const registry = new RpcRegistry({
methods: [
{
method: 'test:ping',
permission: 'test:call',
@ -302,9 +301,8 @@ test('dispatches methods that reference built-in validators by id', async () =>
handler: () => ({ ok: true }),
},
],
undefined,
testValidatorOptions,
);
validators: testValidatorOptions,
});
expect(await registry.dispatch('test:ping', {}, { grantedPermissions: new Set(['test:call']) })).toEqual({
ok: true,
@ -315,7 +313,7 @@ test('dispatches methods that reference built-in validators by id', async () =>
test('built-in vault path validators read the current config directory', () => {
let configDir = '_config-a';
const registry = new RpcRegistry([], undefined, createBuiltInValidators({ getConfigDir: () => configDir }));
const registry = new RpcRegistry({ validators: createBuiltInValidators({ getConfigDir: () => configDir }) });
expect(registry.validate('vault:path', '_config-a/plugins/safe-js/data.json')).toMatchObject({
success: false,
@ -338,8 +336,8 @@ test('built-in vault path validators read the current config directory', () => {
test('rejects methods that reference unknown validator ids', () => {
expect(() => {
new RpcRegistry(
[
new RpcRegistry({
methods: [
{
method: 'test:bad',
permission: 'test:call',
@ -355,8 +353,7 @@ test('rejects methods that reference unknown validator ids', () => {
handler: () => ({ ok: true }),
},
],
undefined,
testValidatorOptions,
);
validators: testValidatorOptions,
});
}).toThrow("Unknown request validator 'missing:validator'");
});

View file

@ -71,8 +71,8 @@ function createSession(workerFactory: WorkerFactory, registry: RpcRegistry): Wor
}
function createRegistry(): RpcRegistry {
return new RpcRegistry(
[
return new RpcRegistry({
methods: [
{
method: 'vault:read',
permission: 'vault:read',
@ -97,9 +97,8 @@ function createRegistry(): RpcRegistry {
},
},
],
undefined,
testValidatorOptions,
);
validators: testValidatorOptions,
});
}
test('sandbox global documentation only advertises the intentional host surface', () => {