Compare commits

..

4 Commits

20 changed files with 278 additions and 9 deletions

2
.gitignore vendored
View File

@ -1 +1,3 @@
node_modules/
dist/
release/

17
LICENSE Normal file
View File

@ -0,0 +1,17 @@
Verstak Plugin SDK
Copyright (C) 2026 Verstak contributors
This program is free software: you can redistribute it and/or modify it under
the terms of the GNU Affero General Public License as published by the Free
Software Foundation, either version 3 of the License, or (at your option) any
later version.
This program is distributed in the hope that it will be useful, but WITHOUT
ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
details.
The complete license text is available at:
https://www.gnu.org/licenses/agpl-3.0.html
SPDX-License-Identifier: AGPL-3.0-or-later

View File

@ -1,6 +1,38 @@
# verstak-sdk
# Verstak Plugin SDK
Verstak Plugin SDK — manifest schema, TypeScript SDK, RPC protocol, capability contracts, event schemas, test helpers, packaging tools
TypeScript API, JSON schemas and contract tests for plugins running in Verstak
Desktop. The SDK is versioned independently so plugin authors can validate
their manifests and compile against the public host API.
> **Alpha contract.** This is the first public alpha. Keep SDK, Desktop and
> official-plugin versions in the same release line while APIs are evolving.
## Install and verify
```bash
npm ci
npm run lint
npm test
npm run build
```
The build emits `dist/`. A packed npm artifact can be made locally with:
```bash
./scripts/release.sh v0.1.0
```
It validates the requested version against `package.json`, then writes an npm
tarball and `SHA256SUMS` to `release/`.
## Contracts relevant to the alpha
- Workspaces have durable UUID identities; paths are addresses, not identity.
- Activity may be scoped to `workspaceId` or to explicit `unassigned` work.
- The `hostname-normalization-v1.json` vectors define the shared canonical
browser-domain representation used by Desktop and the extension.
- Browser activity batches contain only a normalized hostname and bounded
duration. Manual captures use a separate Inbox protocol.
## Bundled Frontend API Contract
@ -40,3 +72,8 @@ or trash metadata.
Bundled frontend plugins are trusted/cooperative and run in the desktop JS
context. Current permission checks are contract checks, not a security boundary;
real isolation belongs to a later sidecar/sandbox milestone.
## License
Copyright © 2026 Verstak contributors. Licensed under
[GNU AGPLv3 or later](LICENSE).

View File

@ -1,6 +1,8 @@
import type { CapabilityEntry, FileBytes, FileEntry, FileMetadata, MovePathOptions, OpenResourceRequest, OpenResourceResult, PluginSettings, RegisteredContributionPoints, RestoreTrashOptions, TrashEntry, TrashResult, WriteTextOptions } from './types';
export type PluginCommandArgs = Record<string, unknown>;
export type PluginDataJSON = Record<string, unknown>;
export type PluginLocale = 'ru' | 'en';
export type TranslationParams = Record<string, string | number>;
export type PluginCommandHandler = (args: PluginCommandArgs, declaration: PluginCommandDeclaration) => unknown | Promise<unknown>;
export type Unsubscribe = () => void;
export interface PluginCommandDeclaration {
@ -61,6 +63,11 @@ export interface BrowserReceiverPairing {
}
export interface VerstakPluginAPI {
readonly pluginId: string;
i18n: {
getLocale(): PluginLocale;
t(key: string, params?: TranslationParams, fallback?: string): string;
onDidChangeLocale(listener: (locale: PluginLocale) => void): Unsubscribe;
};
settings: {
read(): Promise<PluginSettings>;
read<T = unknown>(key: string): Promise<T | undefined>;

File diff suppressed because one or more lines are too long

View File

@ -1 +1 @@
{"version":3,"file":"plugin-api.js","sourceRoot":"","sources":["../src/plugin-api.ts"],"names":[],"mappings":"AAAA,6DAA6D;AAC7D,EAAE;AACF,8EAA8E;AAC9E,gFAAgF;AAChF,8EAA8E;AAC9E,gDAAgD;AAsLhD,MAAM,UAAU,eAAe,CAAC,SAAiB;IAC/C,MAAM,IAAI,KAAK,CAAC,kEAAkE,CAAC,CAAC;AACtF,CAAC"}
{"version":3,"file":"plugin-api.js","sourceRoot":"","sources":["../src/plugin-api.ts"],"names":[],"mappings":"AAAA,6DAA6D;AAC7D,EAAE;AACF,8EAA8E;AAC9E,gFAAgF;AAChF,8EAA8E;AAC9E,gDAAgD;AA8LhD,MAAM,UAAU,eAAe,CAAC,SAAiB;IAC/C,MAAM,IAAI,KAAK,CAAC,kEAAkE,CAAC,CAAC;AACtF,CAAC"}

View File

@ -1,7 +1,10 @@
import type { PluginManifest, PluginState, RegisteredContributionPoints } from './types';
import type { VerstakPluginAPI } from './plugin-api';
import type { PluginLocale, VerstakPluginAPI } from './plugin-api';
export interface MockPluginAPIOptions {
contributions?: RegisteredContributionPoints;
locale?: PluginLocale;
defaultLocale?: PluginLocale;
messages?: Partial<Record<PluginLocale, Record<string, string>>>;
}
/**
* Создать тестовый manifest для unit-тестов.

View File

@ -1 +1 @@
{"version":3,"file":"test-utils.d.ts","sourceRoot":"","sources":["../src/test-utils.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,cAAc,EAAE,WAAW,EAAE,4BAA4B,EAAE,MAAM,SAAS,CAAC;AACzF,OAAO,KAAK,EAAwB,gBAAgB,EAAE,MAAM,cAAc,CAAC;AAQ3E,MAAM,WAAW,oBAAoB;IACnC,aAAa,CAAC,EAAE,4BAA4B,CAAC;CAC9C;AAED;;GAEG;AACH,wBAAgB,kBAAkB,CAAC,SAAS,CAAC,EAAE,OAAO,CAAC,cAAc,CAAC,GAAG,cAAc,CAetF;AAED;;GAEG;AACH,wBAAgB,qBAAqB,CAAC,SAAS,CAAC,EAAE,OAAO,CAAC,WAAW,CAAC,GAAG,WAAW,CASnF;AAED;;GAEG;AACH,wBAAgB,mBAAmB,CAAC,QAAQ,SAAgB,EAAE,OAAO,GAAE,oBAAyB,GAAG,gBAAgB,CA4WlH;AAED;;GAEG;AACH,wBAAgB,gBAAgB,CAAC,QAAQ,EAAE,OAAO,GAAG;IAAE,KAAK,EAAE,OAAO,CAAC;IAAC,MAAM,EAAE,MAAM,EAAE,CAAA;CAAE,CAgCxF;AAGD,OAAO,EAAE,EAAE,EAAE,MAAM,QAAQ,CAAC;AAC5B,OAAO,EAAE,EAAE,EAAE,CAAC"}
{"version":3,"file":"test-utils.d.ts","sourceRoot":"","sources":["../src/test-utils.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,cAAc,EAAE,WAAW,EAAE,4BAA4B,EAAE,MAAM,SAAS,CAAC;AACzF,OAAO,KAAK,EAAwB,YAAY,EAAqB,gBAAgB,EAAE,MAAM,cAAc,CAAC;AAQ5G,MAAM,WAAW,oBAAoB;IACnC,aAAa,CAAC,EAAE,4BAA4B,CAAC;IAC7C,MAAM,CAAC,EAAE,YAAY,CAAC;IACtB,aAAa,CAAC,EAAE,YAAY,CAAC;IAC7B,QAAQ,CAAC,EAAE,OAAO,CAAC,MAAM,CAAC,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC,CAAC;CAClE;AASD;;GAEG;AACH,wBAAgB,kBAAkB,CAAC,SAAS,CAAC,EAAE,OAAO,CAAC,cAAc,CAAC,GAAG,cAAc,CAetF;AAED;;GAEG;AACH,wBAAgB,qBAAqB,CAAC,SAAS,CAAC,EAAE,OAAO,CAAC,WAAW,CAAC,GAAG,WAAW,CASnF;AAED;;GAEG;AACH,wBAAgB,mBAAmB,CAAC,QAAQ,SAAgB,EAAE,OAAO,GAAE,oBAAyB,GAAG,gBAAgB,CA0XlH;AAED;;GAEG;AACH,wBAAgB,gBAAgB,CAAC,QAAQ,EAAE,OAAO,GAAG;IAAE,KAAK,EAAE,OAAO,CAAC;IAAC,MAAM,EAAE,MAAM,EAAE,CAAA;CAAE,CAgCxF;AAGD,OAAO,EAAE,EAAE,EAAE,MAAM,QAAQ,CAAC;AAC5B,OAAO,EAAE,EAAE,EAAE,CAAC"}

19
dist/test-utils.js vendored
View File

@ -3,6 +3,11 @@ const mockCommandHandlers = new Map();
function commandKey(pluginId, commandId) {
return `${pluginId}:${commandId}`;
}
function interpolateMessage(message, params) {
if (!params)
return message;
return message.replace(/\{([A-Za-z0-9_.-]+)\}/g, (placeholder, name) => (Object.prototype.hasOwnProperty.call(params, name) ? String(params[name]) : placeholder));
}
/**
* Создать тестовый manifest для unit-тестов.
*/
@ -39,6 +44,9 @@ export function createTestPluginState(overrides) {
* Создать заглушку VerstakPluginAPI для тестов.
*/
export function createMockPluginAPI(pluginId = 'test.plugin', options = {}) {
const locale = options.locale || 'en';
const defaultLocale = options.defaultLocale || 'en';
const messages = options.messages || {};
const settings = {};
const pluginData = new Map();
const commands = new Map();
@ -129,6 +137,17 @@ export function createMockPluginAPI(pluginId = 'test.plugin', options = {}) {
}
return {
pluginId,
i18n: {
getLocale: vi.fn(() => locale),
t: vi.fn((key, params, fallback) => {
const message = messages[locale]?.[key]
?? messages[defaultLocale]?.[key]
?? fallback
?? key;
return interpolateMessage(message, params);
}),
onDidChangeLocale: vi.fn((_listener) => () => { }),
},
settings: {
read: vi.fn(async (key) => key ? settings[key] : { ...settings }),
write: vi.fn(async (key, value) => {

File diff suppressed because one or more lines are too long

5
dist/types.d.ts vendored
View File

@ -1,4 +1,8 @@
export type PluginSource = 'official' | 'local' | 'third-party';
export interface PluginLocalizationConfig {
defaultLocale: string;
locales: Record<string, string>;
}
export interface PluginManifest {
schemaVersion: 1;
id: string;
@ -8,6 +12,7 @@ export interface PluginManifest {
description?: string;
source?: PluginSource;
icon?: string;
localization?: PluginLocalizationConfig;
provides: string[];
requires?: string[];
optionalRequires?: string[];

2
dist/types.d.ts.map vendored

File diff suppressed because one or more lines are too long

View File

@ -2,6 +2,7 @@
"name": "@verstak/plugin-sdk",
"version": "0.1.0",
"description": "Verstak Plugin SDK — TypeScript types and runtime API for plugin development",
"license": "AGPL-3.0-or-later",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"files": [

View File

@ -0,0 +1,34 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://git.mirv.top/verstak/verstak-sdk/schemas/hostname-normalization-v1.json",
"title": "Verstak canonical hostname normalization v1 test vectors",
"description": "Canonical hostnames are lowercase ASCII A-labels without a port or trailing DNS dot. Bare hostnames accept DNS names, IPv4, bracketed IPv6, localhost, and internal single-label names. URL inputs accept only HTTP(S). Invalid or excessively long input normalizes to an empty string.",
"version": 1,
"bare": [
{ "input": "example.com", "output": "example.com" },
{ "input": " Example.COM. ", "output": "example.com" },
{ "input": "пример.рф", "output": "xn--e1afmkfd.xn--p1ai" },
{ "input": "bücher.example", "output": "xn--bcher-kva.example" },
{ "input": "127.0.0.1", "output": "127.0.0.1" },
{ "input": "[2001:db8::1]", "output": "2001:db8::1" },
{ "input": "localhost", "output": "localhost" },
{ "input": "intranet", "output": "intranet" },
{ "input": "", "output": "" },
{ "input": "https://example.com", "output": "" },
{ "input": "example.com:443", "output": "" },
{ "input": "user@example.com", "output": "" },
{ "input": "bad host", "output": "" },
{ "input": "example..com", "output": "" },
{ "input": "example.com..", "output": "" },
{ "input": "127.000.000.001", "output": "" },
{ "input": "[2001:db8::1]:443", "output": "" },
{ "input": "a...............................................................example", "output": "" }
],
"url": [
{ "input": "https://пример.рф/path", "output": "xn--e1afmkfd.xn--p1ai" },
{ "input": "http://Example.COM.:8080/path", "output": "example.com" },
{ "input": "https://[2001:db8::1]/", "output": "2001:db8::1" },
{ "input": "ftp://example.com/path", "output": "" },
{ "input": "not a URL", "output": "" }
]
}

View File

@ -51,6 +51,31 @@
"type": "string",
"description": "Path to plugin icon (relative to plugin root)"
},
"localization": {
"type": "object",
"description": "Plugin-owned localization catalogs",
"properties": {
"defaultLocale": {
"type": "string",
"description": "Default locale used when the selected locale is unavailable",
"pattern": "^[a-z]{2}(?:-[a-z0-9]+)*$"
},
"locales": {
"type": "object",
"description": "Locale tags mapped to plugin-relative JSON catalog paths",
"propertyNames": {
"pattern": "^[a-z]{2}(?:-[a-z0-9]+)*$"
},
"additionalProperties": {
"type": "string",
"pattern": "^(?![\\\\/])(?!.*(?:^|/)\\.\\.(?:/|$))(?!.*\\\\).+$"
},
"minProperties": 1
}
},
"required": ["defaultLocale", "locales"],
"additionalProperties": false
},
"provides": {
"type": "array",
"description": "Capabilities this plugin provides",

28
scripts/release.sh Executable file
View File

@ -0,0 +1,28 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
REQUESTED_VERSION="${1:-}"
PACKAGE_VERSION="$(node -p "require('$ROOT/package.json').version")"
if [[ -z "$REQUESTED_VERSION" ]]; then
echo "usage: $0 <version>" >&2
echo "example: $0 v0.1.0" >&2
exit 2
fi
if [[ "${REQUESTED_VERSION#v}" != "$PACKAGE_VERSION" ]]; then
echo "requested version $REQUESTED_VERSION does not match package.json $PACKAGE_VERSION" >&2
exit 1
fi
echo "=== verstak sdk release $PACKAGE_VERSION ==="
(cd "$ROOT" && npm ci --no-audit --no-fund && npm run lint && npm test && npm run build)
RELEASE_ROOT="$ROOT/release"
rm -rf "$RELEASE_ROOT"
mkdir -p "$RELEASE_ROOT"
(cd "$ROOT" && npm pack --pack-destination "$RELEASE_ROOT")
(cd "$RELEASE_ROOT" && sha256sum ./*.tgz > SHA256SUMS)
echo "release package: $RELEASE_ROOT"
echo "checksums: $RELEASE_ROOT/SHA256SUMS"

View File

@ -16,6 +16,9 @@ describe('VerstakPluginAPI contract', () => {
expect(typeof api.settings.write).toBe('function');
expect(typeof api.storage.data.read).toBe('function');
expect(typeof api.storage.data.write).toBe('function');
expect(typeof api.i18n.getLocale).toBe('function');
expect(typeof api.i18n.t).toBe('function');
expect(typeof api.i18n.onDidChangeLocale).toBe('function');
expect(typeof api.ui.openSettings).toBe('function');
expect(typeof api.capabilities.list).toBe('function');
expect(typeof api.commands.register).toBe('function');
@ -58,6 +61,56 @@ describe('VerstakPluginAPI contract', () => {
expect(permissionEnum).toContain('workbench.open');
});
test('manifest schema declares safe plugin localization catalogs', () => {
const localization = (manifestSchema as any).properties.localization;
expect(localization.type).toBe('object');
expect(localization.required).toEqual(['defaultLocale', 'locales']);
expect(localization.properties.defaultLocale.pattern).toBe('^[a-z]{2}(?:-[a-z0-9]+)*$');
expect(localization.properties.locales.propertyNames.pattern).toBe('^[a-z]{2}(?:-[a-z0-9]+)*$');
expect(localization.properties.locales.additionalProperties.pattern).toBe('^(?![\\\\/])(?!.*(?:^|/)\\.\\.(?:/|$))(?!.*\\\\).+$');
});
test('manifest types accept plugin-owned localization catalogs', () => {
const manifest: PluginManifest = {
schemaVersion: 1,
id: 'localized.plugin',
name: 'Localized Plugin',
version: '0.1.0',
apiVersion: '0.1.0',
provides: ['localized.example'],
permissions: ['ui.register'],
localization: {
defaultLocale: 'en',
locales: {
en: 'locales/en.json',
ru: 'locales/ru.json',
},
},
};
expect(manifest.localization?.defaultLocale).toBe('en');
expect(manifest.localization?.locales.ru).toBe('locales/ru.json');
});
test('mock i18n translates with fallback and named interpolation', () => {
const api = createMockPluginAPI('localized.plugin', {
locale: 'ru',
messages: {
en: { greeting: 'Hello, {name}!', onlyEnglish: 'English fallback' },
ru: { greeting: 'Привет, {name}!' },
},
defaultLocale: 'en',
});
expect(api.i18n.getLocale()).toBe('ru');
expect(api.i18n.t('greeting', { name: 'Мир' })).toBe('Привет, Мир!');
expect(api.i18n.t('onlyEnglish')).toBe('English fallback');
expect(api.i18n.t('missing', undefined, 'Fallback')).toBe('Fallback');
expect(api.i18n.t('unknown')).toBe('unknown');
expect(typeof api.i18n.onDidChangeLocale(() => {})).toBe('function');
});
test('secrets capability and permissions are declared as dangerous platform contract', () => {
const capabilities = ((capabilitiesSchema as any).capabilities || []) as Array<{ name: string; status: string }>;
const permissions = ((permissionsSchema as any).permissions || []) as Array<{ name: string; dangerous: boolean }>;

View File

@ -23,6 +23,8 @@ import type {
export type PluginCommandArgs = Record<string, unknown>;
export type PluginDataJSON = Record<string, unknown>;
export type PluginLocale = 'ru' | 'en';
export type TranslationParams = Record<string, string | number>;
export type PluginCommandHandler = (
args: PluginCommandArgs,
declaration: PluginCommandDeclaration
@ -95,6 +97,12 @@ export interface BrowserReceiverPairing {
export interface VerstakPluginAPI {
readonly pluginId: string;
i18n: {
getLocale(): PluginLocale;
t(key: string, params?: TranslationParams, fallback?: string): string;
onDidChangeLocale(listener: (locale: PluginLocale) => void): Unsubscribe;
};
settings: {
read(): Promise<PluginSettings>;
read<T = unknown>(key: string): Promise<T | undefined>;

View File

@ -1,7 +1,7 @@
// Verstak Plugin SDK — Test Utilities
import type { PluginManifest, PluginState, RegisteredContributionPoints } from './types';
import type { PluginCommandHandler, VerstakPluginAPI } from './plugin-api';
import type { PluginCommandHandler, PluginLocale, TranslationParams, VerstakPluginAPI } from './plugin-api';
const mockCommandHandlers = new Map<string, PluginCommandHandler>();
@ -11,6 +11,16 @@ function commandKey(pluginId: string, commandId: string): string {
export interface MockPluginAPIOptions {
contributions?: RegisteredContributionPoints;
locale?: PluginLocale;
defaultLocale?: PluginLocale;
messages?: Partial<Record<PluginLocale, Record<string, string>>>;
}
function interpolateMessage(message: string, params?: TranslationParams): string {
if (!params) return message;
return message.replace(/\{([A-Za-z0-9_.-]+)\}/g, (placeholder, name: string) => (
Object.prototype.hasOwnProperty.call(params, name) ? String(params[name]) : placeholder
));
}
/**
@ -51,6 +61,9 @@ export function createTestPluginState(overrides?: Partial<PluginState>): PluginS
* Создать заглушку VerstakPluginAPI для тестов.
*/
export function createMockPluginAPI(pluginId = 'test.plugin', options: MockPluginAPIOptions = {}): VerstakPluginAPI {
const locale = options.locale || 'en';
const defaultLocale = options.defaultLocale || 'en';
const messages = options.messages || {};
const settings: Record<string, unknown> = {};
const pluginData = new Map<string, Record<string, unknown>>();
const commands = new Map<string, PluginCommandHandler>();
@ -139,6 +152,17 @@ export function createMockPluginAPI(pluginId = 'test.plugin', options: MockPlugi
return {
pluginId,
i18n: {
getLocale: vi.fn(() => locale),
t: vi.fn((key: string, params?: TranslationParams, fallback?: string) => {
const message = messages[locale]?.[key]
?? messages[defaultLocale]?.[key]
?? fallback
?? key;
return interpolateMessage(message, params);
}),
onDidChangeLocale: vi.fn((_listener: (nextLocale: PluginLocale) => void) => () => {}),
},
settings: {
read: vi.fn(async (key?: string) => key ? settings[key] : { ...settings }) as VerstakPluginAPI['settings']['read'],
write: vi.fn(async (key: string, value: unknown) => {

View File

@ -4,6 +4,11 @@
export type PluginSource = 'official' | 'local' | 'third-party';
export interface PluginLocalizationConfig {
defaultLocale: string;
locales: Record<string, string>;
}
export interface PluginManifest {
schemaVersion: 1;
id: string;
@ -13,6 +18,7 @@ export interface PluginManifest {
description?: string;
source?: PluginSource;
icon?: string;
localization?: PluginLocalizationConfig;
provides: string[];
requires?: string[];
optionalRequires?: string[];