From fdefaa5f20f9d6006b846e618d70ae84572101fa Mon Sep 17 00:00:00 2001 From: hkimw <54717101+hkimw@users.noreply.github.com> Date: Mon, 5 Oct 2026 00:50:25 +0900 Subject: [PATCH] feat: release 0.12 settings structured commands and localization --- CHANGELOG.md | 16 + PUBLIC_SURFACE.json | 75 +- README.md | 10 +- docs/COMPATIBILITY.md | 18 +- docs/DATA_SCHEMAS.md | 83 ++ docs/DEVTOOLS.md | 2 +- docs/LOCALIZATION.md | 92 ++ docs/PUBLISHING.md | 14 +- docs/SECRETS.md | 72 ++ docs/SETTINGS.md | 78 ++ docs/VERIFYING.md | 20 +- docs/api.md | 14 +- docs/host-contract.md | 6 + examples/configuration/LICENSE | 202 ++++ examples/configuration/browser.html | 30 + examples/configuration/browser.mjs | 74 ++ examples/configuration/catalog.mjs | 34 + examples/configuration/plugin.mjs | 29 + examples/configuration/run.mjs | 59 ++ examples/configuration/serve.mjs | 17 + package-lock.json | 4 +- package.json | 24 +- .../artifact-sink-capabilities.schema.json | 2 +- .../artifact-storage-capabilities.schema.json | 2 +- schemas/artifact-transfer-receipt.schema.json | 2 +- .../binary-artifact-capabilities.schema.json | 2 +- schemas/binary-artifact-chunk.schema.json | 2 +- schemas/binary-artifact-list.schema.json | 2 +- schemas/binary-artifact-reference.schema.json | 2 +- schemas/binary-artifact.schema.json | 2 +- .../browser-artifact-checkpoint.schema.json | 2 +- schemas/code-action.schema.json | 2 +- schemas/command.schema.json | 900 ++++++++++++++++- schemas/completion-item.schema.json | 2 +- schemas/data-schema.schema.json | 810 +++++++++++++++ schemas/diagnostics-request.schema.json | 2 +- schemas/diagnostics-result.schema.json | 2 +- schemas/display-metadata.schema.json | 72 ++ schemas/document-symbol-tree.schema.json | 2 +- schemas/error.schema.json | 2 +- schemas/folding-ranges.schema.json | 2 +- schemas/formatting-options.schema.json | 2 +- schemas/inlay-hints.schema.json | 2 +- schemas/job-artifact-content.schema.json | 2 +- schemas/job-capabilities.schema.json | 2 +- schemas/job-events.schema.json | 2 +- schemas/job-history-page.schema.json | 2 +- schemas/job-history-query.schema.json | 2 +- schemas/job-options.schema.json | 2 +- schemas/job-recovery.schema.json | 26 +- schemas/job-snapshot.schema.json | 4 +- schemas/job-storage-capabilities.schema.json | 2 +- schemas/job-store-identity.schema.json | 2 +- schemas/json-value.schema.json | 2 +- schemas/language-capabilities.schema.json | 2 +- schemas/language-request.schema.json | 2 +- schemas/language-result.schema.json | 4 +- schemas/language-snippet.schema.json | 2 +- schemas/locale-catalog.schema.json | 71 ++ schemas/localized-message.schema.json | 48 + schemas/manifest.schema.json | 82 +- schemas/project-entry.schema.json | 2 +- .../project-query-capabilities.schema.json | 2 +- schemas/project-query-options.schema.json | 2 +- schemas/project-query-page.schema.json | 2 +- schemas/project-snapshot.schema.json | 2 +- .../project-watch-capabilities.schema.json | 2 +- schemas/project-watch-event.schema.json | 2 +- schemas/project-watch-options.schema.json | 2 +- schemas/secret-reference.schema.json | 22 + schemas/secret-scope.schema.json | 38 + schemas/semantic-legend.schema.json | 2 +- schemas/semantic-tokens-delta.schema.json | 2 +- schemas/semantic-tokens.schema.json | 2 +- schemas/settings-definition.schema.json | 939 ++++++++++++++++++ schemas/settings-snapshot.schema.json | 72 ++ schemas/settings-state.schema.json | 68 ++ schemas/signature-help.schema.json | 2 +- schemas/stored-artifact-chunk.schema.json | 2 +- schemas/stored-artifact-list.schema.json | 2 +- schemas/stored-artifact-reference.schema.json | 2 +- schemas/stored-artifact.schema.json | 2 +- schemas/stored-job.schema.json | 10 +- schemas/transfer-queue-options.schema.json | 2 +- schemas/transfer-snapshot.schema.json | 2 +- schemas/upload-capabilities.schema.json | 2 +- schemas/upload-chunk.schema.json | 2 +- schemas/upload-reference.schema.json | 2 +- schemas/upload-spec.schema.json | 2 +- schemas/upload-status.schema.json | 2 +- .../workspace-conditional-file.schema.json | 2 +- schemas/workspace-edit-preview.schema.json | 2 +- schemas/workspace-edit-receipt.schema.json | 2 +- schemas/workspace-edit-record.schema.json | 2 +- schemas/workspace-edit.schema.json | 2 +- .../workspace-file-capabilities.schema.json | 2 +- schemas/workspace-file-revision.schema.json | 2 +- ...workspace-project-capabilities.schema.json | 2 +- schemas/workspace-project-poll.schema.json | 2 +- scripts/pack-check.mjs | 4 + scripts/schemas.mjs | 4 + src/accessibility.d.mts | 7 + src/accessibility.mjs | 57 ++ src/configuration-operations.mjs | 41 + src/configuration-values.mjs | 73 ++ src/consent.mjs | 2 +- src/contracts.mjs | 35 +- src/data-schema-schemas.mjs | 44 + src/data-schema.d.mts | 25 + src/data-schema.mjs | 129 +++ src/host.mjs | 48 +- src/index.d.mts | 19 +- src/index.mjs | 2 +- src/job-registry.mjs | 4 +- src/localization-schemas.mjs | 12 + src/localization.d.mts | 20 + src/localization.mjs | 178 ++++ src/publishing.mjs | 2 +- src/schemas.d.mts | 3 +- src/schemas.mjs | 16 +- src/secrets.d.mts | 22 + src/secrets.mjs | 119 +++ src/settings-host.mjs | 64 ++ src/settings.d.mts | 28 + src/settings.mjs | 167 ++++ src/version.mjs | 4 +- src/workspace-node.d.mts | 4 + src/workspace-node.mjs | 4 +- src/workspace-protocol.mjs | 2 +- tests/command-schema.test.mjs | 93 ++ tests/core-v2.test.mjs | 16 +- tests/data-schema.test.mjs | 71 ++ tests/localization.test.mjs | 96 ++ tests/publishing.test.mjs | 2 +- tests/secrets.test.mjs | 103 ++ tests/settings-host.test.mjs | 57 ++ tests/settings.test.mjs | 96 ++ tests/types/configuration.mts | 36 + tests/types/localization.mts | 17 + 139 files changed, 5817 insertions(+), 172 deletions(-) create mode 100644 docs/DATA_SCHEMAS.md create mode 100644 docs/LOCALIZATION.md create mode 100644 docs/SECRETS.md create mode 100644 docs/SETTINGS.md create mode 100644 examples/configuration/LICENSE create mode 100644 examples/configuration/browser.html create mode 100644 examples/configuration/browser.mjs create mode 100644 examples/configuration/catalog.mjs create mode 100644 examples/configuration/plugin.mjs create mode 100644 examples/configuration/run.mjs create mode 100644 examples/configuration/serve.mjs create mode 100644 schemas/data-schema.schema.json create mode 100644 schemas/display-metadata.schema.json create mode 100644 schemas/locale-catalog.schema.json create mode 100644 schemas/localized-message.schema.json create mode 100644 schemas/secret-reference.schema.json create mode 100644 schemas/secret-scope.schema.json create mode 100644 schemas/settings-definition.schema.json create mode 100644 schemas/settings-snapshot.schema.json create mode 100644 schemas/settings-state.schema.json create mode 100644 src/accessibility.d.mts create mode 100644 src/accessibility.mjs create mode 100644 src/configuration-operations.mjs create mode 100644 src/configuration-values.mjs create mode 100644 src/data-schema-schemas.mjs create mode 100644 src/data-schema.d.mts create mode 100644 src/data-schema.mjs create mode 100644 src/localization-schemas.mjs create mode 100644 src/localization.d.mts create mode 100644 src/localization.mjs create mode 100644 src/secrets.d.mts create mode 100644 src/secrets.mjs create mode 100644 src/settings-host.mjs create mode 100644 src/settings.d.mts create mode 100644 src/settings.mjs create mode 100644 tests/command-schema.test.mjs create mode 100644 tests/data-schema.test.mjs create mode 100644 tests/localization.test.mjs create mode 100644 tests/secrets.test.mjs create mode 100644 tests/settings-host.test.mjs create mode 100644 tests/settings.test.mjs create mode 100644 tests/types/configuration.mts create mode 100644 tests/types/localization.mts diff --git a/CHANGELOG.md b/CHANGELOG.md index 955fd36..5f90906 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,21 @@ # Changelog +## 0.12.0 — 2026-10-05 + +- Add bounded versioned settings, default/user/workspace precedence, atomic CAS, + subscriptions and explicit migrations that preserve prior state on failure. +- Add operator-owned secret references with scoped leases, expiry/revocation, + current authorization, redacted failures and deterministic owned-buffer cleanup. +- Validate structured command inputs/defaults and outputs in normal and job paths; + keep legacy parameters and reject ambiguous declarations. Add form metadata, + stable local field errors and explicit per-execution secret access. +- Add locale catalogs, deterministic fallback, literal placeholders, Intl helpers, + display cross-checks, accessibility names/keyboard hints and contrast reports. + Include independent HTTP and EN/KO/RTL/long-label browser examples. +- Retain actual normal-command/port request slots when cancellation is ignored. + Add types and schemas for the public contracts; keep exact published plugin + metadata verification through 0.11 and target new packages at `>=0.12.0 <0.13.0`. + ## 0.11.0 — 2026-10-04 - Add semantic full/delta tokens with bounded provider-bound caches and one full diff --git a/PUBLIC_SURFACE.json b/PUBLIC_SURFACE.json index 6198fd8..6761b06 100644 --- a/PUBLIC_SURFACE.json +++ b/PUBLIC_SURFACE.json @@ -12,17 +12,19 @@ "docs/BROWSER_RESUME.md", "docs/COMPATIBILITY.md", "docs/CONSENT.md", + "docs/DATA_SCHEMAS.md", "docs/DEVTOOLS.md", "docs/FILE_REVISIONS.md", "docs/host-contract.md", "docs/JOB_HISTORY.md", "docs/JOB_STORAGE.md", "docs/JOBS.md", - "docs/LANGUAGE_DISPLAY.md", - "docs/LANGUAGE.md", "docs/LANGUAGE_ASSISTANCE.md", + "docs/LANGUAGE_DISPLAY.md", "docs/LANGUAGE_EDITING.md", + "docs/LANGUAGE.md", "docs/LICENSING.md", + "docs/LOCALIZATION.md", "docs/OBSERVATION.md", "docs/performance.md", "docs/PROJECT_QUERIES.md", @@ -31,14 +33,16 @@ "docs/PROJECTS.md", "docs/PUBLIC_SCOPE.md", "docs/PUBLISHING.md", + "docs/SECRETS.md", + "docs/SETTINGS.md", "docs/testing.md", "docs/THEMES.md", "docs/TRANSFER_QUEUE.md", "docs/UPLOADS.md", "docs/VERIFYING.md", + "docs/WORKSPACE_EDITS.md", "docs/workspace-protocol.md", "docs/WORKSPACES.md", - "docs/WORKSPACE_EDITS.md", "examples/binary-artifacts/run.mjs", "examples/browser-artifacts/browser.html", "examples/browser-artifacts/browser.mjs", @@ -52,6 +56,13 @@ "examples/browser-resume/serve.mjs", "examples/browser-resume/worker.mjs", "examples/command-jobs/run.mjs", + "examples/configuration/browser.html", + "examples/configuration/browser.mjs", + "examples/configuration/catalog.mjs", + "examples/configuration/LICENSE", + "examples/configuration/plugin.mjs", + "examples/configuration/run.mjs", + "examples/configuration/serve.mjs", "examples/consent/prompt.mjs", "examples/consent/README.md", "examples/durable-jobs/run.mjs", @@ -109,11 +120,11 @@ "examples/user-workspace/project/design.sv", "examples/user-workspace/server.mjs", "examples/user-workspace/tool.mjs", - "examples/workspace-observation/run.mjs", + "examples/workspace-edits/child.mjs", "examples/workspace-edits/LICENSE", "examples/workspace-edits/plugin.mjs", - "examples/workspace-edits/child.mjs", "examples/workspace-edits/run.mjs", + "examples/workspace-observation/run.mjs", "LICENSE", "NOTICE", "package.json", @@ -128,15 +139,17 @@ "schemas/binary-artifact-reference.schema.json", "schemas/binary-artifact.schema.json", "schemas/browser-artifact-checkpoint.schema.json", + "schemas/code-action.schema.json", "schemas/command.schema.json", "schemas/completion-item.schema.json", - "schemas/code-action.schema.json", - "schemas/document-symbol-tree.schema.json", - "schemas/folding-ranges.schema.json", - "schemas/formatting-options.schema.json", + "schemas/data-schema.schema.json", "schemas/diagnostics-request.schema.json", "schemas/diagnostics-result.schema.json", + "schemas/display-metadata.schema.json", + "schemas/document-symbol-tree.schema.json", "schemas/error.schema.json", + "schemas/folding-ranges.schema.json", + "schemas/formatting-options.schema.json", "schemas/inlay-hints.schema.json", "schemas/job-artifact-content.schema.json", "schemas/job-capabilities.schema.json", @@ -153,6 +166,8 @@ "schemas/language-request.schema.json", "schemas/language-result.schema.json", "schemas/language-snippet.schema.json", + "schemas/locale-catalog.schema.json", + "schemas/localized-message.schema.json", "schemas/manifest.schema.json", "schemas/project-entry.schema.json", "schemas/project-query-capabilities.schema.json", @@ -162,9 +177,14 @@ "schemas/project-watch-capabilities.schema.json", "schemas/project-watch-event.schema.json", "schemas/project-watch-options.schema.json", + "schemas/secret-reference.schema.json", + "schemas/secret-scope.schema.json", "schemas/semantic-legend.schema.json", "schemas/semantic-tokens-delta.schema.json", "schemas/semantic-tokens.schema.json", + "schemas/settings-definition.schema.json", + "schemas/settings-snapshot.schema.json", + "schemas/settings-state.schema.json", "schemas/signature-help.schema.json", "schemas/stored-artifact-chunk.schema.json", "schemas/stored-artifact-list.schema.json", @@ -180,15 +200,17 @@ "schemas/upload-spec.schema.json", "schemas/upload-status.schema.json", "schemas/workspace-conditional-file.schema.json", + "schemas/workspace-edit-preview.schema.json", + "schemas/workspace-edit-receipt.schema.json", + "schemas/workspace-edit-record.schema.json", + "schemas/workspace-edit.schema.json", "schemas/workspace-file-capabilities.schema.json", "schemas/workspace-file-revision.schema.json", "schemas/workspace-project-capabilities.schema.json", "schemas/workspace-project-poll.schema.json", - "schemas/workspace-edit.schema.json", - "schemas/workspace-edit-preview.schema.json", - "schemas/workspace-edit-record.schema.json", - "schemas/workspace-edit-receipt.schema.json", "SECURITY.md", + "src/accessibility.d.mts", + "src/accessibility.mjs", "src/artifact-download-node.d.mts", "src/artifact-download-node.mjs", "src/artifact-resume-browser.d.mts", @@ -206,9 +228,14 @@ "src/artifact-transfer.mjs", "src/artifacts.d.mts", "src/artifacts.mjs", + "src/configuration-operations.mjs", + "src/configuration-values.mjs", "src/consent.d.mts", "src/consent.mjs", "src/contracts.mjs", + "src/data-schema-schemas.mjs", + "src/data-schema.d.mts", + "src/data-schema.mjs", "src/dev-worker.mjs", "src/devtools.d.mts", "src/devtools.mjs", @@ -232,12 +259,15 @@ "src/language-assistance.mjs", "src/language-display-schemas.mjs", "src/language-display.mjs", - "src/language-editing.mjs", "src/language-editing-schemas.mjs", + "src/language-editing.mjs", "src/language-registry.mjs", "src/language-values.mjs", "src/lifecycle.mjs", "src/limits.mjs", + "src/localization-schemas.mjs", + "src/localization.d.mts", + "src/localization.mjs", "src/node-project-context.mjs", "src/node-store-context.mjs", "src/node-workspace-context.mjs", @@ -259,6 +289,11 @@ "src/publishing.mjs", "src/schemas.d.mts", "src/schemas.mjs", + "src/secrets.d.mts", + "src/secrets.mjs", + "src/settings-host.mjs", + "src/settings.d.mts", + "src/settings.mjs", "src/sha256-stream.mjs", "src/stored-artifact-download-node.d.mts", "src/stored-artifact-download-node.mjs", @@ -285,6 +320,12 @@ "src/version.mjs", "src/workspace-client.d.mts", "src/workspace-client.mjs", + "src/workspace-edit-contracts.mjs", + "src/workspace-edit-schemas.mjs", + "src/workspace-edits-node.d.mts", + "src/workspace-edits-node.mjs", + "src/workspace-edits.d.mts", + "src/workspace-edits.mjs", "src/workspace-identity-node.mjs", "src/workspace-node.d.mts", "src/workspace-node.mjs", @@ -302,12 +343,6 @@ "src/workspace-upload-client.mjs", "src/workspace-upload-node.mjs", "src/workspace-values.mjs", - "src/workspace-edit-contracts.mjs", - "src/workspace-edit-schemas.mjs", - "src/workspace-edits.mjs", - "src/workspace-edits.d.mts", - "src/workspace-edits-node.mjs", - "src/workspace-edits-node.d.mts", "templates/LICENSE.proprietary.example.txt", "templates/publisher-disclosure.example.json" ] diff --git a/README.md b/README.md index 76b0e31..570aef2 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,12 @@ a small ESM package, TypeScript declarations and versioned contracts. Connect your own workspace and tools with the Node.js host and HTTP client. The SDK has **zero runtime dependencies**. +Version 0.12 adds [versioned settings](docs/SETTINGS.md), [execution-scoped secret +references](docs/SECRETS.md), [structured command schemas](docs/DATA_SCHEMAS.md) +and [localization/accessibility helpers](docs/LOCALIZATION.md). Run +`npm run example:configuration` for the independent HTTP example or +`npm run example:configuration-browser` for EN/KO, RTL and long-label forms. + Version 0.11 adds [signature help and completion assistance](docs/LANGUAGE_ASSISTANCE.md), including lazy item resolution, limited snippets and pure insertion previews. Run `npm run example:assistance` for the nested-call and Unicode example. @@ -34,7 +40,7 @@ application, private engines/services and production signing systems are outside this repository. Read the [public scope](docs/PUBLIC_SCOPE.md) and [compatibility table](docs/COMPATIBILITY.md) for exact boundaries. -Version **0.11.0** supports Node 22 and 24. Manifest v1 diagnostic plugins remain +Version **0.12.0** supports Node 22 and 24. Manifest v1 diagnostic plugins remain compatible. Manifest v2 adds commands, permission-scoped workspace and backend APIs, and open-source or proprietary distribution metadata. Themes export to the XML format already supported by DDS. The workspace host runs trusted plugins @@ -94,7 +100,7 @@ mkdir my-dds-plugin cd my-dds-plugin npm init -y npm pkg set type=module -npm install --ignore-scripts --save-exact https://github.com/Altifigence/dds-plugin-sdk/releases/download/v0.11.0/altifigence-dds-plugin-sdk-0.11.0.tgz +npm install --ignore-scripts --save-exact https://github.com/Altifigence/dds-plugin-sdk/releases/download/v0.12.0/altifigence-dds-plugin-sdk-0.12.0.tgz ``` Copy the example's `plugin.mjs` and `run.mjs` into this directory, then run: diff --git a/docs/COMPATIBILITY.md b/docs/COMPATIBILITY.md index 51a3d66..fee86f3 100644 --- a/docs/COMPATIBILITY.md +++ b/docs/COMPATIBILITY.md @@ -1,5 +1,21 @@ # Compatibility and availability +## 0.12 configuration and display + +[Settings](SETTINGS.md) and [secret references](SECRETS.md) are operator-owned +optional ports. New `settings` capability, `settings.read`/`secrets.resolve` +permissions, manifest display and [command schema metadata](DATA_SCHEMAS.md) +require a 0.12-aware peer. Existing diagnostics, language features, commands and +workspace protocol v1 continue to work. Older clients reject unknown metadata; +an unchanged protocol version does not make new fields readable by an old peer. +Keep legacy definitions when using old clients. Cancellation now retains normal +command/port slots until actual provider settlement, as jobs already did. + +[Locale catalogs and accessibility helpers](LOCALIZATION.md) return display data +and bounded inspection hints. They do not change DDS product UI or theme XML. +New plugin archives use `>=0.12.0 <0.13.0`; verification preserves exact released +0.3.x through 0.11.x peer metadata. An old archive's peer range is not widened. + ## 0.11 language assistance and editing Optional `signature-help`, `completion-resolve` and `completion-snippets` extend the @@ -29,7 +45,7 @@ is unchanged. These features do not establish a DDS product editor integration. | Browser-only DDS host | Native workspace transport is unavailable | | Language providers | Diagnostics plus completion, hover, definition, references and document symbols in SDK hosts since 0.3.0; no released DDS editor bridge is implied | | Project edit sessions | Client helpers in 0.3.0 over unchanged workspace protocol v1, including 0.2.x servers | -| Download verification | Node API/CLI since 0.3.1; 0.11.0 accepts exact 0.3.x–0.11.x package metadata; no extraction, code execution or publisher authentication | +| Download verification | Node API/CLI since 0.3.1; 0.12.0 accepts exact 0.3.x–0.12.x package metadata; no extraction, code execution or publisher authentication | | Development tools | `init`, `doctor`, `dev` since 0.4.0; local Node tools, explicit trusted-code execution | | Command jobs | Opt-in SDK host/client extension since 0.4.0; progress/logs/UTF-8 artifacts; v1 hello unchanged | | File and job observation | Client helpers since 0.5.0; 0.6 uses revision reads when supported; job reads need enabled 0.4+ hosts | diff --git a/docs/DATA_SCHEMAS.md b/docs/DATA_SCHEMAS.md new file mode 100644 index 0000000..bea585a --- /dev/null +++ b/docs/DATA_SCHEMAS.md @@ -0,0 +1,83 @@ +# Structured command data (SDK 0.12) + +Commands may declare `inputSchema` and `outputSchema`, each using the version-1 +envelope below. Existing `parameters` remain supported. Declaring both +`parameters` and `inputSchema` is an error; neither silently overrides the other. + +```js +import {parseCommandInput, parseCommandOutput} from '@altifigence/dds-plugin-sdk'; + +const command = { + id: 'summarize', title: 'Summarize', + inputSchema: {schemaVersion: 1, schema: { + type: 'object', additionalProperties: false, + properties: { + names: {type: 'array', minItems: 1, maxItems: 8, + items: {type: 'string', minLength: 1, maxLength: 64}}, + count: {type: 'integer', minimum: 1, maximum: 10, default: 2}, + }, required: ['names', 'count'], + }}, + outputSchema: {schemaVersion: 1, schema: {type: 'integer', minimum: 0}}, +}; +const input = parseCommandInput({names: ['Ada']}, command); // count becomes 2 +parseCommandOutput(2, command); +``` + +The portable host applies defaults and validates inputs before invoking a handler +or starting a job. It validates outputs before publishing success. Output defaults +are never applied. A required output field must actually be returned. Job errors +retain a safe error code and omit invalid results. Existing primitive parameter +validation and commands without schemas retain their behavior. + +| Node type | Supported fields besides `type` and `display` | +| --- | --- | +| `object` | `properties`, `required`, mandatory `additionalProperties: false`, `default` | +| `array` | mandatory `items`, `minItems`, `maxItems`, `default` | +| `string` | `minLength`, `maxLength`, `enum`, `default` | +| `number`, `integer` | `minimum`, `maximum`, `enum`, `default` | +| `boolean`, `null` | `enum`, `default` | +| Secret reference | `type: 'object', format: 'dds-secret-reference'`; no default | + +Numbers must be finite; integers must be safe integers. String value lengths use +Unicode code points. Object property names use `[A-Za-z][A-Za-z0-9_.-]{0,127}`; +`constructor`, `prototype` and `__proto__` are forbidden. Unknown value fields, +unknown schema keywords, inconsistent ranges, duplicate enums and invalid defaults +are rejected. This subset has no `$ref`, remote retrieval, regex, union, coercion, +executable expression, HTML renderer or schema-supplied callback. + +Defaults fill absent fields, including fields inside present nested objects. They +do not create a missing parent object unless that parent has its own valid default. +Defaults must be independently valid without further default expansion. References +cannot be placed in defaults, including through enclosing object/array defaults. +`null` is a value, not a request to apply a default or reset a setting. + +`/data-schema` exports `parseDataSchema`, `validateDataValue` (defaults off unless +`{applyDefaults: true}`), `collectSecretReferences` and `describeDataForm`. +`DataValidationError` exposes `code`, `reason` and a JSON-pointer-like `path` using +known schema property names and array indices. Command helpers prefix paths with +`/input` or `/output`, for example `/input/options/names/0`. Unknown fields report +their parent path without echoing an arbitrary rejected key or value. HTTP errors +and retained job failures expose sanitized codes; run the public helpers locally +when a form needs a detailed field path. + +Schemas are limited to 32 KiB, depth 8, 128 nodes, 64 properties per object and +64 primitive enum entries. Values and expanded defaults are limited to 64 KiB; +arrays to 256 items and strings to 16,384 code points. A complete command definition +including both schemas still has the existing 16 KiB metadata budget. Plain JSON +copying also has structural limits and rejects accessors, custom prototypes, +sparse arrays, cycles, invalid Unicode and non-JSON values without invoking hooks. + +`display` contains literal `label`, `help`, `order`, `labelKey`, `helpKey` and +`accessibility` metadata. `describeDataForm` returns field paths, types, required +flags, choices, defaults and `secretReference` hints for a host-owned form. Array +item descriptions use `/*`. The host decides how to render and edit data; display +strings are never inserted as HTML. See [localization](LOCALIZATION.md). + +Declare reference fields explicitly to use [execution-scoped secrets](SECRETS.md). +An ordinary string or an ordinary object resembling a reference conveys no secret +authority. See the [configuration example](../examples/configuration/run.mjs) for +actual HTTP commands and jobs, and `npm run example:configuration-browser` for forms. + +New schema/display metadata requires a 0.12-aware host/client. Older strict +validators reject it. Keep legacy definitions when connecting older peers; +protocol version 1 does not imply support for every optional metadata extension. diff --git a/docs/DEVTOOLS.md b/docs/DEVTOOLS.md index 6029f30..57bc599 100644 --- a/docs/DEVTOOLS.md +++ b/docs/DEVTOOLS.md @@ -12,7 +12,7 @@ Install the released SDK in a tools directory, then create a sibling project: mkdir dds-tools cd dds-tools npm init -y -npm install --ignore-scripts --save-exact https://github.com/Altifigence/dds-plugin-sdk/releases/download/v0.9.0/altifigence-dds-plugin-sdk-0.9.0.tgz +npm install --ignore-scripts --save-exact https://github.com/Altifigence/dds-plugin-sdk/releases/download/v0.12.0/altifigence-dds-plugin-sdk-0.12.0.tgz npx --no-install dds-plugin init ../my-plugin --id my-plugin --publisher example cd ../my-plugin npm install --ignore-scripts diff --git a/docs/LOCALIZATION.md b/docs/LOCALIZATION.md new file mode 100644 index 0000000..753f607 --- /dev/null +++ b/docs/LOCALIZATION.md @@ -0,0 +1,92 @@ +# Localization and accessibility (SDK 0.12) + +The `/localization` module validates caller-supplied catalogs and returns literal +display text. It does not fetch translations, load remote resources or execute +markup. Keep stable command IDs, settings keys, saved values and execution inputs +separate from the selected display language. + +```js +import {parseLocaleCatalog, resolveMessage, localizeDisplay} + from '@altifigence/dds-plugin-sdk/localization'; + +const catalog = parseLocaleCatalog({schemaVersion: 1, defaultLocale: 'en', + messages: { + en: {title: 'Run', finished: '{count} results'}, + ko: {title: '실행', finished: '결과 {count}개'}, + ar: {}, + }, fallbacks: {ar: ['en']}, +}); +const message = resolveMessage(catalog, 'ko-KR', 'finished', {count: 2}); +// message.text, locale:'ko', requestedLocale:'ko-KR', fallback:true, direction:'ltr' +const display = localizeDisplay(catalog, 'ko', { + labelKey: 'title', accessibility: {nameKey: 'title', shortcut: 'Control+Enter'}, +}); +``` + +Catalog locale keys and `defaultLocale` must be canonical BCP 47 base tags. +`canonicalLocale` canonicalizes user preferences. Unicode extensions/private-use +negotiation is unsupported. `negotiateLocale` accepts one preference or up to eight: +it tries each exact tag and progressively less specific ancestors, then the default. +Per-key lookup traverses the chosen locale's explicit fallback edges in declared +depth-first order, de-duplicates visited locales and finally tries the default. +Fallback targets must exist; cycles and outgoing edges from the default are errors. + +The default catalog defines every valid key. Other locales may omit translations, +but may not introduce unknown keys. Every supplied translation must use exactly the +same placeholder names as its default message. The only interpolation syntax is +`{name}`; unmatched braces and ICU/plural expressions are rejected. Arguments must +match that set exactly and contain strings, finite numbers or booleans. Replacement +is one literal pass, so argument text containing braces is not reinterpreted. + +`resolveMessage` reports the actual source locale, fallback flag and direction; +`formatMessage` returns just the text. Render with `textContent` or equivalent. +Apply `lang` and `dir` from the actual source to fallback text inside an RTL page. +`localeDirection` is a maximized-script layout hint for the documented implementation, +not font/shaping or complete writing-system conformance. + +`formatLocaleNumber` supports decimal/percent, grouping and 0–10 fraction digits. +`formatLocaleDate` takes an explicit epoch and defaults to UTC with medium date style; +date/time styles and an explicit timezone can be supplied. `selectLocalePlural` +returns the host Intl cardinal/ordinal category; choose your own message key for +that category. Unsupported formatting locales or options fail explicitly. Hosts +own ICU/Intl availability; punctuation, numbering and timezone data can differ +between runtime versions. Persist raw values, not locale-formatted strings. + +Optional manifest-v2 and command `display`, settings metadata and data-schema nodes +share `label/help/order`, locale keys and accessibility name/description/shortcut +hints. `validateLocalizedSurfaces(catalog, {manifest?, commands?, settings?, themes?})` +checks their keys and every available locale's resolved display bounds, and reports +missing translations. Static display fields cannot use placeholders. Themes use +explicit `{theme, display}` sidecars; the existing DDS theme JSON/XML is unchanged. +The catalog is an explicit module/file export selected by the operator, not an +implicit loader or a new manifest execution entry point. + +Catalog limits are 256 KiB, 32 locales, 256 default keys, 4,096 UTF-16 units per +message, 32 placeholders and 16,384 units per formatted result. Display labels and +accessible names are additionally limited to 256 units, help/descriptions to 2,048. +Catalogs are frozen copies; accessors, prototypes, invalid Unicode and oversized +data are rejected before use. The example includes EN/KO, partial Arabic and a +deliberately long `en-XA` layout fixture. These are developer fixtures, not a claim +that DDS product UI or every supported Docs language has been translated. + +## Bounded accessibility hints + +`/accessibility` provides `normalizeShortcut`, `inspectAccessibility`, +`contrastRatio` and `inspectThemeContrast`. Shortcut metadata supports Control, +Alt, Shift and Meta plus a letter/digit, F1–F24 or an enumerated navigation key. +The inspector reports duplicate normalized shortcuts and missing resolved names +within up to 256 supplied surfaces. It neither binds keys nor knows conflicts +with operating-system shortcuts or another app. + +Theme checks calculate unrounded contrast for explicitly listed opaque `#RRGGBB` +pairs in both palettes and available text/background token pairs. The default +threshold is 4.5, following the [W3C normal-text contrast guidance](https://www.w3.org/WAI/WCAG22/Understanding/contrast-minimum.html) +and [sRGB luminance formula](https://www.w3.org/WAI/GL/wiki/Relative_luminance). +These are color-pair hints: they do not inspect rendered text, font size, opacity, +focus behavior, screen readers or certify complete UI accessibility. + +Run `npm run example:configuration` for an independent HTTP consumer or +`npm run example:configuration-browser` for the local form. In the browser, choose +another language, save/reset a scoped value, try invalid names, revoke/renew the +disposable reference or hold a command while switching language. Continue within +30 seconds; timed-out work is cancelled. Ctrl+Enter invokes the same command. diff --git a/docs/PUBLISHING.md b/docs/PUBLISHING.md index 2177f02..1f568e0 100644 --- a/docs/PUBLISHING.md +++ b/docs/PUBLISHING.md @@ -26,7 +26,7 @@ notes for the capabilities and hosts actually supported by that release. what data the plugin uses, which backend receives it, and how users can find updates and contact the publisher about security. 4. List the plugin's distribution files explicitly in `dds-package.json`, then - validate and pack them with the SDK 0.9.0 CLI described below. Inspect the + validate and pack them with the SDK 0.12.0 CLI described below. Inspect the archive; do not list credentials, private project data or files that you do not intend to distribute. 5. Publish the immutable versioned `.tgz`, its external `.release.json` and @@ -44,10 +44,10 @@ file. Errors do not print the detected value. This is a conservative additional check, not a complete secret or malware scanner. Inspect your source and final archive; obfuscated credentials and confidential implementation may evade it. -## Package format and SDK 0.9.0 CLI +## Package format and SDK 0.12.0 CLI The CLI has been available since SDK 0.2.0; SDK 0.1.0 does not provide it. -Use an installed 0.9.0 package containing the `dds-plugin` executable. +Use an installed 0.12.0 package containing the `dds-plugin` executable. For a new project, start with [init and doctor](DEVTOOLS.md). Prepare this layout: @@ -87,9 +87,9 @@ The manifest, disclosure, manifest entry and selected license file must all be listed. The packer automatically includes `dds-package.json` and generates `package.json`; do not list either file in `files`. The generated npm metadata uses `type: "module"`, the manifest entry and a peer dependency on this SDK: -`>=0.9.0 <0.10.0`. The packer targets its current tested SDK line; it does not -infer compatibility by inspecting imports. Older archives from the 0.2.x, -0.3.x, 0.4.x, 0.5.x, 0.6.x, 0.7.x and 0.8.x CLIs exclude SDK 0.9.0 in their peer ranges. Validate against 0.9.0 and +`>=0.12.0 <0.13.0`. The packer targets its current tested SDK line; it does not +infer compatibility by inspecting imports. Older archives from the 0.2.x–0.11.x +CLIs exclude SDK 0.12.0 in their peer ranges. Validate against 0.12.0 and publish a new plugin version with this packer when migrating those archives. List `NOTICE`, third-party notices, other runtime modules, assets and source material explicitly when they are applicable. The CLI does not determine which legal @@ -153,7 +153,7 @@ separate from validating and packaging your own plugin. ## Select a distribution channel -Before installing a downloaded plugin, use the SDK 0.9.0 +Before installing a downloaded plugin, use the SDK 0.12.0 [`dds-plugin verify` command](VERIFYING.md) or `verifyPluginArchive()` API to check its archive and release metadata without running or extracting it. Supply an independently obtained expected SHA-256 when selecting exact release diff --git a/docs/SECRETS.md b/docs/SECRETS.md new file mode 100644 index 0000000..a1d1b72 --- /dev/null +++ b/docs/SECRETS.md @@ -0,0 +1,72 @@ +# Explicit secret references (SDK 0.12) + +The `/secrets` module separates an opaque reference from the material it refers +to. A `SecretReference` is only `{kind: 'dds-secret-reference', id: ''}`. +The operator owns `createSecretResolver({resolve, authorize, now?})`, keeps the +actual bytes in a separate provider and issues references for a plugin, workspace, +command allowlist and lifetime. There is no automatic credential discovery. + +```js +import {createSecretResolver} from '@altifigence/dds-plugin-sdk/secrets'; + +const resolver = createSecretResolver({ + authorize: request => operatorStillApproves(request), + resolve: (request, {signal}) => operatorStore.readBytes(request.secretId, {signal}), +}); +const reference = resolver.issue({secretId: 'selected-credential', + pluginId: 'example', workspaceId: 'selected-project', commandIds: ['send'], + ttlMs: 60_000}); +// Store/pass reference as a field explicitly typed with format:'dds-secret-reference'. +``` + +Mount it with `createPluginHost({secrets: resolver, grants: ['secrets.resolve']})` +or the automatic `createWorkspaceServer` host. The workspace-runtime manifest +must declare `secrets.resolve`. UI-runtime manifests cannot request this permission. +A command input schema must identify each permitted reference field explicitly. +The handler receives `options.secrets` only when its validated input contains such +references. It cannot resolve a different reference merely by knowing an ID. + +```js +// Inside an authorized command handler: +await options.secrets.withSecret(input.token, async (bytes, {signal}) => { + await callerConfiguredOperation(bytes, {signal}); +}); +``` + +Each normal command and job gets a fresh execution ID. The resolver checks the +plugin/workspace/command scope, reference expiry, revocation and current operator +authorization before and after resolution and after the callback. A callback +must return `undefined`, not secret material. It receives an SDK-owned copy of +provider bytes; that copy is zeroed on cancellation and on every exit. Provider +and callback errors are replaced with safe SDK errors, without their message or +cause. A reference is permission metadata, not proof that the provider will agree +to supply material on a later call. + +`revoke(reference)` and `dispose()` abort active leases and prevent new access. +Expiry also aborts leases. Closing the command execution or deactivating its plugin +invalidates the execution API, including a saved copy retained by a handler. +Work that ignores cancellation still occupies the resolver's actual pending slot +until it settles. Cancellation cannot undo an external side effect already made. +The command can perform other work before requesting its secret; place the lease +before any effect that depends on that authorization. + +Independent hosts can call `createSecretExecution(resolver, scope, reviewedRefs, +{signal})`, use its `secrets.withSecret`, and always dispose it in `finally`. +Custom resolver ports are trusted operator code and must enforce equivalent +authorization, bounds and byte-lifetime rules. The execution adapter confines the +reference list and sanitizes port errors; it cannot enforce internals of a custom +provider or prevent that provider from retaining material. + +The bundled resolver limits references to 128, command IDs to 32 per reference, +concurrent actual leases to 8 and each material value to 16 KiB. Default lifetime +is 60 seconds; maximum is one hour. Leases use the normal 5-second timeout with a +30-second maximum and are additionally bounded by reference expiry. `inspect()` +returns counts only. The optional clock is for operator-controlled tests. + +This is an in-process SDK, not an OS security boundary. Trusted callbacks can copy +or deliberately disclose bytes; the SDK cannot erase copies it does not own. +Provider-owned bytes are not cleared for the provider. Ordinary settings strings, +plugin logs/results and arbitrary application data cannot be automatically +classified as secrets. Never place real material in manifests, bundles, fixtures, +settings, logs or result objects. The public example generates disposable material +in memory, reports only an authorization boolean and clears its own provider buffer. diff --git a/docs/SETTINGS.md b/docs/SETTINGS.md new file mode 100644 index 0000000..a5498d0 --- /dev/null +++ b/docs/SETTINGS.md @@ -0,0 +1,78 @@ +# Operator-owned settings (SDK 0.12) + +The `/settings` module provides a bounded **memory** store for one plugin's +versioned definition. The operator owns writes, migrations, persistence and +disposal. Plugins receive a read-only port when the operator mounts that store. +This module does not write files, sync accounts or update DDS product preferences. + +```js +import {createSettingsStore} from '@altifigence/dds-plugin-sdk/settings'; +import {createPluginHost} from '@altifigence/dds-plugin-sdk'; + +const definition = {schemaVersion: 1, pluginId: 'example', version: 1, settings: { + count: {schema: {schemaVersion: 1, schema: { + type: 'integer', minimum: 1, maximum: 10, default: 2, + }}, scopes: ['user', 'workspace'], display: {label: 'Count'}}, +}}; +const store = createSettingsStore(definition); +store.update({workspaceId: 'selected-project', scope: 'workspace', + expectedRevision: 0, values: {count: 4}}); +const host = createPluginHost({ + scope: {projectId: 'selected-project', sessionId: 'selected-session'}, + settings: {example: store}, grants: ['settings.read'], +}); +// A v2 plugin must also declare capability 'settings' and permission 'settings.read'. +// Its context.settings.read() returns this plugin/project's effective snapshot. +``` + +Every setting uses the shared [data schema](DATA_SCHEMAS.md). Effective values +replace a whole key in the order **default < user < workspace**; nested objects +are not recursively merged. `read(workspaceId)` returns frozen `values`, matching +`sources`, the plugin/workspace IDs, `definitionVersion` and `revision`. A setting +without a default or an override is absent. `readOnly: true` requires a valid +default and `scopes: []`; writable settings need at least one allowed scope. + +`update({workspaceId, scope, expectedRevision, values?, reset?})` validates the +entire transaction before committing. `reset` lists keys to remove from that +layer, revealing the next available value. A key cannot appear in both `values` +and `reset`. Unknown/deleted keys and writes outside a key's declared scope are +rejected. No-op writes do not advance the revision. A single store-wide revision +makes CAS conservative: a change in another workspace can also cause `conflict`. +Read the current revision and let the caller review/retry the intended edit. + +`subscribe(workspaceId, listener)` returns a disposable subscription. The store +coalesces synchronous writes into a microtask notification of the latest view. +Unsubscription before delivery is honored; failing listeners cannot corrupt the +store or another listener. The host validates plugin/workspace identity for each +read and notification, and removes its subscriptions on plugin deactivation. +Closing the host does not dispose a caller-owned store. + +`migrate(nextDefinition, callback, {expectedRevision, signal?, timeoutMs?})` is an +explicit transaction. The next version must increase and retain the same plugin +ID. The callback receives a frozen prior export and returns `{user, workspaces}`. +The store validates every resulting layer before its final CAS. A failed callback, +invalid value, stale revision, cancellation or timeout preserves the existing +definition and state. Unknown or removed keys must be handled by that callback; +they are never silently dropped. One actual migration can be pending at a time, +including after a callback ignores cancellation. Run untrusted migration code +outside this trusted in-process host. + +`exportState()` returns plain data for operator-managed persistence. Import with +`createSettingsStore(definition, {state})`; plugin and definition versions must +match exactly. Persistence, encryption and crash durability remain the operator's +responsibility. Store exports can contain opaque secret references, never values +resolved by the separate [secret port](SECRETS.md). Arbitrary strings can still +contain sensitive content if a caller puts it there. + +Limits: 64 keys, 32 workspace layers, 64 KiB per layer/definition, 256 KiB per +effective snapshot, 1 MiB total exported state and 64 subscriptions. All validation +and expanded defaults must fit their shared schema budgets. Migration timeout is +5 seconds by default, up to 30 seconds. `dispose()` closes the store and cancels +pending migrations; late results cannot commit. + +`createWorkspaceServer({settings: {[pluginId]: store}})` configures the automatic +plugin host. With a supplied `pluginHost`, configure its ports yourself. Settings +are accessed by workspace plugins through their context; this release adds no +remote settings-write method. A separate module export such as `settingsDefinition` +lets an operator review definitions before activation, as shown in +[`examples/configuration`](../examples/configuration/plugin.mjs). diff --git a/docs/VERIFYING.md b/docs/VERIFYING.md index 6718a1d..cac1978 100644 --- a/docs/VERIFYING.md +++ b/docs/VERIFYING.md @@ -1,6 +1,6 @@ # Verify a downloaded plugin release -SDK **0.9.0** provides a Node.js API and CLI for checking archives produced by the +SDK **0.12.0** provides a Node.js API and CLI for checking archives produced by the **0.3.x, 0.4.x, 0.5.x, 0.6.x, 0.7.x or 0.8.x DDS plugin packer**. Keep the publisher's `.tgz` and corresponding `.tgz.release.json` together. Verification reads these local files without extracting them, importing a plugin, running a package script or installing it. @@ -33,7 +33,7 @@ const receipt = await verifyPluginArchive(process.argv[2], { console.log(receipt.pluginId, receipt.pluginVersion, receipt.artifact.sha256); ``` -Save that code as `verify.mjs` in an ESM project with SDK 0.9.0 installed. Run it +Save that code as `verify.mjs` in an ESM project with SDK 0.12.0 installed. Run it with the archive path, metadata path and independently obtained hash as its three arguments. The API returns a frozen report, and the CLI serializes it as JSON. The report contains the plugin @@ -82,11 +82,11 @@ The receipt describes the bytes read during that call. If another process can change a download before installation or use, recheck the expected hash on the actual bytes being consumed. A report is not a grant to execute a mutable path. -0.9.0 preserves existing runtime/workspace contracts and verifies exact 0.3.x -metadata (`>=0.3.0 <0.4.0`), 0.4.x metadata (`>=0.4.0 <0.5.0`), 0.5.x -metadata (`>=0.5.0 <0.6.0`), 0.6.x metadata (`>=0.6.0 <0.7.0`), 0.7.x metadata (`>=0.7.0 <0.8.0`) 0.8.x metadata (`>=0.8.0 <0.9.0`) and new 0.9.x metadata (`>=0.9.0 <0.10.0`). +0.12.0 preserves existing runtime/workspace contracts and verifies the exact +published peer metadata from 0.3.x through 0.11.x, plus new 0.12.x metadata +(`>=0.12.0 <0.13.0`). Each prior minor line retains its original minor-bounded range. Verification does not alter the peer range or authorize installation on another -SDK line: validate and repack a new version for 0.9. Existing 0.3.x/0.4.x/0.5.x/0.6.x/0.7.x/0.8.x packages that +SDK line: validate and repack a new version for 0.12. Existing 0.3.x–0.11.x packages that satisfy the strengthened file policy can be verified unchanged. The verifier rejects private credential names and Windows device aliases; remove them and publish a new plugin version if needed. See [Security](../SECURITY.md). @@ -96,7 +96,7 @@ SDK's own npm release tarball. ## 한국어 -SDK **0.9.0**의 `dds-plugin verify`와 `verifyPluginArchive()`는 **0.3.x·0.4.x·0.5.x·0.6.x·0.7.x·0.8.x·0.9.x DDS +SDK **0.12.0**의 `dds-plugin verify`와 `verifyPluginArchive()`는 **0.3.x~0.12.x DDS 플러그인 packer**가 만든 `.tgz`와 외부 `.tgz.release.json`을 검사합니다. 플러그인을 실행·설치하거나 파일을 디스크에 추출하지 않으며 네트워크 요청도 하지 않습니다. 실행 명령과 API는 위 예제와 같습니다. 별도 위치의 메타데이터는 @@ -116,9 +116,9 @@ manifest·발행자 고지·진입 모듈·라이선스 및 생성된 npm 메타 격리·설치 동의·DDS 제품 호환성 검증을 대신하지 않습니다. 실제 실행 시 파일이 바뀔 수 있는 환경에서는 소비하는 바로 그 파일의 해시를 다시 확인해야 합니다. -강화된 파일 정책을 만족하는 기존 0.3.x·0.4.x·0.5.x·0.6.x·0.7.x·0.8.x 플러그인 아카이브는 그대로 검증할 수 있고 -각각 기존 peer 범위도 유지됩니다. 새 패키지는 `>=0.9.0 <0.10.0`을 -선언하며, 기존 패키지를 0.9에 설치하려면 발행자가 검증 후 새 버전으로 패키징해야 +강화된 파일 정책을 만족하는 기존 0.3.x~0.11.x 플러그인 아카이브는 그대로 검증할 수 있고 +각각 기존 peer 범위도 유지됩니다. 새 패키지는 `>=0.12.0 <0.13.0`을 +선언하며, 기존 패키지를 0.12에 설치하려면 발행자가 검증 후 새 버전으로 패키징해야 합니다. 검증 자체는 호환 범위를 바꾸지 않습니다. 제외한 자격증명 경로나 Windows 장치명이 들어 있으면 해당 파일을 제거한 새 플러그인 버전을 발행하세요. 0.2.x 아카이브는 기존 마이그레이션 안내에 따라 diff --git a/docs/api.md b/docs/api.md index abfca8a..fb247c7 100644 --- a/docs/api.md +++ b/docs/api.md @@ -1,4 +1,4 @@ -# API reference — 0.11.0 +# API reference — 0.12.0 SDK 0.8 adds optional [job storage](JOB_STORAGE.md), [history and retry](JOB_HISTORY.md) and [stored result files](ARTIFACT_STORAGE.md). Existing v1 response shapes remain valid. @@ -80,7 +80,7 @@ license eligibility or agreement acceptance. See [LICENSING](LICENSING.md). | Activation context member | Behavior | | --- | --- | -| `host` | `{id: 'test-host' \| 'workspace-host', version: '0.11.0', protocolVersion: 1}` | +| `host` | `{id: 'test-host' \| 'workspace-host', version: '0.12.0', protocolVersion: 1}` | | `pluginId`, `scope` | Manifest ID and opaque host `{projectId, sessionId}` | | `grants` | Frozen intersection of host grants and manifest permissions | | `signal` | Aborted on deactivation, host disposal or activation timeout | @@ -89,6 +89,7 @@ license eligibility or agreement acceptance. See [LICENSING](LICENSING.md). | `registerCommand(definition, handler)` | Require declared commands capability; return a registration `Disposable` | | `workspace` | Permission-checked saved-file read, list and CAS write APIs | | `backends` | Permission-checked invocation of named, host-configured handlers | +| `settings` | Plugin/project-scoped effective reads and subscriptions; requires declared capability and grant | `createPluginHost()` defaults to **no grants**. `createTestHost()` from `@altifigence/dds-plugin-sdk/testing` preserves its v1 compatibility defaults: @@ -98,15 +99,18 @@ themselves access. A missing optional port returns `capability_unavailable`. ## Commands and parameters `context.registerCommand(definition, handler)` takes `{id, title, -description?, parameters?}`. Each parameter is `{name, label, type, required, +description?, parameters?, inputSchema?, outputSchema?, display?}`. Each legacy parameter is `{name, label, type, required, choices?}`; types are `string`, `number` or `boolean`. `choices` is a nonempty unique array available only for string parameters. A command supports at most 32 parameters and each string choice has at most 256 code points. -If `parameters` is omitted, the input may be any bounded `JsonValue`. If present, +If both `parameters` and `inputSchema` are omitted, the input may be any bounded `JsonValue`. If `parameters` is present, including an empty array, input must be an object containing only the named parameters. Required values must be supplied, types must match and choices must -match exactly. The SDK provides no parameter defaults or coercion. +match exactly. Legacy parameters have no defaults or coercion. New schemas and +parameters are mutually exclusive; schemas support validated defaults and output +validation. See [structured data](DATA_SCHEMAS.md), [settings](SETTINGS.md), +[secret leases](SECRETS.md) and [localized display](LOCALIZATION.md). ```js const registration = context.registerCommand({ diff --git a/docs/host-contract.md b/docs/host-contract.md index e2131ca..ea452ac 100644 --- a/docs/host-contract.md +++ b/docs/host-contract.md @@ -12,6 +12,12 @@ no Node filesystem, process or network APIs. It also supplies no OS sandbox: loop or remove a module's ambient access. Choose the OS user, container, WSL environment or other execution boundary independently. +SDK 0.12 adds explicitly mounted [read-only plugin settings](SETTINGS.md) and +[execution-scoped secret references](SECRETS.md), plus [command schemas](DATA_SCHEMAS.md). +The operator retains write and provider authority. Cancelled normal commands and +port calls keep their pending slot until the actual provider settles, so ignored +cancellation cannot create unbounded background operations. + ## Portable host ```js diff --git a/examples/configuration/LICENSE b/examples/configuration/LICENSE new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/examples/configuration/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/examples/configuration/browser.html b/examples/configuration/browser.html new file mode 100644 index 0000000..86cd9b8 --- /dev/null +++ b/examples/configuration/browser.html @@ -0,0 +1,30 @@ + + + + +SDK configuration example + + + +
+
DDS PLUGIN SDK · 0.12
+

Configuration example

+
+

+ + +

+summary-v1 +

+
+

+

+ + +
+

+
+

+
diff --git a/examples/configuration/browser.mjs b/examples/configuration/browser.mjs new file mode 100644 index 0000000..15b4a9c --- /dev/null +++ b/examples/configuration/browser.mjs @@ -0,0 +1,74 @@ +import {createPluginHost, parseCommandInput} from '@altifigence/dds-plugin-sdk'; +import {createSettingsStore} from '@altifigence/dds-plugin-sdk/settings'; +import {createSecretResolver} from '@altifigence/dds-plugin-sdk/secrets'; +import {describeDataForm} from '@altifigence/dds-plugin-sdk/data-schema'; +import {localizeDisplay, localeDirection, validateLocalizedSurfaces, formatLocaleNumber, resolveMessage} from '@altifigence/dds-plugin-sdk/localization'; +import {inspectAccessibility, inspectThemeContrast} from '@altifigence/dds-plugin-sdk/accessibility'; +import {settingsDefinition, command, createConfigurationPlugin} from './plugin.mjs'; +import {catalog, exampleTheme} from './catalog.mjs'; + +const element = id => document.getElementById(id), workspaceId = crypto.randomUUID(), store = createSettingsStore(settingsDefinition); +let locale = 'en', reference, revoked = false, view, result, failure, waiting = false, release, submitted, executions = 0; +const privateMaterial = crypto.getRandomValues(new Uint8Array(16)); +const resolver = createSecretResolver({authorize: () => !revoked, resolve: () => privateMaterial}); +function renew() { + if (reference) resolver.revoke(reference); + revoked = false; reference = resolver.issue({secretId: 'browser-fixture', pluginId: settingsDefinition.pluginId, workspaceId, commandIds: ['summarize'], ttlMs: 3_600_000}); + store.update({workspaceId, scope: 'workspace', expectedRevision: store.read(workspaceId).revision, values: {token: reference}}); +} +renew(); view = store.read(workspaceId); +const plugin = createConfigurationPlugin({onStart: async ({signal}) => { + executions++; + if (element('hold').checked) { + waiting = true; render(); + await new Promise(resolve => {const finish = () => {signal.removeEventListener('abort', finish); resolve();}; release = finish; signal.addEventListener('abort', finish, {once: true}); if (signal.aborted) finish();}); + release = undefined; waiting = false; + } +}}); +const host = createPluginHost({hostId: 'workspace-host', scope: {projectId: workspaceId, sessionId: crypto.randomUUID()}, settings: {[plugin.manifest.id]: store}, secrets: resolver, grants: ['settings.read', 'secrets.resolve']}); +await host.activate(plugin); +const report = validateLocalizedSurfaces(catalog, {manifest: plugin.manifest, commands: [command], settings: settingsDefinition, themes: [{theme: exampleTheme, display: {labelKey: 'theme.name'}}]}); +const contrast = inspectThemeContrast(exampleTheme), namesField = describeDataForm(command.inputSchema).find(field => field.path === '/options/names'); +function show(target, key, args) { + const translation = resolveMessage(catalog, locale, key, args); + target.textContent = translation.text; target.lang = translation.locale; target.dir = translation.direction; +} +function render() { + document.documentElement.lang = locale; document.documentElement.dir = localeDirection(locale); + for (const target of document.querySelectorAll('[data-text]')) show(target, target.dataset.text); + const commandDisplay = localizeDisplay(catalog, locale, command.display), settingDisplay = localizeDisplay(catalog, locale, settingsDefinition.settings.multiplier.display); + show(element('run'), command.display.labelKey); element('run').setAttribute('aria-label', commandDisplay.accessibility.name); element('run').setAttribute('aria-keyshortcuts', commandDisplay.accessibility.shortcut); + show(element('multiplier-label'), settingsDefinition.settings.multiplier.display.labelKey); element('multiplier').setAttribute('aria-label', settingDisplay.accessibility.name); + show(element('names-label'), namesField.display.labelKey); show(element('names-help'), namesField.display.helpKey); + show(element('effective'), 'settings.effective', {value: view.values.multiplier, source: view.sources.multiplier, revision: view.revision}); + element('format').textContent = view.values.format; + show(element('reference-state'), revoked ? 'reference.revoked' : 'reference.active'); + element('continue').hidden = !waiting; + show(element('result'), waiting ? 'execution.waiting' : result ? 'result.summary' : 'execution.ready', result && !waiting ? {count: formatLocaleNumber(locale, result.count), total: formatLocaleNumber(locale, result.total), mode: result.mode} : undefined); + if (failure) show(element('error'), failure.path ? 'error.validation' : 'error.operation', failure.path ? {path: failure.path} : {code: failure.code ?? 'invalid_contract'}); else element('error').textContent = ''; + element('receipt').textContent = JSON.stringify({commandId: command.id, settingsKey: 'multiplier', executions, ...(submitted ? {submitted: {options: submitted.options, reference: 'opaque'}} : {}), ...(result ? {result} : {})}, null, 2); + const accessibility = inspectAccessibility([{id: command.id, display: commandDisplay}, {id: 'multiplier', display: settingDisplay}]); + show(element('audit'), 'audit.summary', {conflicts: accessibility.conflicts.length, contrast: contrast.failures, missing: catalog.messages[locale] ? report.missingTranslations.filter(item => item.locale === locale).length : report.keys.length}); + const source = resolveMessage(catalog, locale, 'command.help'); element('fallback').textContent = source.fallback ? 'English source · ' + source.locale : 'Translation: ' + source.locale; element('fallback').dir = 'ltr'; element('fallback').lang = 'en'; +} +function act(operation) {try {operation(); failure = undefined;} catch (error) {failure = error;} view = store.read(workspaceId); render();} +element('locale').addEventListener('change', () => {locale = element('locale').value; render();}); +element('theme').addEventListener('click', () => {document.documentElement.dataset.theme = document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark';}); +element('save').addEventListener('click', () => act(() => store.update({workspaceId, scope: element('scope').value, expectedRevision: view.revision, values: {multiplier: Number(element('multiplier').value)}}))); +element('reset').addEventListener('click', () => act(() => {store.update({workspaceId, scope: element('scope').value, expectedRevision: view.revision, reset: ['multiplier']}); element('multiplier').value = String(store.read(workspaceId).values.multiplier);})); +element('revoke').addEventListener('click', () => act(() => {resolver.revoke(reference); revoked = true;})); +element('renew').addEventListener('click', () => act(renew)); +element('continue').addEventListener('click', () => {release?.(); release = undefined;}); +element('run').addEventListener('click', async () => { + if (element('run').disabled) return; + result = undefined; failure = undefined; submitted = undefined; + try { + submitted = parseCommandInput({options: {names: element('names').value.split(',').map(name => name.trim()), mode: element('mode').value}, token: reference}, command); + element('run').disabled = true; render(); result = await host.executeCommand(plugin.manifest.id, command.id, submitted, {timeoutMs: 30_000}); + } catch (error) {failure = error;} + finally {element('run').disabled = false; waiting = false; render();} +}); +document.addEventListener('keydown', event => {if (event.ctrlKey && !event.altKey && !event.metaKey && !event.shiftKey && event.key === 'Enter') {event.preventDefault(); element('run').click();}}); +const subscription = store.subscribe(workspaceId, next => {view = next; render();}); +window.addEventListener('pagehide', () => {release?.(); subscription.dispose(); host.dispose(); store.dispose(); resolver.dispose(); privateMaterial.fill(0);}, {once: true}); +render(); diff --git a/examples/configuration/catalog.mjs b/examples/configuration/catalog.mjs new file mode 100644 index 0000000..c1652c8 --- /dev/null +++ b/examples/configuration/catalog.mjs @@ -0,0 +1,34 @@ +export const catalog = {schemaVersion: 1, defaultLocale: 'en', messages: { + en: { + 'plugin.name': 'Configuration example', 'page.lead': 'Validated settings and commands, with language-aware labels.', + 'settings.title': 'Settings', 'settings.multiplier': 'Multiplier', 'settings.help': 'Workspace values take priority over user values and defaults.', 'settings.token': 'Execution reference', 'settings.format': 'Result format', + 'settings.scope': 'Save to', 'settings.user': 'User', 'settings.workspace': 'Workspace', 'settings.save': 'Save setting', 'settings.reset': 'Reset setting', 'settings.effective': 'Effective value: {value} · Source: {source} · Revision: {revision}', + 'command.title': 'Summarize', 'command.help': 'Count the supplied names using the current multiplier and an authorized reference.', + 'input.names': 'Names', 'input.help': 'Enter 1–8 names, separated by commas.', 'input.mode': 'Summary style', 'input.short': 'Short', 'input.detailed': 'Detailed', + 'execution.hold': 'Hold execution to try switching languages', 'execution.continue': 'Continue execution', 'execution.waiting': 'Execution is held. Change the language, then continue.', 'execution.ready': 'Ready', + 'result.summary': '{count} names × multiplier = {total}. Style: {mode}.', 'error.validation': 'Check field {path}.', 'error.operation': 'Operation failed: {code}.', + 'reference.revoke': 'Revoke reference', 'reference.renew': 'Issue new reference', 'reference.active': 'Reference available', 'reference.revoked': 'Reference revoked', + 'literal.title': 'Literal text check', 'literal.sample': ' is displayed as text.', + 'audit.title': 'Display checks', 'audit.summary': '{conflicts} shortcut conflicts · {contrast} contrast warnings · {missing} untranslated display keys', + 'theme.name': 'Example palette', 'theme.toggle': 'Switch appearance', + }, + ko: { + 'plugin.name': '설정 예제', 'page.lead': '입력 검증과 다국어 표시를 지원하는 설정·명령 예제입니다.', + 'settings.title': '설정', 'settings.multiplier': '배수', 'settings.help': '워크스페이스 값이 사용자 값과 기본값보다 우선합니다.', 'settings.token': '실행 참조', 'settings.format': '결과 형식', + 'settings.scope': '저장 범위', 'settings.user': '사용자', 'settings.workspace': '워크스페이스', 'settings.save': '설정 저장', 'settings.reset': '설정 재설정', 'settings.effective': '유효값: {value} · 출처: {source} · 리비전: {revision}', + 'command.title': '요약 실행', 'command.help': '현재 배수와 허용된 참조를 사용해 입력한 이름의 수를 계산합니다.', + 'input.names': '이름', 'input.help': '쉼표로 구분한 이름 1~8개를 입력하세요.', 'input.mode': '요약 방식', 'input.short': '간단히', 'input.detailed': '자세히', + 'execution.hold': '언어 전환을 확인하도록 실행 대기', 'execution.continue': '실행 계속', 'execution.waiting': '실행이 대기 중입니다. 언어를 바꾼 후 계속하세요.', 'execution.ready': '준비됨', + 'result.summary': '이름 {count}개 × 배수 = {total}. 방식: {mode}.', 'error.validation': '{path} 필드를 확인하세요.', 'error.operation': '작업 실패: {code}.', + 'reference.revoke': '참조 철회', 'reference.renew': '새 참조 발급', 'reference.active': '참조 사용 가능', 'reference.revoked': '참조 철회됨', + 'literal.title': '일반 텍스트 표시 확인', 'literal.sample': '는 텍스트로 표시됩니다.', + 'audit.title': '표시 점검', 'audit.summary': '단축키 충돌 {conflicts}개 · 대비 경고 {contrast}개 · 미번역 표시 키 {missing}개', + 'theme.name': '예제 색상', 'theme.toggle': '화면 테마 전환', + }, + ar: {'plugin.name': 'مثال الإعدادات', 'page.lead': 'إعدادات وأوامر مع تسميات متعددة اللغات.', 'settings.title': 'الإعدادات', 'settings.multiplier': 'المضاعف', 'command.title': 'تلخيص', 'input.names': 'الأسماء'}, + 'en-XA': {'plugin.name': '[ Configuration example with a deliberately long translated title for layout testing ]', 'settings.multiplier': '[ Multiplier — this deliberately extended label must wrap within the available space ]', 'command.title': '[ Summarize the supplied names with the selected workspace settings ]'}, +}, fallbacks: {ar: ['en'], 'en-XA': ['en']}}; +export const exampleTheme = {name: 'Configuration example', colors: { + light: {backdrop: '#f6f7fb', navigation: '#ffffff', tool: '#ffffff', main: '#ffffff', text: '#17203a', muted: '#4b556b'}, + dark: {backdrop: '#101625', navigation: '#192236', tool: '#192236', main: '#192236', text: '#f0f3ff', muted: '#b8c4de'}, +}}; diff --git a/examples/configuration/plugin.mjs b/examples/configuration/plugin.mjs new file mode 100644 index 0000000..62fd1eb --- /dev/null +++ b/examples/configuration/plugin.mjs @@ -0,0 +1,29 @@ +import {definePlugin} from '@altifigence/dds-plugin-sdk'; + +const data = schema => ({schemaVersion: 1, schema}); +export const settingsDefinition = {schemaVersion: 1, pluginId: 'configuration-example', version: 1, settings: { + multiplier: {schema: data({type: 'integer', minimum: 1, maximum: 10, default: 2}), scopes: ['user', 'workspace'], display: {label: 'Multiplier', labelKey: 'settings.multiplier', helpKey: 'settings.help', accessibility: {nameKey: 'settings.multiplier'}}}, + token: {schema: data({type: 'object', format: 'dds-secret-reference'}), scopes: ['workspace'], display: {label: 'Execution reference', labelKey: 'settings.token'}}, + format: {schema: data({type: 'string', default: 'summary-v1'}), scopes: [], readOnly: true, display: {label: 'Result format', labelKey: 'settings.format'}}, +}}; +export const command = {id: 'summarize', title: 'Summarize', display: {labelKey: 'command.title', helpKey: 'command.help', accessibility: {nameKey: 'command.title', descriptionKey: 'command.help', shortcut: 'Control+Enter'}}, inputSchema: data({ + type: 'object', additionalProperties: false, properties: { + options: {type: 'object', additionalProperties: false, properties: { + names: {type: 'array', items: {type: 'string', minLength: 1, maxLength: 64}, minItems: 1, maxItems: 8, display: {labelKey: 'input.names', helpKey: 'input.help'}}, + mode: {type: 'string', enum: ['short', 'detailed'], default: 'short', display: {labelKey: 'input.mode'}}, + }, required: ['names', 'mode']}, + token: {type: 'object', format: 'dds-secret-reference', display: {labelKey: 'settings.token'}}, + }, required: ['options', 'token'], +}), outputSchema: data({type: 'object', additionalProperties: false, properties: { + count: {type: 'integer', minimum: 1}, total: {type: 'integer', minimum: 1}, mode: {type: 'string', enum: ['short', 'detailed']}, authorized: {type: 'boolean'}, +}, required: ['count', 'total', 'mode', 'authorized']})}; + +export function createConfigurationPlugin({onStart = () => {}} = {}) { + return definePlugin({manifestVersion: 2, id: 'configuration-example', name: 'Configuration Example', publisher: 'example', version: '1.0.0', protocolVersion: 1, entry: './plugin.mjs', runtime: 'workspace', capabilities: ['commands', 'settings'], permissions: ['settings.read', 'secrets.resolve'], supportedHosts: ['workspace-host'], license: 'Apache-2.0', source: {visibility: 'open', licenseFile: 'LICENSE'}, display: {labelKey: 'plugin.name'}}, context => context.registerCommand(command, async (input, {signal, secrets, job}) => { + await onStart({signal}); signal.throwIfAborted(); + const view = await context.settings.read({signal}); let authorized = false; + await secrets.withSecret(input.token, bytes => {authorized = bytes.byteLength > 0;}, {signal}); + job?.reportProgress({completed: 1, total: 1}); + return {count: input.options.names.length, total: input.options.names.length * view.values.multiplier, mode: input.options.mode, authorized}; + })); +} diff --git a/examples/configuration/run.mjs b/examples/configuration/run.mjs new file mode 100644 index 0000000..1f5add9 --- /dev/null +++ b/examples/configuration/run.mjs @@ -0,0 +1,59 @@ +import assert from 'node:assert/strict'; +import {mkdtemp, realpath, rm} from 'node:fs/promises'; +import {tmpdir} from 'node:os'; +import path from 'node:path'; +import {randomUUID, randomBytes} from 'node:crypto'; +import {parseCommandInput, parseCommandOutput} from '@altifigence/dds-plugin-sdk'; +import {createSettingsStore} from '@altifigence/dds-plugin-sdk/settings'; +import {createSecretResolver} from '@altifigence/dds-plugin-sdk/secrets'; +import {createWorkspaceServer} from '@altifigence/dds-plugin-sdk/workspace-node'; +import {createWorkspaceClient} from '@altifigence/dds-plugin-sdk/workspace-client'; +import {validateLocalizedSurfaces, localizeDisplay, resolveMessage} from '@altifigence/dds-plugin-sdk/localization'; +import {inspectAccessibility, inspectThemeContrast} from '@altifigence/dds-plugin-sdk/accessibility'; +import {command, settingsDefinition, createConfigurationPlugin} from './plugin.mjs'; +import {catalog, exampleTheme} from './catalog.mjs'; + +const root = await realpath(await mkdtemp(path.join(tmpdir(), 'dds-configuration-example-'))), workspaceId = randomUUID(); +const token = randomBytes(32).toString('hex'), privateMaterial = randomBytes(16), artifactSha256 = 'c'.repeat(64); +let allowed = true, executions = 0, server, client; +const store = createSettingsStore(settingsDefinition), notifications = []; +const resolver = createSecretResolver({authorize: () => allowed, resolve: () => privateMaterial}); +const reference = resolver.issue({secretId: 'operator-example', pluginId: settingsDefinition.pluginId, workspaceId, commandIds: ['summarize']}); +try { + assert.equal(store.read(workspaceId).values.multiplier, 2); + store.update({workspaceId, scope: 'user', expectedRevision: 0, values: {multiplier: 3}}); + const subscription = store.subscribe(workspaceId, view => notifications.push(view)); + store.update({workspaceId, scope: 'workspace', expectedRevision: 1, values: {multiplier: 4, token: reference}}); + await Promise.resolve(); assert.equal(notifications.at(-1).sources.multiplier, 'workspace'); + assert.equal(store.read(randomUUID()).values.multiplier, 3); + assert.throws(() => store.update({workspaceId, scope: 'workspace', expectedRevision: 1, values: {multiplier: 9}}), error => error.code === 'conflict'); + const plugin = createConfigurationPlugin({onStart: () => executions++}); + const surfaces = validateLocalizedSurfaces(catalog, {manifest: plugin.manifest, commands: [command], settings: settingsDefinition, themes: [{theme: exampleTheme, display: {labelKey: 'theme.name'}}]}); + assert.equal(surfaces.missingTranslations.some(item => ['en', 'ko'].includes(item.locale)), false); + assert.equal(resolveMessage(catalog, 'ar', 'command.help').locale, 'en'); assert.equal(resolveMessage(catalog, 'ar', 'command.title').direction, 'rtl'); + const localized = localizeDisplay(catalog, 'ko', command.display); assert.equal(localized.label, '요약 실행'); assert.equal(inspectAccessibility([{id: command.id, display: localized}]).conflicts.length, 0); + assert.equal(inspectThemeContrast(exampleTheme).failures, 0); + server = await createWorkspaceServer({root, workspaceId, token, notice: {id: 'configuration-example', version: '1', text: 'Disposable local SDK configuration example.'}, plugins: [{plugin, artifactSha256}], settings: {[plugin.manifest.id]: store}, secrets: resolver, grants: ['settings.read', 'secrets.resolve'], jobs: true}); + client = createWorkspaceClient({url: server.url, token}); const hello = await client.connect(); + assert.equal(hello.plugins[0].commands[0].inputSchema.schemaVersion, 1); + const input = parseCommandInput({options: {names: ['Ada', '한글']} , token: store.read(workspaceId).values.token}, command); + assert.equal(input.options.mode, 'short'); + assert.throws(() => parseCommandInput({...input, options: {names: ['']}}, command), error => error.path === '/input/options/names/0'); + await assert.rejects(client.runCommand(plugin.manifest.id, 'summarize', {...input, options: {names: []}}, artifactSha256)); assert.equal(executions, 0); + const output = parseCommandOutput(await client.runCommand(plugin.manifest.id, 'summarize', input, artifactSha256), command); + assert.deepEqual(output, {count: 2, total: 8, mode: 'short', authorized: true}); + let job = await client.startCommandJob(plugin.manifest.id, 'summarize', input, artifactSha256, {jobId: randomUUID()}); + for (let poll = 0; job.state === 'running' && poll < 100; poll++) {await new Promise(resolve => setTimeout(resolve, 10)); job = await client.getJob(job.jobId);} + assert.equal(job.state, 'succeeded'); assert.equal(job.result.total, 8); + allowed = false; await assert.rejects(client.runCommand(plugin.manifest.id, 'summarize', input, artifactSha256), error => error.code === 'permission_denied'); + allowed = true; resolver.revoke(reference); await assert.rejects(client.runCommand(plugin.manifest.id, 'summarize', input, artifactSha256), error => error.code === 'permission_denied'); + store.update({workspaceId, scope: 'workspace', expectedRevision: 2, reset: ['multiplier', 'token']}); assert.equal(store.read(workspaceId).values.multiplier, 3); + await assert.rejects(store.migrate({...settingsDefinition, version: 2}, () => {throw new Error('migration fixture');}, {expectedRevision: 3}), error => error.code === 'provider_failed'); assert.equal(store.read(workspaceId).definitionVersion, 1); + await store.migrate({...settingsDefinition, version: 2}, before => ({user: before.user, workspaces: before.workspaces}), {expectedRevision: 3}); assert.equal(store.read(workspaceId).definitionVersion, 2); + subscription.dispose(); assert.equal(store.inspect().subscriptions, 0); + assert.equal(JSON.stringify(store.exportState()).includes(privateMaterial.toString('hex')), false); + console.log('Configuration: scoped CAS settings, migration rollback, structured HTTP commands/jobs, isolated secret references and revocation verified'); +} finally { + client?.dispose(); await server?.close(); store.dispose(); resolver.dispose(); privateMaterial.fill(0); + assert.equal(path.dirname(root), await realpath(tmpdir())); assert.ok(path.basename(root).startsWith('dds-configuration-example-')); await rm(root, {recursive: true}); +} diff --git a/examples/configuration/serve.mjs b/examples/configuration/serve.mjs new file mode 100644 index 0000000..c68a533 --- /dev/null +++ b/examples/configuration/serve.mjs @@ -0,0 +1,17 @@ +import {readFile, readdir} from 'node:fs/promises'; +import {createServer} from 'node:http'; +const root = new URL('./', import.meta.url), source = new URL('../../src/', import.meta.url); +const files = new Map([['/', ['browser.html', 'text/html; charset=utf-8']], ...['browser.mjs', 'plugin.mjs', 'catalog.mjs'].map(name => ['/' + name, [name, 'text/javascript; charset=utf-8']])]); +const modules = new Set((await readdir(source)).filter(name => /^[a-z0-9-]+\.mjs$/.test(name))); +const server = createServer(async (request, response) => { + try { + if (request.method !== 'GET') {response.writeHead(405).end(); return;} + let entry = files.get(request.url), base = root; + if (!entry && request.url.startsWith('/sdk/') && modules.has(request.url.slice(5))) {entry = [request.url.slice(5), 'text/javascript; charset=utf-8']; base = source;} + if (!entry) {response.writeHead(404).end(); return;} + response.writeHead(200, {'content-type': entry[1], 'cache-control': 'no-store', 'x-content-type-options': 'nosniff'}).end(await readFile(new URL(entry[0], base))); + } catch {response.writeHead(404).end();} +}); +await new Promise((resolve, reject) => {server.once('error', reject); server.listen(0, '127.0.0.1', resolve);}); +console.log('Configuration example: http://127.0.0.1:' + server.address().port); +for (const signal of ['SIGINT', 'SIGTERM']) process.once(signal, () => {server.close(); server.closeAllConnections();}); diff --git a/package-lock.json b/package-lock.json index 6eb7315..1a0ed85 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@altifigence/dds-plugin-sdk", - "version": "0.11.0", + "version": "0.12.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@altifigence/dds-plugin-sdk", - "version": "0.11.0", + "version": "0.12.0", "license": "Apache-2.0", "bin": { "dds-plugin": "bin/dds-plugin.mjs" diff --git a/package.json b/package.json index 7bdd1d6..90ea06a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@altifigence/dds-plugin-sdk", - "version": "0.11.0", + "version": "0.12.0", "description": "Plugin, theme and user-owned workspace tools for Digital Design Studio", "type": "module", "license": "Apache-2.0", @@ -16,6 +16,26 @@ "url": "https://github.com/Altifigence/dds-plugin-sdk/issues" }, "exports": { + "./localization": { + "types": "./src/localization.d.mts", + "import": "./src/localization.mjs" + }, + "./accessibility": { + "types": "./src/accessibility.d.mts", + "import": "./src/accessibility.mjs" + }, + "./data-schema": { + "types": "./src/data-schema.d.mts", + "import": "./src/data-schema.mjs" + }, + "./settings": { + "types": "./src/settings.d.mts", + "import": "./src/settings.mjs" + }, + "./secrets": { + "types": "./src/secrets.d.mts", + "import": "./src/secrets.mjs" + }, ".": { "types": "./src/index.d.mts", "import": "./src/index.mjs" @@ -153,6 +173,8 @@ "PUBLIC_SURFACE.json" ], "scripts": { + "example:configuration": "node examples/configuration/run.mjs", + "example:configuration-browser": "node examples/configuration/serve.mjs", "test": "node --test tests/*.test.mjs", "test:types": "tsc --noEmit -p tests/types/tsconfig.json", "schemas:write": "node scripts/schemas.mjs --write", diff --git a/schemas/artifact-sink-capabilities.schema.json b/schemas/artifact-sink-capabilities.schema.json index dcaffe8..840ed15 100644 --- a/schemas/artifact-sink-capabilities.schema.json +++ b/schemas/artifact-sink-capabilities.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.11.0/schemas/artifact-sink-capabilities.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.12.0/schemas/artifact-sink-capabilities.schema.json", "title": "artifact-sink-capabilities", "type": "object", "properties": { diff --git a/schemas/artifact-storage-capabilities.schema.json b/schemas/artifact-storage-capabilities.schema.json index 7442843..160f0c9 100644 --- a/schemas/artifact-storage-capabilities.schema.json +++ b/schemas/artifact-storage-capabilities.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.11.0/schemas/artifact-storage-capabilities.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.12.0/schemas/artifact-storage-capabilities.schema.json", "title": "artifact-storage-capabilities", "oneOf": [ { diff --git a/schemas/artifact-transfer-receipt.schema.json b/schemas/artifact-transfer-receipt.schema.json index 3308cb3..94dc84a 100644 --- a/schemas/artifact-transfer-receipt.schema.json +++ b/schemas/artifact-transfer-receipt.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.11.0/schemas/artifact-transfer-receipt.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.12.0/schemas/artifact-transfer-receipt.schema.json", "title": "artifact-transfer-receipt", "type": "object", "properties": { diff --git a/schemas/binary-artifact-capabilities.schema.json b/schemas/binary-artifact-capabilities.schema.json index 1e941b4..931c115 100644 --- a/schemas/binary-artifact-capabilities.schema.json +++ b/schemas/binary-artifact-capabilities.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.11.0/schemas/binary-artifact-capabilities.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.12.0/schemas/binary-artifact-capabilities.schema.json", "title": "binary-artifact-capabilities", "type": "object", "properties": { diff --git a/schemas/binary-artifact-chunk.schema.json b/schemas/binary-artifact-chunk.schema.json index 9a5f4b3..8f7d4a9 100644 --- a/schemas/binary-artifact-chunk.schema.json +++ b/schemas/binary-artifact-chunk.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.11.0/schemas/binary-artifact-chunk.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.12.0/schemas/binary-artifact-chunk.schema.json", "title": "binary-artifact-chunk", "type": "object", "properties": { diff --git a/schemas/binary-artifact-list.schema.json b/schemas/binary-artifact-list.schema.json index 44e2d27..960d8fe 100644 --- a/schemas/binary-artifact-list.schema.json +++ b/schemas/binary-artifact-list.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.11.0/schemas/binary-artifact-list.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.12.0/schemas/binary-artifact-list.schema.json", "title": "binary-artifact-list", "type": "object", "properties": { diff --git a/schemas/binary-artifact-reference.schema.json b/schemas/binary-artifact-reference.schema.json index 22dc371..406ca27 100644 --- a/schemas/binary-artifact-reference.schema.json +++ b/schemas/binary-artifact-reference.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.11.0/schemas/binary-artifact-reference.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.12.0/schemas/binary-artifact-reference.schema.json", "title": "binary-artifact-reference", "type": "object", "properties": { diff --git a/schemas/binary-artifact.schema.json b/schemas/binary-artifact.schema.json index e464d90..a1d7411 100644 --- a/schemas/binary-artifact.schema.json +++ b/schemas/binary-artifact.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.11.0/schemas/binary-artifact.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.12.0/schemas/binary-artifact.schema.json", "title": "binary-artifact", "type": "object", "properties": { diff --git a/schemas/browser-artifact-checkpoint.schema.json b/schemas/browser-artifact-checkpoint.schema.json index 0189e76..c286e93 100644 --- a/schemas/browser-artifact-checkpoint.schema.json +++ b/schemas/browser-artifact-checkpoint.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.11.0/schemas/browser-artifact-checkpoint.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.12.0/schemas/browser-artifact-checkpoint.schema.json", "title": "browser-artifact-checkpoint", "type": "object", "properties": { diff --git a/schemas/code-action.schema.json b/schemas/code-action.schema.json index 87ad84f..4818d4d 100644 --- a/schemas/code-action.schema.json +++ b/schemas/code-action.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.11.0/schemas/code-action.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.12.0/schemas/code-action.schema.json", "title": "code-action", "type": "object", "properties": { diff --git a/schemas/command.schema.json b/schemas/command.schema.json index 53bf5d6..4471a70 100644 --- a/schemas/command.schema.json +++ b/schemas/command.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.11.0/schemas/command.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.12.0/schemas/command.schema.json", "title": "command", "type": "object", "properties": { @@ -84,11 +84,907 @@ }, "minItems": 0, "maxItems": 32 + }, + "inputSchema": { + "type": "object", + "properties": { + "schemaVersion": { + "const": 1 + }, + "schema": { + "$ref": "#/$defs/dataNode" + } + }, + "required": [ + "schemaVersion", + "schema" + ], + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "properties": { + "schemaVersion": { + "const": 1 + }, + "schema": { + "$ref": "#/$defs/dataNode" + } + }, + "required": [ + "schemaVersion", + "schema" + ], + "additionalProperties": false + }, + "display": { + "type": "object", + "properties": { + "label": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "help": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "order": { + "type": "integer", + "minimum": 0, + "maximum": 10000 + }, + "labelKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "helpKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "accessibility": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "description": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "nameKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "descriptionKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "shortcut": { + "type": "string", + "minLength": 1, + "maxLength": 256 + } + }, + "required": [], + "additionalProperties": false + } + }, + "required": [], + "additionalProperties": false } }, "required": [ "id", "title" ], - "additionalProperties": false + "additionalProperties": false, + "not": { + "required": [ + "parameters", + "inputSchema" + ] + }, + "$defs": { + "dataNode": { + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "const": "string" + }, + "display": { + "type": "object", + "properties": { + "label": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "help": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "order": { + "type": "integer", + "minimum": 0, + "maximum": 10000 + }, + "labelKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "helpKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "accessibility": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "description": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "nameKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "descriptionKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "shortcut": { + "type": "string", + "minLength": 1, + "maxLength": 256 + } + }, + "required": [], + "additionalProperties": false + } + }, + "required": [], + "additionalProperties": false + }, + "minLength": { + "type": "integer", + "minimum": 0, + "maximum": 16384 + }, + "maxLength": { + "type": "integer", + "minimum": 0, + "maximum": 16384 + }, + "enum": { + "type": "array", + "items": { + "type": "string", + "minLength": 0, + "maxLength": 16384 + }, + "minItems": 1, + "maxItems": 64, + "uniqueItems": true + }, + "default": { + "type": "string", + "minLength": 0, + "maxLength": 16384 + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "number" + }, + "display": { + "type": "object", + "properties": { + "label": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "help": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "order": { + "type": "integer", + "minimum": 0, + "maximum": 10000 + }, + "labelKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "helpKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "accessibility": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "description": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "nameKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "descriptionKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "shortcut": { + "type": "string", + "minLength": 1, + "maxLength": 256 + } + }, + "required": [], + "additionalProperties": false + } + }, + "required": [], + "additionalProperties": false + }, + "minimum": { + "type": "number" + }, + "maximum": { + "type": "number" + }, + "enum": { + "type": "array", + "items": { + "type": "number" + }, + "minItems": 1, + "maxItems": 64, + "uniqueItems": true + }, + "default": { + "type": "number" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "integer" + }, + "display": { + "type": "object", + "properties": { + "label": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "help": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "order": { + "type": "integer", + "minimum": 0, + "maximum": 10000 + }, + "labelKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "helpKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "accessibility": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "description": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "nameKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "descriptionKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "shortcut": { + "type": "string", + "minLength": 1, + "maxLength": 256 + } + }, + "required": [], + "additionalProperties": false + } + }, + "required": [], + "additionalProperties": false + }, + "minimum": { + "type": "integer" + }, + "maximum": { + "type": "integer" + }, + "enum": { + "type": "array", + "items": { + "type": "integer" + }, + "minItems": 1, + "maxItems": 64, + "uniqueItems": true + }, + "default": { + "type": "integer" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "boolean" + }, + "display": { + "type": "object", + "properties": { + "label": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "help": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "order": { + "type": "integer", + "minimum": 0, + "maximum": 10000 + }, + "labelKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "helpKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "accessibility": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "description": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "nameKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "descriptionKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "shortcut": { + "type": "string", + "minLength": 1, + "maxLength": 256 + } + }, + "required": [], + "additionalProperties": false + } + }, + "required": [], + "additionalProperties": false + }, + "enum": { + "type": "array", + "items": { + "type": "boolean" + }, + "minItems": 1, + "maxItems": 64, + "uniqueItems": true + }, + "default": { + "type": "boolean" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "null" + }, + "display": { + "type": "object", + "properties": { + "label": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "help": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "order": { + "type": "integer", + "minimum": 0, + "maximum": 10000 + }, + "labelKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "helpKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "accessibility": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "description": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "nameKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "descriptionKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "shortcut": { + "type": "string", + "minLength": 1, + "maxLength": 256 + } + }, + "required": [], + "additionalProperties": false + } + }, + "required": [], + "additionalProperties": false + }, + "enum": { + "type": "array", + "items": { + "type": "null" + }, + "minItems": 1, + "maxItems": 64, + "uniqueItems": true + }, + "default": { + "type": "null" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "array" + }, + "display": { + "type": "object", + "properties": { + "label": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "help": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "order": { + "type": "integer", + "minimum": 0, + "maximum": 10000 + }, + "labelKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "helpKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "accessibility": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "description": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "nameKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "descriptionKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "shortcut": { + "type": "string", + "minLength": 1, + "maxLength": 256 + } + }, + "required": [], + "additionalProperties": false + } + }, + "required": [], + "additionalProperties": false + }, + "items": { + "$ref": "#/$defs/dataNode" + }, + "minItems": { + "type": "integer", + "minimum": 0, + "maximum": 256 + }, + "maxItems": { + "type": "integer", + "minimum": 0, + "maximum": 256 + }, + "default": { + "type": "array", + "items": {}, + "minItems": 0, + "maxItems": 256 + } + }, + "required": [ + "type", + "items" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "object" + }, + "display": { + "type": "object", + "properties": { + "label": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "help": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "order": { + "type": "integer", + "minimum": 0, + "maximum": 10000 + }, + "labelKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "helpKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "accessibility": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "description": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "nameKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "descriptionKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "shortcut": { + "type": "string", + "minLength": 1, + "maxLength": 256 + } + }, + "required": [], + "additionalProperties": false + } + }, + "required": [], + "additionalProperties": false + }, + "properties": { + "type": "object", + "maxProperties": 64, + "propertyNames": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "additionalProperties": { + "$ref": "#/$defs/dataNode" + } + }, + "additionalProperties": { + "const": false + }, + "required": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "minItems": 0, + "maxItems": 64, + "uniqueItems": true + }, + "default": { + "type": "object" + } + }, + "required": [ + "type", + "properties", + "additionalProperties" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "object" + }, + "format": { + "const": "dds-secret-reference" + }, + "display": { + "type": "object", + "properties": { + "label": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "help": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "order": { + "type": "integer", + "minimum": 0, + "maximum": 10000 + }, + "labelKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "helpKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "accessibility": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "description": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "nameKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "descriptionKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "shortcut": { + "type": "string", + "minLength": 1, + "maxLength": 256 + } + }, + "required": [], + "additionalProperties": false + } + }, + "required": [], + "additionalProperties": false + } + }, + "required": [ + "type", + "format" + ], + "additionalProperties": false + } + ] + } + }, + "x-maxUtf8Bytes": 16384 } diff --git a/schemas/completion-item.schema.json b/schemas/completion-item.schema.json index 4a2c821..055452d 100644 --- a/schemas/completion-item.schema.json +++ b/schemas/completion-item.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.11.0/schemas/completion-item.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.12.0/schemas/completion-item.schema.json", "title": "completion-item", "type": "object", "properties": { diff --git a/schemas/data-schema.schema.json b/schemas/data-schema.schema.json new file mode 100644 index 0000000..9bd0763 --- /dev/null +++ b/schemas/data-schema.schema.json @@ -0,0 +1,810 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.12.0/schemas/data-schema.schema.json", + "title": "data-schema", + "type": "object", + "properties": { + "schemaVersion": { + "const": 1 + }, + "schema": { + "$ref": "#/$defs/dataNode" + } + }, + "required": [ + "schemaVersion", + "schema" + ], + "additionalProperties": false, + "x-maxUtf8Bytes": 32768, + "$comment": "Closed version-1 subset. Runtime enforces depth 8, 128 schema nodes, finite numbers/safe integers, matching required keys, range ordering, typed enums and valid defaults. No input $ref, pattern, union or external resolution. Reference defaults are forbidden, including nested references. Strings use Unicode code-point length. Values/default expansion are bounded to 64 KiB of plain JSON.", + "$defs": { + "dataNode": { + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "const": "string" + }, + "display": { + "type": "object", + "properties": { + "label": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "help": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "order": { + "type": "integer", + "minimum": 0, + "maximum": 10000 + }, + "labelKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "helpKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "accessibility": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "description": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "nameKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "descriptionKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "shortcut": { + "type": "string", + "minLength": 1, + "maxLength": 256 + } + }, + "required": [], + "additionalProperties": false + } + }, + "required": [], + "additionalProperties": false + }, + "minLength": { + "type": "integer", + "minimum": 0, + "maximum": 16384 + }, + "maxLength": { + "type": "integer", + "minimum": 0, + "maximum": 16384 + }, + "enum": { + "type": "array", + "items": { + "type": "string", + "minLength": 0, + "maxLength": 16384 + }, + "minItems": 1, + "maxItems": 64, + "uniqueItems": true + }, + "default": { + "type": "string", + "minLength": 0, + "maxLength": 16384 + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "number" + }, + "display": { + "type": "object", + "properties": { + "label": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "help": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "order": { + "type": "integer", + "minimum": 0, + "maximum": 10000 + }, + "labelKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "helpKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "accessibility": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "description": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "nameKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "descriptionKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "shortcut": { + "type": "string", + "minLength": 1, + "maxLength": 256 + } + }, + "required": [], + "additionalProperties": false + } + }, + "required": [], + "additionalProperties": false + }, + "minimum": { + "type": "number" + }, + "maximum": { + "type": "number" + }, + "enum": { + "type": "array", + "items": { + "type": "number" + }, + "minItems": 1, + "maxItems": 64, + "uniqueItems": true + }, + "default": { + "type": "number" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "integer" + }, + "display": { + "type": "object", + "properties": { + "label": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "help": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "order": { + "type": "integer", + "minimum": 0, + "maximum": 10000 + }, + "labelKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "helpKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "accessibility": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "description": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "nameKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "descriptionKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "shortcut": { + "type": "string", + "minLength": 1, + "maxLength": 256 + } + }, + "required": [], + "additionalProperties": false + } + }, + "required": [], + "additionalProperties": false + }, + "minimum": { + "type": "integer" + }, + "maximum": { + "type": "integer" + }, + "enum": { + "type": "array", + "items": { + "type": "integer" + }, + "minItems": 1, + "maxItems": 64, + "uniqueItems": true + }, + "default": { + "type": "integer" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "boolean" + }, + "display": { + "type": "object", + "properties": { + "label": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "help": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "order": { + "type": "integer", + "minimum": 0, + "maximum": 10000 + }, + "labelKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "helpKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "accessibility": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "description": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "nameKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "descriptionKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "shortcut": { + "type": "string", + "minLength": 1, + "maxLength": 256 + } + }, + "required": [], + "additionalProperties": false + } + }, + "required": [], + "additionalProperties": false + }, + "enum": { + "type": "array", + "items": { + "type": "boolean" + }, + "minItems": 1, + "maxItems": 64, + "uniqueItems": true + }, + "default": { + "type": "boolean" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "null" + }, + "display": { + "type": "object", + "properties": { + "label": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "help": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "order": { + "type": "integer", + "minimum": 0, + "maximum": 10000 + }, + "labelKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "helpKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "accessibility": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "description": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "nameKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "descriptionKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "shortcut": { + "type": "string", + "minLength": 1, + "maxLength": 256 + } + }, + "required": [], + "additionalProperties": false + } + }, + "required": [], + "additionalProperties": false + }, + "enum": { + "type": "array", + "items": { + "type": "null" + }, + "minItems": 1, + "maxItems": 64, + "uniqueItems": true + }, + "default": { + "type": "null" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "array" + }, + "display": { + "type": "object", + "properties": { + "label": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "help": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "order": { + "type": "integer", + "minimum": 0, + "maximum": 10000 + }, + "labelKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "helpKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "accessibility": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "description": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "nameKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "descriptionKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "shortcut": { + "type": "string", + "minLength": 1, + "maxLength": 256 + } + }, + "required": [], + "additionalProperties": false + } + }, + "required": [], + "additionalProperties": false + }, + "items": { + "$ref": "#/$defs/dataNode" + }, + "minItems": { + "type": "integer", + "minimum": 0, + "maximum": 256 + }, + "maxItems": { + "type": "integer", + "minimum": 0, + "maximum": 256 + }, + "default": { + "type": "array", + "items": {}, + "minItems": 0, + "maxItems": 256 + } + }, + "required": [ + "type", + "items" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "object" + }, + "display": { + "type": "object", + "properties": { + "label": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "help": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "order": { + "type": "integer", + "minimum": 0, + "maximum": 10000 + }, + "labelKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "helpKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "accessibility": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "description": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "nameKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "descriptionKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "shortcut": { + "type": "string", + "minLength": 1, + "maxLength": 256 + } + }, + "required": [], + "additionalProperties": false + } + }, + "required": [], + "additionalProperties": false + }, + "properties": { + "type": "object", + "maxProperties": 64, + "propertyNames": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "additionalProperties": { + "$ref": "#/$defs/dataNode" + } + }, + "additionalProperties": { + "const": false + }, + "required": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "minItems": 0, + "maxItems": 64, + "uniqueItems": true + }, + "default": { + "type": "object" + } + }, + "required": [ + "type", + "properties", + "additionalProperties" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "object" + }, + "format": { + "const": "dds-secret-reference" + }, + "display": { + "type": "object", + "properties": { + "label": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "help": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "order": { + "type": "integer", + "minimum": 0, + "maximum": 10000 + }, + "labelKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "helpKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "accessibility": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "description": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "nameKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "descriptionKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "shortcut": { + "type": "string", + "minLength": 1, + "maxLength": 256 + } + }, + "required": [], + "additionalProperties": false + } + }, + "required": [], + "additionalProperties": false + } + }, + "required": [ + "type", + "format" + ], + "additionalProperties": false + } + ] + } + } +} diff --git a/schemas/diagnostics-request.schema.json b/schemas/diagnostics-request.schema.json index d680583..4fd2d32 100644 --- a/schemas/diagnostics-request.schema.json +++ b/schemas/diagnostics-request.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.11.0/schemas/diagnostics-request.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.12.0/schemas/diagnostics-request.schema.json", "title": "diagnostics-request", "type": "object", "properties": { diff --git a/schemas/diagnostics-result.schema.json b/schemas/diagnostics-result.schema.json index e7a47f2..1058df5 100644 --- a/schemas/diagnostics-result.schema.json +++ b/schemas/diagnostics-result.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.11.0/schemas/diagnostics-result.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.12.0/schemas/diagnostics-result.schema.json", "title": "diagnostics-result", "type": "object", "properties": { diff --git a/schemas/display-metadata.schema.json b/schemas/display-metadata.schema.json new file mode 100644 index 0000000..656650d --- /dev/null +++ b/schemas/display-metadata.schema.json @@ -0,0 +1,72 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.12.0/schemas/display-metadata.schema.json", + "title": "display-metadata", + "type": "object", + "properties": { + "label": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "help": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "order": { + "type": "integer", + "minimum": 0, + "maximum": 10000 + }, + "labelKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "helpKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "accessibility": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "description": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "nameKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "descriptionKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "shortcut": { + "type": "string", + "minLength": 1, + "maxLength": 256 + } + }, + "required": [], + "additionalProperties": false + } + }, + "required": [], + "additionalProperties": false, + "$comment": "All fields are literal display data. Runtime additionally checks well-formed UTF-16 and rejects NUL. Locale key cross-checks are separate." +} diff --git a/schemas/document-symbol-tree.schema.json b/schemas/document-symbol-tree.schema.json index 42ac22e..89f35a0 100644 --- a/schemas/document-symbol-tree.schema.json +++ b/schemas/document-symbol-tree.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.11.0/schemas/document-symbol-tree.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.12.0/schemas/document-symbol-tree.schema.json", "title": "document-symbol-tree", "type": "array", "items": { diff --git a/schemas/error.schema.json b/schemas/error.schema.json index 075a765..3e72fcc 100644 --- a/schemas/error.schema.json +++ b/schemas/error.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.11.0/schemas/error.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.12.0/schemas/error.schema.json", "title": "error", "type": "object", "properties": { diff --git a/schemas/folding-ranges.schema.json b/schemas/folding-ranges.schema.json index fba88c1..1c62773 100644 --- a/schemas/folding-ranges.schema.json +++ b/schemas/folding-ranges.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.11.0/schemas/folding-ranges.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.12.0/schemas/folding-ranges.schema.json", "title": "folding-ranges", "type": "array", "items": { diff --git a/schemas/formatting-options.schema.json b/schemas/formatting-options.schema.json index 85426a2..64e21de 100644 --- a/schemas/formatting-options.schema.json +++ b/schemas/formatting-options.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.11.0/schemas/formatting-options.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.12.0/schemas/formatting-options.schema.json", "title": "formatting-options", "type": "object", "properties": { diff --git a/schemas/inlay-hints.schema.json b/schemas/inlay-hints.schema.json index 7afd9f7..0e046f2 100644 --- a/schemas/inlay-hints.schema.json +++ b/schemas/inlay-hints.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.11.0/schemas/inlay-hints.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.12.0/schemas/inlay-hints.schema.json", "title": "inlay-hints", "type": "array", "items": { diff --git a/schemas/job-artifact-content.schema.json b/schemas/job-artifact-content.schema.json index 414d40b..e3eb0a8 100644 --- a/schemas/job-artifact-content.schema.json +++ b/schemas/job-artifact-content.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.11.0/schemas/job-artifact-content.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.12.0/schemas/job-artifact-content.schema.json", "title": "job-artifact-content", "type": "object", "properties": { diff --git a/schemas/job-capabilities.schema.json b/schemas/job-capabilities.schema.json index 0590083..7326367 100644 --- a/schemas/job-capabilities.schema.json +++ b/schemas/job-capabilities.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.11.0/schemas/job-capabilities.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.12.0/schemas/job-capabilities.schema.json", "title": "job-capabilities", "type": "object", "properties": { diff --git a/schemas/job-events.schema.json b/schemas/job-events.schema.json index 65a316c..98d2caf 100644 --- a/schemas/job-events.schema.json +++ b/schemas/job-events.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.11.0/schemas/job-events.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.12.0/schemas/job-events.schema.json", "title": "job-events", "type": "object", "properties": { diff --git a/schemas/job-history-page.schema.json b/schemas/job-history-page.schema.json index 81cb27e..a4cb3cd 100644 --- a/schemas/job-history-page.schema.json +++ b/schemas/job-history-page.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.11.0/schemas/job-history-page.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.12.0/schemas/job-history-page.schema.json", "title": "job-history-page", "type": "object", "properties": { diff --git a/schemas/job-history-query.schema.json b/schemas/job-history-query.schema.json index 8f53021..273e814 100644 --- a/schemas/job-history-query.schema.json +++ b/schemas/job-history-query.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.11.0/schemas/job-history-query.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.12.0/schemas/job-history-query.schema.json", "title": "job-history-query", "type": "object", "properties": { diff --git a/schemas/job-options.schema.json b/schemas/job-options.schema.json index d450de8..1501192 100644 --- a/schemas/job-options.schema.json +++ b/schemas/job-options.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.11.0/schemas/job-options.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.12.0/schemas/job-options.schema.json", "title": "job-options", "type": "object", "properties": { diff --git a/schemas/job-recovery.schema.json b/schemas/job-recovery.schema.json index d6928d9..d94630b 100644 --- a/schemas/job-recovery.schema.json +++ b/schemas/job-recovery.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.11.0/schemas/job-recovery.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.12.0/schemas/job-recovery.schema.json", "title": "job-recovery", "oneOf": [ { @@ -115,15 +115,17 @@ "workspace.read", "workspace.write", "backend.invoke", - "language.provide" + "language.provide", + "settings.read", + "secrets.resolve" ] }, "minItems": 0, - "maxItems": 6, + "maxItems": 8, "uniqueItems": true }, "snapshot": { - "$ref": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.11.0/schemas/job-snapshot.schema.json" + "$ref": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.12.0/schemas/job-snapshot.schema.json" }, "events": { "type": "array", @@ -679,15 +681,17 @@ "workspace.read", "workspace.write", "backend.invoke", - "language.provide" + "language.provide", + "settings.read", + "secrets.resolve" ] }, "minItems": 0, - "maxItems": 6, + "maxItems": 8, "uniqueItems": true }, "snapshot": { - "$ref": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.11.0/schemas/job-snapshot.schema.json" + "$ref": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.12.0/schemas/job-snapshot.schema.json" }, "events": { "type": "array", @@ -1243,15 +1247,17 @@ "workspace.read", "workspace.write", "backend.invoke", - "language.provide" + "language.provide", + "settings.read", + "secrets.resolve" ] }, "minItems": 0, - "maxItems": 6, + "maxItems": 8, "uniqueItems": true }, "snapshot": { - "$ref": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.11.0/schemas/job-snapshot.schema.json" + "$ref": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.12.0/schemas/job-snapshot.schema.json" }, "events": { "type": "array", diff --git a/schemas/job-snapshot.schema.json b/schemas/job-snapshot.schema.json index 8369b16..c3e309a 100644 --- a/schemas/job-snapshot.schema.json +++ b/schemas/job-snapshot.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.11.0/schemas/job-snapshot.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.12.0/schemas/job-snapshot.schema.json", "title": "job-snapshot", "oneOf": [ { @@ -305,7 +305,7 @@ "const": "succeeded" }, "result": { - "$ref": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.11.0/schemas/json-value.schema.json" + "$ref": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.12.0/schemas/json-value.schema.json" } }, "required": [ diff --git a/schemas/job-storage-capabilities.schema.json b/schemas/job-storage-capabilities.schema.json index 746daad..a5e49cc 100644 --- a/schemas/job-storage-capabilities.schema.json +++ b/schemas/job-storage-capabilities.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.11.0/schemas/job-storage-capabilities.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.12.0/schemas/job-storage-capabilities.schema.json", "title": "job-storage-capabilities", "oneOf": [ { diff --git a/schemas/job-store-identity.schema.json b/schemas/job-store-identity.schema.json index f2efff8..e7b6d56 100644 --- a/schemas/job-store-identity.schema.json +++ b/schemas/job-store-identity.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.11.0/schemas/job-store-identity.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.12.0/schemas/job-store-identity.schema.json", "title": "job-store-identity", "type": "object", "properties": { diff --git a/schemas/json-value.schema.json b/schemas/json-value.schema.json index d9be63e..0765b68 100644 --- a/schemas/json-value.schema.json +++ b/schemas/json-value.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.11.0/schemas/json-value.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.12.0/schemas/json-value.schema.json", "title": "json-value", "$ref": "#/$defs/value", "x-maxUtf8Bytes": 262144, diff --git a/schemas/language-capabilities.schema.json b/schemas/language-capabilities.schema.json index c2c92d2..4eb3dcd 100644 --- a/schemas/language-capabilities.schema.json +++ b/schemas/language-capabilities.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.11.0/schemas/language-capabilities.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.12.0/schemas/language-capabilities.schema.json", "title": "language-capabilities", "type": "object", "properties": { diff --git a/schemas/language-request.schema.json b/schemas/language-request.schema.json index 1768e21..36674bb 100644 --- a/schemas/language-request.schema.json +++ b/schemas/language-request.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.11.0/schemas/language-request.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.12.0/schemas/language-request.schema.json", "title": "language-request", "oneOf": [ { diff --git a/schemas/language-result.schema.json b/schemas/language-result.schema.json index bb786dc..5a77e18 100644 --- a/schemas/language-result.schema.json +++ b/schemas/language-result.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.11.0/schemas/language-result.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.12.0/schemas/language-result.schema.json", "title": "language-result", "oneOf": [ { @@ -3178,7 +3178,7 @@ "const": "document-symbol-tree" }, "data": { - "$ref": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.11.0/schemas/document-symbol-tree.schema.json" + "$ref": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.12.0/schemas/document-symbol-tree.schema.json" } }, "required": [ diff --git a/schemas/language-snippet.schema.json b/schemas/language-snippet.schema.json index d73eb5e..5672938 100644 --- a/schemas/language-snippet.schema.json +++ b/schemas/language-snippet.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.11.0/schemas/language-snippet.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.12.0/schemas/language-snippet.schema.json", "title": "language-snippet", "type": "string", "minLength": 0, diff --git a/schemas/locale-catalog.schema.json b/schemas/locale-catalog.schema.json new file mode 100644 index 0000000..d6f7ded --- /dev/null +++ b/schemas/locale-catalog.schema.json @@ -0,0 +1,71 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.12.0/schemas/locale-catalog.schema.json", + "title": "locale-catalog", + "type": "object", + "properties": { + "schemaVersion": { + "const": 1 + }, + "defaultLocale": { + "type": "string", + "minLength": 2, + "maxLength": 64, + "$comment": "Runtime requires a canonical BCP 47 base tag supported by Intl.getCanonicalLocales; extensions/private-use are rejected." + }, + "messages": { + "type": "object", + "minProperties": 1, + "maxProperties": 32, + "propertyNames": { + "type": "string", + "minLength": 2, + "maxLength": 64, + "$comment": "Runtime requires a canonical BCP 47 base tag supported by Intl.getCanonicalLocales; extensions/private-use are rejected." + }, + "additionalProperties": { + "type": "object", + "maxProperties": 256, + "propertyNames": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "additionalProperties": { + "type": "string", + "maxLength": 4096 + } + } + }, + "fallbacks": { + "type": "object", + "maxProperties": 32, + "propertyNames": { + "type": "string", + "minLength": 2, + "maxLength": 64, + "$comment": "Runtime requires a canonical BCP 47 base tag supported by Intl.getCanonicalLocales; extensions/private-use are rejected." + }, + "additionalProperties": { + "type": "array", + "maxItems": 32, + "uniqueItems": true, + "items": { + "type": "string", + "minLength": 2, + "maxLength": 64, + "$comment": "Runtime requires a canonical BCP 47 base tag supported by Intl.getCanonicalLocales; extensions/private-use are rejected." + } + } + } + }, + "required": [ + "schemaVersion", + "defaultLocale", + "messages" + ], + "additionalProperties": false, + "x-maxUtf8Bytes": 262144, + "$comment": "Runtime requires a nonempty default catalog and matching placeholder sets for every translation. Other locales may omit keys; unknown keys, noncanonical locales, undefined fallback targets, cycles and fallback edges from the default locale are rejected. Display cross-checks validate locale keys independently. Values are literal text; no HTML, ICU expression or remote loading." +} diff --git a/schemas/localized-message.schema.json b/schemas/localized-message.schema.json new file mode 100644 index 0000000..2307e26 --- /dev/null +++ b/schemas/localized-message.schema.json @@ -0,0 +1,48 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.12.0/schemas/localized-message.schema.json", + "title": "localized-message", + "type": "object", + "properties": { + "key": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "text": { + "type": "string", + "maxLength": 16384 + }, + "locale": { + "type": "string", + "minLength": 2, + "maxLength": 64, + "$comment": "Runtime requires a canonical BCP 47 base tag supported by Intl.getCanonicalLocales; extensions/private-use are rejected." + }, + "requestedLocale": { + "type": "string", + "minLength": 2, + "maxLength": 64, + "$comment": "Runtime requires a canonical BCP 47 base tag supported by Intl.getCanonicalLocales; extensions/private-use are rejected." + }, + "fallback": { + "type": "boolean" + }, + "direction": { + "enum": [ + "ltr", + "rtl" + ] + } + }, + "required": [ + "key", + "text", + "locale", + "requestedLocale", + "fallback", + "direction" + ], + "additionalProperties": false +} diff --git a/schemas/manifest.schema.json b/schemas/manifest.schema.json index 574c966..8a2458b 100644 --- a/schemas/manifest.schema.json +++ b/schemas/manifest.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.11.0/schemas/manifest.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.12.0/schemas/manifest.schema.json", "title": "manifest", "properties": { "id": { @@ -181,6 +181,7 @@ "enum": [ "diagnostics", "commands", + "settings", "completion", "hover", "definition", @@ -203,7 +204,7 @@ ] }, "minItems": 1, - "maxItems": 21, + "maxItems": 22, "uniqueItems": true }, "permissions": { @@ -215,11 +216,13 @@ "workspace.read", "workspace.write", "backend.invoke", - "language.provide" + "language.provide", + "settings.read", + "secrets.resolve" ] }, "minItems": 0, - "maxItems": 6, + "maxItems": 8, "uniqueItems": true }, "supportedHosts": { @@ -271,6 +274,74 @@ "licenseFile" ], "additionalProperties": false + }, + "display": { + "type": "object", + "properties": { + "label": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "help": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "order": { + "type": "integer", + "minimum": 0, + "maximum": 10000 + }, + "labelKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "helpKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "accessibility": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "description": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "nameKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "descriptionKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "shortcut": { + "type": "string", + "minLength": 1, + "maxLength": 256 + } + }, + "required": [], + "additionalProperties": false + } + }, + "required": [], + "additionalProperties": false } }, "required": [ @@ -305,7 +376,8 @@ "enum": [ "document.read", "diagnostics.publish", - "language.provide" + "language.provide", + "settings.read" ] } } diff --git a/schemas/project-entry.schema.json b/schemas/project-entry.schema.json index 6745882..e0655a0 100644 --- a/schemas/project-entry.schema.json +++ b/schemas/project-entry.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.11.0/schemas/project-entry.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.12.0/schemas/project-entry.schema.json", "title": "project-entry", "type": "object", "properties": { diff --git a/schemas/project-query-capabilities.schema.json b/schemas/project-query-capabilities.schema.json index 0720967..bc78e79 100644 --- a/schemas/project-query-capabilities.schema.json +++ b/schemas/project-query-capabilities.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.11.0/schemas/project-query-capabilities.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.12.0/schemas/project-query-capabilities.schema.json", "title": "project-query-capabilities", "type": "object", "properties": { diff --git a/schemas/project-query-options.schema.json b/schemas/project-query-options.schema.json index 6503b3d..8f2bca2 100644 --- a/schemas/project-query-options.schema.json +++ b/schemas/project-query-options.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.11.0/schemas/project-query-options.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.12.0/schemas/project-query-options.schema.json", "title": "project-query-options", "oneOf": [ { diff --git a/schemas/project-query-page.schema.json b/schemas/project-query-page.schema.json index 7776a02..96835f0 100644 --- a/schemas/project-query-page.schema.json +++ b/schemas/project-query-page.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.11.0/schemas/project-query-page.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.12.0/schemas/project-query-page.schema.json", "title": "project-query-page", "type": "object", "properties": { diff --git a/schemas/project-snapshot.schema.json b/schemas/project-snapshot.schema.json index bd1a8d3..42a7166 100644 --- a/schemas/project-snapshot.schema.json +++ b/schemas/project-snapshot.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.11.0/schemas/project-snapshot.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.12.0/schemas/project-snapshot.schema.json", "title": "project-snapshot", "type": "object", "properties": { diff --git a/schemas/project-watch-capabilities.schema.json b/schemas/project-watch-capabilities.schema.json index d3815fa..6c3fb10 100644 --- a/schemas/project-watch-capabilities.schema.json +++ b/schemas/project-watch-capabilities.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.11.0/schemas/project-watch-capabilities.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.12.0/schemas/project-watch-capabilities.schema.json", "title": "project-watch-capabilities", "type": "object", "properties": { diff --git a/schemas/project-watch-event.schema.json b/schemas/project-watch-event.schema.json index 600ffdb..7df6f42 100644 --- a/schemas/project-watch-event.schema.json +++ b/schemas/project-watch-event.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.11.0/schemas/project-watch-event.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.12.0/schemas/project-watch-event.schema.json", "title": "project-watch-event", "type": "object", "properties": { diff --git a/schemas/project-watch-options.schema.json b/schemas/project-watch-options.schema.json index 5c1a95b..522639f 100644 --- a/schemas/project-watch-options.schema.json +++ b/schemas/project-watch-options.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.11.0/schemas/project-watch-options.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.12.0/schemas/project-watch-options.schema.json", "title": "project-watch-options", "type": "object", "properties": { diff --git a/schemas/secret-reference.schema.json b/schemas/secret-reference.schema.json new file mode 100644 index 0000000..243765d --- /dev/null +++ b/schemas/secret-reference.schema.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.12.0/schemas/secret-reference.schema.json", + "title": "secret-reference", + "type": "object", + "properties": { + "kind": { + "const": "dds-secret-reference" + }, + "id": { + "type": "string", + "minLength": 36, + "maxLength": 36, + "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$" + } + }, + "required": [ + "kind", + "id" + ], + "additionalProperties": false +} diff --git a/schemas/secret-scope.schema.json b/schemas/secret-scope.schema.json new file mode 100644 index 0000000..f5e3e43 --- /dev/null +++ b/schemas/secret-scope.schema.json @@ -0,0 +1,38 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.12.0/schemas/secret-scope.schema.json", + "title": "secret-scope", + "type": "object", + "properties": { + "pluginId": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$" + }, + "workspaceId": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "commandId": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$" + }, + "executionId": { + "type": "string", + "minLength": 36, + "maxLength": 36, + "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$" + } + }, + "required": [ + "pluginId", + "workspaceId", + "commandId", + "executionId" + ], + "additionalProperties": false +} diff --git a/schemas/semantic-legend.schema.json b/schemas/semantic-legend.schema.json index 9606e93..6814339 100644 --- a/schemas/semantic-legend.schema.json +++ b/schemas/semantic-legend.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.11.0/schemas/semantic-legend.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.12.0/schemas/semantic-legend.schema.json", "title": "semantic-legend", "type": "object", "properties": { diff --git a/schemas/semantic-tokens-delta.schema.json b/schemas/semantic-tokens-delta.schema.json index 072a3ee..8634ba8 100644 --- a/schemas/semantic-tokens-delta.schema.json +++ b/schemas/semantic-tokens-delta.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.11.0/schemas/semantic-tokens-delta.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.12.0/schemas/semantic-tokens-delta.schema.json", "title": "semantic-tokens-delta", "type": "object", "properties": { diff --git a/schemas/semantic-tokens.schema.json b/schemas/semantic-tokens.schema.json index ba16907..eb35c0d 100644 --- a/schemas/semantic-tokens.schema.json +++ b/schemas/semantic-tokens.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.11.0/schemas/semantic-tokens.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.12.0/schemas/semantic-tokens.schema.json", "title": "semantic-tokens", "type": "object", "properties": { diff --git a/schemas/settings-definition.schema.json b/schemas/settings-definition.schema.json new file mode 100644 index 0000000..dc7509f --- /dev/null +++ b/schemas/settings-definition.schema.json @@ -0,0 +1,939 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.12.0/schemas/settings-definition.schema.json", + "title": "settings-definition", + "type": "object", + "properties": { + "schemaVersion": { + "const": 1 + }, + "pluginId": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$" + }, + "version": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "settings": { + "type": "object", + "maxProperties": 64, + "propertyNames": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "additionalProperties": { + "type": "object", + "properties": { + "schema": { + "type": "object", + "properties": { + "schemaVersion": { + "const": 1 + }, + "schema": { + "$ref": "#/$defs/dataNode" + } + }, + "required": [ + "schemaVersion", + "schema" + ], + "additionalProperties": false + }, + "scopes": { + "type": "array", + "items": { + "enum": [ + "user", + "workspace" + ] + }, + "minItems": 0, + "maxItems": 2, + "uniqueItems": true + }, + "readOnly": { + "type": "boolean" + }, + "display": { + "type": "object", + "properties": { + "label": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "help": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "order": { + "type": "integer", + "minimum": 0, + "maximum": 10000 + }, + "labelKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "helpKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "accessibility": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "description": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "nameKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "descriptionKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "shortcut": { + "type": "string", + "minLength": 1, + "maxLength": 256 + } + }, + "required": [], + "additionalProperties": false + } + }, + "required": [], + "additionalProperties": false + } + }, + "required": [ + "schema", + "scopes" + ], + "additionalProperties": false + } + } + }, + "required": [ + "schemaVersion", + "pluginId", + "version", + "settings" + ], + "additionalProperties": false, + "x-maxUtf8Bytes": 65536, + "$comment": "Runtime validates each data schema. Read-only settings require a default and no writable scopes; other settings require at least one scope. Unknown/deleted keys never migrate implicitly.", + "$defs": { + "dataNode": { + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "const": "string" + }, + "display": { + "type": "object", + "properties": { + "label": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "help": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "order": { + "type": "integer", + "minimum": 0, + "maximum": 10000 + }, + "labelKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "helpKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "accessibility": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "description": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "nameKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "descriptionKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "shortcut": { + "type": "string", + "minLength": 1, + "maxLength": 256 + } + }, + "required": [], + "additionalProperties": false + } + }, + "required": [], + "additionalProperties": false + }, + "minLength": { + "type": "integer", + "minimum": 0, + "maximum": 16384 + }, + "maxLength": { + "type": "integer", + "minimum": 0, + "maximum": 16384 + }, + "enum": { + "type": "array", + "items": { + "type": "string", + "minLength": 0, + "maxLength": 16384 + }, + "minItems": 1, + "maxItems": 64, + "uniqueItems": true + }, + "default": { + "type": "string", + "minLength": 0, + "maxLength": 16384 + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "number" + }, + "display": { + "type": "object", + "properties": { + "label": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "help": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "order": { + "type": "integer", + "minimum": 0, + "maximum": 10000 + }, + "labelKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "helpKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "accessibility": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "description": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "nameKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "descriptionKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "shortcut": { + "type": "string", + "minLength": 1, + "maxLength": 256 + } + }, + "required": [], + "additionalProperties": false + } + }, + "required": [], + "additionalProperties": false + }, + "minimum": { + "type": "number" + }, + "maximum": { + "type": "number" + }, + "enum": { + "type": "array", + "items": { + "type": "number" + }, + "minItems": 1, + "maxItems": 64, + "uniqueItems": true + }, + "default": { + "type": "number" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "integer" + }, + "display": { + "type": "object", + "properties": { + "label": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "help": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "order": { + "type": "integer", + "minimum": 0, + "maximum": 10000 + }, + "labelKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "helpKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "accessibility": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "description": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "nameKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "descriptionKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "shortcut": { + "type": "string", + "minLength": 1, + "maxLength": 256 + } + }, + "required": [], + "additionalProperties": false + } + }, + "required": [], + "additionalProperties": false + }, + "minimum": { + "type": "integer" + }, + "maximum": { + "type": "integer" + }, + "enum": { + "type": "array", + "items": { + "type": "integer" + }, + "minItems": 1, + "maxItems": 64, + "uniqueItems": true + }, + "default": { + "type": "integer" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "boolean" + }, + "display": { + "type": "object", + "properties": { + "label": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "help": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "order": { + "type": "integer", + "minimum": 0, + "maximum": 10000 + }, + "labelKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "helpKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "accessibility": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "description": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "nameKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "descriptionKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "shortcut": { + "type": "string", + "minLength": 1, + "maxLength": 256 + } + }, + "required": [], + "additionalProperties": false + } + }, + "required": [], + "additionalProperties": false + }, + "enum": { + "type": "array", + "items": { + "type": "boolean" + }, + "minItems": 1, + "maxItems": 64, + "uniqueItems": true + }, + "default": { + "type": "boolean" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "null" + }, + "display": { + "type": "object", + "properties": { + "label": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "help": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "order": { + "type": "integer", + "minimum": 0, + "maximum": 10000 + }, + "labelKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "helpKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "accessibility": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "description": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "nameKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "descriptionKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "shortcut": { + "type": "string", + "minLength": 1, + "maxLength": 256 + } + }, + "required": [], + "additionalProperties": false + } + }, + "required": [], + "additionalProperties": false + }, + "enum": { + "type": "array", + "items": { + "type": "null" + }, + "minItems": 1, + "maxItems": 64, + "uniqueItems": true + }, + "default": { + "type": "null" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "array" + }, + "display": { + "type": "object", + "properties": { + "label": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "help": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "order": { + "type": "integer", + "minimum": 0, + "maximum": 10000 + }, + "labelKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "helpKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "accessibility": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "description": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "nameKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "descriptionKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "shortcut": { + "type": "string", + "minLength": 1, + "maxLength": 256 + } + }, + "required": [], + "additionalProperties": false + } + }, + "required": [], + "additionalProperties": false + }, + "items": { + "$ref": "#/$defs/dataNode" + }, + "minItems": { + "type": "integer", + "minimum": 0, + "maximum": 256 + }, + "maxItems": { + "type": "integer", + "minimum": 0, + "maximum": 256 + }, + "default": { + "type": "array", + "items": {}, + "minItems": 0, + "maxItems": 256 + } + }, + "required": [ + "type", + "items" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "object" + }, + "display": { + "type": "object", + "properties": { + "label": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "help": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "order": { + "type": "integer", + "minimum": 0, + "maximum": 10000 + }, + "labelKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "helpKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "accessibility": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "description": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "nameKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "descriptionKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "shortcut": { + "type": "string", + "minLength": 1, + "maxLength": 256 + } + }, + "required": [], + "additionalProperties": false + } + }, + "required": [], + "additionalProperties": false + }, + "properties": { + "type": "object", + "maxProperties": 64, + "propertyNames": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "additionalProperties": { + "$ref": "#/$defs/dataNode" + } + }, + "additionalProperties": { + "const": false + }, + "required": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "minItems": 0, + "maxItems": 64, + "uniqueItems": true + }, + "default": { + "type": "object" + } + }, + "required": [ + "type", + "properties", + "additionalProperties" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "object" + }, + "format": { + "const": "dds-secret-reference" + }, + "display": { + "type": "object", + "properties": { + "label": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "help": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "order": { + "type": "integer", + "minimum": 0, + "maximum": 10000 + }, + "labelKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "helpKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "accessibility": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "description": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "nameKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "descriptionKey": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "shortcut": { + "type": "string", + "minLength": 1, + "maxLength": 256 + } + }, + "required": [], + "additionalProperties": false + } + }, + "required": [], + "additionalProperties": false + } + }, + "required": [ + "type", + "format" + ], + "additionalProperties": false + } + ] + } + } +} diff --git a/schemas/settings-snapshot.schema.json b/schemas/settings-snapshot.schema.json new file mode 100644 index 0000000..d5162da --- /dev/null +++ b/schemas/settings-snapshot.schema.json @@ -0,0 +1,72 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.12.0/schemas/settings-snapshot.schema.json", + "title": "settings-snapshot", + "type": "object", + "properties": { + "schemaVersion": { + "const": 1 + }, + "pluginId": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$" + }, + "workspaceId": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!__proto__$|prototype$|constructor$)[^\\u0000-\\u001f\\u007f]+$" + }, + "definitionVersion": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "revision": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "values": { + "type": "object", + "propertyNames": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "maxProperties": 64 + }, + "sources": { + "type": "object", + "maxProperties": 64, + "propertyNames": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "additionalProperties": { + "enum": [ + "default", + "user", + "workspace" + ] + } + } + }, + "required": [ + "schemaVersion", + "pluginId", + "workspaceId", + "definitionVersion", + "revision", + "values", + "sources" + ], + "additionalProperties": false, + "x-maxUtf8Bytes": 262144, + "$comment": "Runtime requires the same keys in values and sources. Host reads and notifications must match the active plugin and workspace. Default < user < workspace replaces an entire setting value. References are opaque identifiers, not secret values." +} diff --git a/schemas/settings-state.schema.json b/schemas/settings-state.schema.json new file mode 100644 index 0000000..11be777 --- /dev/null +++ b/schemas/settings-state.schema.json @@ -0,0 +1,68 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.12.0/schemas/settings-state.schema.json", + "title": "settings-state", + "type": "object", + "properties": { + "schemaVersion": { + "const": 1 + }, + "pluginId": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$" + }, + "definitionVersion": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "revision": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "user": { + "type": "object", + "propertyNames": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "maxProperties": 64 + }, + "workspaces": { + "type": "object", + "maxProperties": 32, + "propertyNames": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!__proto__$|prototype$|constructor$)[^\\u0000-\\u001f\\u007f]+$" + }, + "additionalProperties": { + "type": "object", + "propertyNames": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$" + }, + "maxProperties": 64 + } + } + }, + "required": [ + "schemaVersion", + "pluginId", + "definitionVersion", + "revision", + "user", + "workspaces" + ], + "additionalProperties": false, + "x-maxUtf8Bytes": 1048576, + "$comment": "An operator-owned memory store export. Import must match the selected plugin and definition version, validate each layer against the definition, reject read-only/scope overrides and bound each layer to 64 KiB. No secret resolver values are included." +} diff --git a/schemas/signature-help.schema.json b/schemas/signature-help.schema.json index 9c1a517..01f3a71 100644 --- a/schemas/signature-help.schema.json +++ b/schemas/signature-help.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.11.0/schemas/signature-help.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.12.0/schemas/signature-help.schema.json", "title": "signature-help", "oneOf": [ { diff --git a/schemas/stored-artifact-chunk.schema.json b/schemas/stored-artifact-chunk.schema.json index cc59353..beebcd8 100644 --- a/schemas/stored-artifact-chunk.schema.json +++ b/schemas/stored-artifact-chunk.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.11.0/schemas/stored-artifact-chunk.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.12.0/schemas/stored-artifact-chunk.schema.json", "title": "stored-artifact-chunk", "type": "object", "properties": { diff --git a/schemas/stored-artifact-list.schema.json b/schemas/stored-artifact-list.schema.json index 1559919..4d0c43f 100644 --- a/schemas/stored-artifact-list.schema.json +++ b/schemas/stored-artifact-list.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.11.0/schemas/stored-artifact-list.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.12.0/schemas/stored-artifact-list.schema.json", "title": "stored-artifact-list", "type": "object", "properties": { diff --git a/schemas/stored-artifact-reference.schema.json b/schemas/stored-artifact-reference.schema.json index a3bbcf4..8de486a 100644 --- a/schemas/stored-artifact-reference.schema.json +++ b/schemas/stored-artifact-reference.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.11.0/schemas/stored-artifact-reference.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.12.0/schemas/stored-artifact-reference.schema.json", "title": "stored-artifact-reference", "type": "object", "properties": { diff --git a/schemas/stored-artifact.schema.json b/schemas/stored-artifact.schema.json index 766449b..474d54e 100644 --- a/schemas/stored-artifact.schema.json +++ b/schemas/stored-artifact.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.11.0/schemas/stored-artifact.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.12.0/schemas/stored-artifact.schema.json", "title": "stored-artifact", "type": "object", "properties": { diff --git a/schemas/stored-job.schema.json b/schemas/stored-job.schema.json index 9dbdaf2..17b8004 100644 --- a/schemas/stored-job.schema.json +++ b/schemas/stored-job.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.11.0/schemas/stored-job.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.12.0/schemas/stored-job.schema.json", "title": "stored-job", "type": "object", "properties": { @@ -69,15 +69,17 @@ "workspace.read", "workspace.write", "backend.invoke", - "language.provide" + "language.provide", + "settings.read", + "secrets.resolve" ] }, "minItems": 0, - "maxItems": 6, + "maxItems": 8, "uniqueItems": true }, "snapshot": { - "$ref": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.11.0/schemas/job-snapshot.schema.json" + "$ref": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.12.0/schemas/job-snapshot.schema.json" }, "events": { "type": "array", diff --git a/schemas/transfer-queue-options.schema.json b/schemas/transfer-queue-options.schema.json index c5c8b7f..ef4aa75 100644 --- a/schemas/transfer-queue-options.schema.json +++ b/schemas/transfer-queue-options.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.11.0/schemas/transfer-queue-options.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.12.0/schemas/transfer-queue-options.schema.json", "title": "transfer-queue-options", "type": "object", "properties": { diff --git a/schemas/transfer-snapshot.schema.json b/schemas/transfer-snapshot.schema.json index bf26bb6..0467ce7 100644 --- a/schemas/transfer-snapshot.schema.json +++ b/schemas/transfer-snapshot.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.11.0/schemas/transfer-snapshot.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.12.0/schemas/transfer-snapshot.schema.json", "title": "transfer-snapshot", "type": "object", "properties": { diff --git a/schemas/upload-capabilities.schema.json b/schemas/upload-capabilities.schema.json index debde89..adef461 100644 --- a/schemas/upload-capabilities.schema.json +++ b/schemas/upload-capabilities.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.11.0/schemas/upload-capabilities.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.12.0/schemas/upload-capabilities.schema.json", "title": "upload-capabilities", "type": "object", "properties": { diff --git a/schemas/upload-chunk.schema.json b/schemas/upload-chunk.schema.json index 988ba94..a7415ca 100644 --- a/schemas/upload-chunk.schema.json +++ b/schemas/upload-chunk.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.11.0/schemas/upload-chunk.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.12.0/schemas/upload-chunk.schema.json", "title": "upload-chunk", "type": "object", "properties": { diff --git a/schemas/upload-reference.schema.json b/schemas/upload-reference.schema.json index 69d0875..81d3119 100644 --- a/schemas/upload-reference.schema.json +++ b/schemas/upload-reference.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.11.0/schemas/upload-reference.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.12.0/schemas/upload-reference.schema.json", "title": "upload-reference", "type": "object", "properties": { diff --git a/schemas/upload-spec.schema.json b/schemas/upload-spec.schema.json index f70a039..67f0f61 100644 --- a/schemas/upload-spec.schema.json +++ b/schemas/upload-spec.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.11.0/schemas/upload-spec.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.12.0/schemas/upload-spec.schema.json", "title": "upload-spec", "type": "object", "properties": { diff --git a/schemas/upload-status.schema.json b/schemas/upload-status.schema.json index b32e795..59ffdbe 100644 --- a/schemas/upload-status.schema.json +++ b/schemas/upload-status.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.11.0/schemas/upload-status.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.12.0/schemas/upload-status.schema.json", "title": "upload-status", "type": "object", "properties": { diff --git a/schemas/workspace-conditional-file.schema.json b/schemas/workspace-conditional-file.schema.json index 9034c8c..77d18f1 100644 --- a/schemas/workspace-conditional-file.schema.json +++ b/schemas/workspace-conditional-file.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.11.0/schemas/workspace-conditional-file.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.12.0/schemas/workspace-conditional-file.schema.json", "title": "workspace-conditional-file", "oneOf": [ { diff --git a/schemas/workspace-edit-preview.schema.json b/schemas/workspace-edit-preview.schema.json index fa1c084..50ffb83 100644 --- a/schemas/workspace-edit-preview.schema.json +++ b/schemas/workspace-edit-preview.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.11.0/schemas/workspace-edit-preview.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.12.0/schemas/workspace-edit-preview.schema.json", "title": "workspace-edit-preview", "type": "object", "properties": { diff --git a/schemas/workspace-edit-receipt.schema.json b/schemas/workspace-edit-receipt.schema.json index a16136c..1fd5818 100644 --- a/schemas/workspace-edit-receipt.schema.json +++ b/schemas/workspace-edit-receipt.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.11.0/schemas/workspace-edit-receipt.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.12.0/schemas/workspace-edit-receipt.schema.json", "title": "workspace-edit-receipt", "type": "object", "properties": { diff --git a/schemas/workspace-edit-record.schema.json b/schemas/workspace-edit-record.schema.json index fb7ee68..92bc050 100644 --- a/schemas/workspace-edit-record.schema.json +++ b/schemas/workspace-edit-record.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.11.0/schemas/workspace-edit-record.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.12.0/schemas/workspace-edit-record.schema.json", "title": "workspace-edit-record", "type": "object", "properties": { diff --git a/schemas/workspace-edit.schema.json b/schemas/workspace-edit.schema.json index 1c2e6ac..7c9ed1d 100644 --- a/schemas/workspace-edit.schema.json +++ b/schemas/workspace-edit.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.11.0/schemas/workspace-edit.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.12.0/schemas/workspace-edit.schema.json", "title": "workspace-edit", "type": "object", "properties": { diff --git a/schemas/workspace-file-capabilities.schema.json b/schemas/workspace-file-capabilities.schema.json index 92f2f02..d0b5cf8 100644 --- a/schemas/workspace-file-capabilities.schema.json +++ b/schemas/workspace-file-capabilities.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.11.0/schemas/workspace-file-capabilities.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.12.0/schemas/workspace-file-capabilities.schema.json", "title": "workspace-file-capabilities", "type": "object", "properties": { diff --git a/schemas/workspace-file-revision.schema.json b/schemas/workspace-file-revision.schema.json index 028f3b6..485ef46 100644 --- a/schemas/workspace-file-revision.schema.json +++ b/schemas/workspace-file-revision.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.11.0/schemas/workspace-file-revision.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.12.0/schemas/workspace-file-revision.schema.json", "title": "workspace-file-revision", "type": "object", "properties": { diff --git a/schemas/workspace-project-capabilities.schema.json b/schemas/workspace-project-capabilities.schema.json index 37583ae..a0a0d16 100644 --- a/schemas/workspace-project-capabilities.schema.json +++ b/schemas/workspace-project-capabilities.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.11.0/schemas/workspace-project-capabilities.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.12.0/schemas/workspace-project-capabilities.schema.json", "title": "workspace-project-capabilities", "type": "object", "properties": { diff --git a/schemas/workspace-project-poll.schema.json b/schemas/workspace-project-poll.schema.json index 2ab1625..994bc56 100644 --- a/schemas/workspace-project-poll.schema.json +++ b/schemas/workspace-project-poll.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.11.0/schemas/workspace-project-poll.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.12.0/schemas/workspace-project-poll.schema.json", "title": "workspace-project-poll", "type": "object", "properties": { diff --git a/scripts/pack-check.mjs b/scripts/pack-check.mjs index 471bf1d..43bc1e5 100644 --- a/scripts/pack-check.mjs +++ b/scripts/pack-check.mjs @@ -195,6 +195,10 @@ try { `); run(process.execPath, ['workspace-smoke.mjs'], consumer); await cp(join(root, 'tests', 'types', 'consumer.mts'), join(consumer, 'consumer.mts')); + await cp(join(root, 'tests', 'types', 'configuration.mts'), join(consumer, 'configuration.mts')); + await cp(join(root, 'tests', 'types', 'localization.mts'), join(consumer, 'localization.mts')); + await cp(join(installed, 'examples', 'configuration'), join(consumer, 'configuration-example'), {recursive: true}); + assert.match(run(process.execPath, ['configuration-example/run.mjs'], consumer), /scoped CAS settings, migration rollback, structured HTTP commands\/jobs, isolated secret references and revocation verified/); await cp(join(root, 'tests', 'types', 'uploads.mts'), join(consumer, 'uploads.mts')); await cp(join(root, 'tests', 'types', 'transfer-queue.mts'), join(consumer, 'transfer-queue.mts')); await cp(join(root, 'tests', 'types', 'public-apis.mts'), join(consumer, 'public-apis.mts')); diff --git a/scripts/schemas.mjs b/scripts/schemas.mjs index 29c3cc0..52ff638 100644 --- a/scripts/schemas.mjs +++ b/scripts/schemas.mjs @@ -11,4 +11,8 @@ for (const [name, schema] of Object.entries(SCHEMAS)) { if (mode === '--write') await writeFile(path, expected); else if (await readFile(path, 'utf8') !== expected) throw new Error(`Schema drift: ${name}; run npm run schemas:write`); } +const declarationPath = new URL('../src/schemas.d.mts', import.meta.url); +const declaration = `// Generated by npm run schemas:write.\nexport const SCHEMAS: Readonly `'${name}'`).join(' | ')}, Readonly>>>;\n`; +if (mode === '--write') await writeFile(declarationPath, declaration); +else if (await readFile(declarationPath, 'utf8') !== declaration) throw new Error('Schema declaration drift; run npm run schemas:write'); console.log(`Schemas ${mode === '--write' ? 'generated' : 'verified'}: ${Object.keys(SCHEMAS).length}`); diff --git a/src/accessibility.d.mts b/src/accessibility.d.mts new file mode 100644 index 0000000..0c23238 --- /dev/null +++ b/src/accessibility.d.mts @@ -0,0 +1,7 @@ +import type {DisplayMetadata} from './data-schema.mjs'; +import type {ThemeMode} from './themes.mjs'; +export function normalizeShortcut(input: string): string; +export function inspectAccessibility(input: readonly {readonly id: string; readonly display: DisplayMetadata}[]): Readonly<{issues: readonly {readonly id: string; readonly code: 'missing_name'}[]; conflicts: readonly {readonly shortcut: string; readonly ids: readonly string[]}[]}>; +export function contrastRatio(foreground: string, background: string): number; +export interface ThemeContrastCheck {readonly mode: ThemeMode; readonly foreground: string; readonly background: string; readonly ratio: number; readonly minimum: number; readonly passes: boolean;} +export function inspectThemeContrast(input: unknown, options?: {readonly minimum?: number}): Readonly<{checks: readonly ThemeContrastCheck[]; failures: number}>; diff --git a/src/accessibility.mjs b/src/accessibility.mjs new file mode 100644 index 0000000..284911a --- /dev/null +++ b/src/accessibility.mjs @@ -0,0 +1,57 @@ +import {configurationCopy as copy, configurationObject as object, configurationFailure as fail, configurationText as text, parseDisplayMetadata, freezeConfiguration as freeze} from './configuration-values.mjs'; +import {parseTheme} from './themes.mjs'; + +const modifiers = ['Control', 'Alt', 'Shift', 'Meta']; +const keys = ['Enter', 'Space', 'Tab', 'Escape', 'Backspace', 'Delete', 'Insert', 'Home', 'End', 'PageUp', 'PageDown', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', ...Array.from({length: 24}, (_, i) => 'F' + (i + 1))]; +/** Portable metadata only. Hosts own keyboard routing and platform conflicts. */ +export function normalizeShortcut(input) { + text(input, 256); + const parts = input.split('+').map(part => part.trim()); + if (!parts.length || parts.length > 5) fail('shortcut'); + const final = parts.pop(), main = /^[a-z0-9]$/i.test(final) ? final.toUpperCase() : keys.find(key => key.toLowerCase() === final.toLowerCase()); + const selected = parts.map(part => modifiers.find(modifier => modifier.toLowerCase() === part.toLowerCase())); + if (!main || selected.includes(undefined) || new Set(selected).size !== selected.length) fail('shortcut'); + return [...modifiers.filter(modifier => selected.includes(modifier)), main].join('+'); +} +export function inspectAccessibility(input) { + const surfaces = copy(input, 262_144); + if (!Array.isArray(surfaces) || surfaces.length > 256) fail('budget', '', true); + const identities = new Set(), shortcuts = new Map(), issues = []; + for (const surface of surfaces) { + object(surface, ['id', 'display']); text(surface.id, 256); const display = parseDisplayMetadata(surface.display); + if (identities.has(surface.id)) fail('duplicate_surface'); identities.add(surface.id); + if (!(display.accessibility?.name ?? display.label)?.trim()) issues.push({id: surface.id, code: 'missing_name'}); + if (display.accessibility?.shortcut) { + const shortcut = normalizeShortcut(display.accessibility.shortcut); + if (!shortcuts.has(shortcut)) shortcuts.set(shortcut, []); shortcuts.get(shortcut).push(surface.id); + } + } + const conflicts = [...shortcuts].filter(([, ids]) => ids.length > 1).map(([shortcut, ids]) => ({shortcut, ids})); + return freeze({issues, conflicts}); +} +function luminance(color) { + if (typeof color !== 'string' || !/^#[a-f0-9]{6}$/i.test(color)) fail('color'); + const values = [1, 3, 5].map(start => parseInt(color.slice(start, start + 2), 16) / 255).map(value => value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4); + return values[0] * 0.2126 + values[1] * 0.7152 + values[2] * 0.0722; +} +export function contrastRatio(foreground, background) { + const a = luminance(foreground), b = luminance(background); + return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05); +} +/** Checks these explicit opaque-color pairs only; this is not UI conformance. */ +export function inspectThemeContrast(input, options = {}) { + const theme = parseTheme(input); options = copy(options, 1024); object(options, [], ['minimum']); + const minimum = options.minimum ?? 4.5; + if (typeof minimum !== 'number' || !Number.isFinite(minimum) || minimum < 1 || minimum > 21) fail('contrast_threshold'); + const checks = []; + for (const mode of ['light', 'dark']) { + const palette = theme.colors[mode], tokens = theme.colors.tokens?.[mode] ?? {}; + const add = (foreground, background, colors, prefix = '') => { + const ratio = contrastRatio(colors[foreground], colors[background]); + checks.push({mode, foreground: prefix + foreground, background: prefix + background, ratio, minimum, passes: ratio >= minimum}); + }; + for (const [foreground, background] of [['text', 'main'], ['muted', 'main'], ['text', 'navigation'], ['text', 'tool'], ['text', 'backdrop']]) add(foreground, background, palette); + for (const [foreground, background] of [['searchText', 'search'], ['selectionText', 'selection'], ['hintsBoxText', 'hintsBoxBackground'], ['hintsBoxSelectedText', 'hintsBoxSelectedBackground']]) if (tokens[foreground] && tokens[background]) add(foreground, background, tokens, 'tokens.'); + } + return freeze({checks, failures: checks.filter(check => !check.passes).length}); +} diff --git a/src/configuration-operations.mjs b/src/configuration-operations.mjs new file mode 100644 index 0000000..cf73654 --- /dev/null +++ b/src/configuration-operations.mjs @@ -0,0 +1,41 @@ +import {ErrorCode, PluginSdkError} from './limits.mjs'; +import {configurationObject, configurationInteger} from './configuration-values.mjs'; + +export const configurationError = code => new PluginSdkError(code, 'Configuration operation failed'); +export function configurationOptions(options = {}) { + configurationObject(options, [], ['signal', 'timeoutMs']); + if (options.signal !== undefined && !(options.signal instanceof AbortSignal)) throw configurationError(ErrorCode.INVALID_CONTRACT); + configurationInteger(options.timeoutMs ?? 5000, 30_000, 1); + return {signal: options.signal, timeoutMs: options.timeoutMs ?? 5000}; +} + +/** Cancellation ends the caller's wait; ignored cancellation still occupies a slot. */ +export function createConfigurationOperations(maximum) { + const pending = new Map(); let closed = false; + const abort = (controller, code) => {if (!controller.signal.aborted) controller.abort(configurationError(code));}; + return Object.freeze({ + async run(operation, options = {}, {tag, expiresIn} = {}) { + if (closed) throw configurationError(ErrorCode.DISPOSED); + const {signal, timeoutMs} = configurationOptions(options); + if (signal?.aborted) throw configurationError(ErrorCode.CANCELLED); + if (expiresIn !== undefined && expiresIn <= 0) throw configurationError(ErrorCode.PERMISSION_DENIED); + if (pending.size >= maximum) throw configurationError(ErrorCode.BUDGET_EXCEEDED); + const controller = new AbortController(); pending.set(controller, tag); + const callerAbort = () => abort(controller, ErrorCode.CANCELLED); + signal?.addEventListener('abort', callerAbort, {once: true}); + let rejectAbort; + const aborted = new Promise((_, reject) => {rejectAbort = () => reject(controller.signal.reason); controller.signal.addEventListener('abort', rejectAbort, {once: true});}); + const expiryFirst = expiresIn !== undefined && expiresIn <= timeoutMs; + const timer = setTimeout(() => abort(controller, expiryFirst ? ErrorCode.PERMISSION_DENIED : ErrorCode.BUDGET_EXCEEDED), Math.min(timeoutMs, expiresIn ?? timeoutMs)); + const actual = Promise.resolve().then(() => { + if (controller.signal.aborted) throw controller.signal.reason; + return operation(controller.signal); + }).then(result => {if (controller.signal.aborted) throw controller.signal.reason; return result;}).finally(() => pending.delete(controller)); + try {return await Promise.race([actual, aborted]);} + finally {clearTimeout(timer); signal?.removeEventListener('abort', callerAbort); controller.signal.removeEventListener('abort', rejectAbort);} + }, + abort(tag, code = ErrorCode.PERMISSION_DENIED) {for (const [controller, owner] of pending) if (owner === tag) abort(controller, code);}, + get pending() {return pending.size;}, + dispose() {closed = true; for (const controller of pending.keys()) abort(controller, ErrorCode.DISPOSED);}, + }); +} diff --git a/src/configuration-values.mjs b/src/configuration-values.mjs new file mode 100644 index 0000000..2879ea8 --- /dev/null +++ b/src/configuration-values.mjs @@ -0,0 +1,73 @@ +import {copyWorkspaceJson, exactObject, WorkspaceError} from './workspace-values.mjs'; +import {ErrorCode, PluginSdkError} from './limits.mjs'; + +/** A path and a stable reason, never the rejected value or a provider's message. */ +export class DataValidationError extends PluginSdkError { + constructor(reason, path = '', budget = false) { + super(budget ? ErrorCode.BUDGET_EXCEEDED : ErrorCode.INVALID_CONTRACT, `Data validation failed (${reason}) at ${path || '/'}`); + this.name = 'DataValidationError'; this.reason = reason; this.path = path; + } +} +export function configurationFailure(reason, path = '', budget = false) {throw new DataValidationError(reason, path, budget);} +export function configurationCopy(value, maxBytes = 65_536) { + try {return copyWorkspaceJson(value, {maxBytes, maxDepth: 32, maxNodes: 10_000});} + catch (error) {configurationFailure(error instanceof WorkspaceError && error.code === 'budget_exceeded' ? 'budget' : 'invalid_json', '', error instanceof WorkspaceError && error.code === 'budget_exceeded');} +} +export function configurationObject(value, required, optional = [], path = '') { + try {exactObject(value, required, optional);} + catch {configurationFailure('unsupported_field', path);} + return value; +} +/** Capture data methods, including class prototypes, without invoking getters. */ +export function configurationMethod(port, name, required = false) { + let current = port; + for (let depth = 0; current && depth < 16; depth++, current = Object.getPrototypeOf(current)) { + const descriptor = Object.getOwnPropertyDescriptor(current, name); + if (descriptor) { + if (!('value' in descriptor) || typeof descriptor.value !== 'function') configurationFailure('port_method'); + return (...args) => descriptor.value.call(port, ...args); + } + } + if (required) configurationFailure('port_method'); +} +export function configurationText(value, maximum, path = '', minimum = 1) { + if (typeof value !== 'string' || !value.isWellFormed() || value.length < minimum || value.length > maximum || value.includes('\0')) configurationFailure('text', path); + return value; +} +export function configurationKey(value, path = '') { + if (typeof value !== 'string' || !/^[A-Za-z][A-Za-z0-9_.-]{0,127}$/.test(value) || ['constructor', 'prototype', '__proto__'].includes(value)) configurationFailure('identifier', path); + return value; +} +export function configurationPluginId(value) { + if (typeof value !== 'string' || value.length > 128 || !/^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$/.test(value)) configurationFailure('plugin_id'); + return value; +} +export function configurationInteger(value, maximum, minimum = 0, path = '') { + if (!Number.isSafeInteger(value) || value < minimum || value > maximum) configurationFailure('integer', path); + return value; +} +export function freezeConfiguration(value) { + if (value && typeof value === 'object') {for (const item of Object.values(value)) freezeConfiguration(item); Object.freeze(value);} + return value; +} +export function displayMetadata(value, path = '') { + configurationObject(value, [], ['label', 'help', 'order', 'labelKey', 'helpKey', 'accessibility'], path); + for (const key of ['label', 'help']) if (Object.hasOwn(value, key)) configurationText(value[key], key === 'help' ? 2048 : 256, `${path}/${key}`); + for (const key of ['labelKey', 'helpKey']) if (Object.hasOwn(value, key)) configurationKey(value[key], `${path}/${key}`); + if (Object.hasOwn(value, 'order')) configurationInteger(value.order, 10_000, 0, `${path}/order`); + if (Object.hasOwn(value, 'accessibility')) { + const a = value.accessibility; + configurationObject(a, [], ['name', 'description', 'nameKey', 'descriptionKey', 'shortcut'], `${path}/accessibility`); + for (const key of ['name', 'description', 'shortcut']) if (Object.hasOwn(a, key)) configurationText(a[key], key === 'description' ? 2048 : 256, `${path}/accessibility/${key}`); + for (const key of ['nameKey', 'descriptionKey']) if (Object.hasOwn(a, key)) configurationKey(a[key], `${path}/accessibility/${key}`); + } + return value; +} +export function parseDisplayMetadata(value) {return displayMetadata(configurationCopy(value, 8192));} + +export function parseSecretReference(value) { + value = configurationCopy(value, 256); + configurationObject(value, ['kind', 'id']); + if (value.kind !== 'dds-secret-reference' || typeof value.id !== 'string' || !/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(value.id)) configurationFailure('secret_reference'); + return value; +} diff --git a/src/consent.mjs b/src/consent.mjs index c3130ce..b4aae1d 100644 --- a/src/consent.mjs +++ b/src/consent.mjs @@ -9,7 +9,7 @@ const hashPattern = /^[a-f0-9]{64}$/; const idPattern = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/; const semver = new RegExp(SEMVER_PATTERN); const placeholderPattern = /\{[^{}\r\n]+\}|\$\{|\{\{|<[^>\r\n]+>|\b(?:TODO|TBD|PLACEHOLDER|REPLACE_ME)\b/i; -const permissionNames = ['document.read', 'diagnostics.publish', 'workspace.read', 'workspace.write', 'backend.invoke', 'language.provide']; +const permissionNames = ['document.read', 'diagnostics.publish', 'workspace.read', 'workspace.write', 'backend.invoke', 'language.provide', 'settings.read', 'secrets.resolve']; const decisions = { pluginPermissionGrant: ['granted', 'denied', 'revoked'], noticeAcknowledgement: ['acknowledged', 'declined'], diff --git a/src/contracts.mjs b/src/contracts.mjs index c4be0f4..54f8ce4 100644 --- a/src/contracts.mjs +++ b/src/contracts.mjs @@ -5,14 +5,15 @@ import {textIndex, languageText, languageCall} from './language-values.mjs'; import {parseWorkspaceEdit} from './workspace-edit-contracts.mjs'; import {parseFormattingOptions, parseCodeActionContext, parseCodeActions, validateFormattingEdit, validateCodeActions} from './language-editing.mjs'; import {parseSemanticTokens, parseFoldingRanges, parseInlayHints, parseDocumentSymbolTree, validateLanguageDisplay} from './language-display.mjs'; +import {parseDataSchema, validateDataValue, parseDisplayMetadata, DataValidationError} from './data-schema.mjs'; const encoder = new TextEncoder(); const identifierPattern = /^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$/; const semverPattern = new RegExp(SEMVER_PATTERN); const uriPattern = /^[A-Za-z][A-Za-z0-9+.-]*:[^\s\u0000-\u001f\u007f]+$/; const permissions = ['document.read', 'diagnostics.publish']; -const workspacePermissions = ['workspace.read', 'workspace.write', 'backend.invoke']; -const allPermissions = [...permissions, ...workspacePermissions, 'language.provide']; +const workspacePermissions = ['workspace.read', 'workspace.write', 'backend.invoke', 'secrets.resolve']; +const allPermissions = [...permissions, ...workspacePermissions, 'language.provide', 'settings.read']; export const LANGUAGE_FEATURES = Object.freeze(['completion', 'hover', 'definition', 'references', 'document-symbols', 'signature-help', 'prepare-rename', 'rename', 'format-document', 'format-range', 'code-actions', 'semantic-tokens', 'folding-ranges', 'inlay-hints', 'document-symbol-tree']); export const LANGUAGE_CAPABILITIES = Object.freeze([...LANGUAGE_FEATURES, 'completion-resolve', 'completion-snippets', 'code-action-resolve', 'semantic-tokens-delta']); @@ -157,7 +158,7 @@ export function parseManifest(value) { const descriptor = Object.getOwnPropertyDescriptor(value, 'manifestVersion'); const manifestVersion = descriptor && 'value' in descriptor ? descriptor.value : undefined; if (![1, 2].includes(manifestVersion)) fail('manifest.manifestVersion', 'expected 1 or 2'); - record(value, ['manifestVersion', 'id', 'name', 'publisher', 'version', 'protocolVersion', 'entry', 'capabilities', 'permissions', 'supportedHosts', 'license', ...(manifestVersion === 2 ? ['runtime', 'source'] : [])], [], 'manifest'); + record(value, ['manifestVersion', 'id', 'name', 'publisher', 'version', 'protocolVersion', 'entry', 'capabilities', 'permissions', 'supportedHosts', 'license', ...(manifestVersion === 2 ? ['runtime', 'source'] : [])], manifestVersion === 2 ? ['display'] : [], 'manifest'); const entry = string(value.entry, 256, 'manifest.entry'); if (!/^\.\/(?:[A-Za-z0-9_-]+\/)*[A-Za-z0-9_.-]+\.mjs$/.test(entry)) fail('manifest.entry', 'expected a relative .mjs module path'); const output = { @@ -168,12 +169,13 @@ export function parseManifest(value) { version: string(value.version, 64, 'manifest.version', semverPattern), protocolVersion: version(value.protocolVersion, 'manifest.protocolVersion'), entry, - capabilities: Object.freeze(unique(array(value.capabilities, manifestVersion === 1 ? 1 : LANGUAGE_CAPABILITIES.length + 2, 'manifest.capabilities', (v, p) => enumeration(v, manifestVersion === 1 ? ['diagnostics'] : ['diagnostics', 'commands', ...LANGUAGE_CAPABILITIES], p), 1), 'manifest.capabilities')), - permissions: Object.freeze(unique(array(value.permissions, manifestVersion === 1 ? 2 : 6, 'manifest.permissions', (v, p) => enumeration(v, manifestVersion === 1 ? permissions : allPermissions, p)), 'manifest.permissions')), + capabilities: Object.freeze(unique(array(value.capabilities, manifestVersion === 1 ? 1 : LANGUAGE_CAPABILITIES.length + 3, 'manifest.capabilities', (v, p) => enumeration(v, manifestVersion === 1 ? ['diagnostics'] : ['diagnostics', 'commands', 'settings', ...LANGUAGE_CAPABILITIES], p), 1), 'manifest.capabilities')), + permissions: Object.freeze(unique(array(value.permissions, manifestVersion === 1 ? 2 : allPermissions.length, 'manifest.permissions', (v, p) => enumeration(v, manifestVersion === 1 ? permissions : allPermissions, p)), 'manifest.permissions')), supportedHosts: Object.freeze(unique(array(value.supportedHosts, manifestVersion === 1 ? 1 : 2, 'manifest.supportedHosts', (v, p) => enumeration(v, manifestVersion === 1 ? ['test-host'] : ['test-host', 'workspace-host'], p), 1), 'manifest.supportedHosts')), license: manifestVersion === 1 ? string(value.license, 128, 'manifest.license', /^[A-Za-z0-9.+-]+$/) : parseLicenseExpression(value.license), }; if (manifestVersion === 2) { + if (Object.hasOwn(value, 'display')) output.display = parseDisplayMetadata(value.display); if (output.capabilities.some(capability => ['completion-resolve', 'completion-snippets'].includes(capability)) && !output.capabilities.includes('completion')) fail('manifest.capabilities', 'completion extension requires completion'); if (output.capabilities.includes('code-action-resolve') && !output.capabilities.includes('code-actions')) fail('manifest.capabilities', 'code action resolve requires code-actions'); if (output.capabilities.includes('semantic-tokens-delta') && !output.capabilities.includes('semantic-tokens')) fail('manifest.capabilities', 'semantic delta requires semantic-tokens'); @@ -242,7 +244,7 @@ export function parseProviderSelector(value) { } export function parseGrants(value) { - return Object.freeze(unique(array(value, 6, 'grants', (v, p) => enumeration(v, allPermissions, p)), 'grants')); + return Object.freeze(unique(array(value, allPermissions.length, 'grants', (v, p) => enumeration(v, allPermissions, p)), 'grants')); } export function parseScope(value) { return scope(value, 'scope'); } @@ -325,9 +327,12 @@ export function parseJsonValue(value) { } export function parseCommandDefinition(value) { - record(value, ['id', 'title'], ['description', 'parameters'], 'command'); + record(value, ['id', 'title'], ['description', 'parameters', 'inputSchema', 'outputSchema', 'display'], 'command'); const output = {id: string(value.id, 128, 'command.id', identifierPattern), title: string(value.title, 128, 'command.title')}; if (Object.hasOwn(value, 'description')) output.description = string(value.description, 2048, 'command.description'); + if (Object.hasOwn(value, 'parameters') && Object.hasOwn(value, 'inputSchema')) fail('command', 'parameters and inputSchema are mutually exclusive'); + for (const field of ['inputSchema', 'outputSchema']) if (Object.hasOwn(value, field)) output[field] = parseDataSchema(value[field]); + if (Object.hasOwn(value, 'display')) output.display = parseDisplayMetadata(value.display); if (Object.hasOwn(value, 'parameters')) output.parameters = Object.freeze(array(value.parameters, 32, 'command.parameters', (parameter, path) => { record(parameter, ['name', 'label', 'type', 'required'], ['choices'], path); if (typeof parameter.required !== 'boolean') fail(`${path}.required`, 'expected boolean'); @@ -346,6 +351,8 @@ export function parseCommandDefinition(value) { } export function parseCommandInput(value, command) { + command = parseCommandDefinition(command); + if (command.inputSchema) return commandData(command.inputSchema, value, 'input', true); const input = parseJsonValue(value); if (!command.parameters) return input; if (!input || typeof input !== 'object' || Array.isArray(input)) fail('command.input', 'expected parameter object'); @@ -359,6 +366,20 @@ export function parseCommandInput(value, command) { return input; } +function commandData(schema, value, phase, applyDefaults) { + try {return parseJsonValue(validateDataValue(schema, value, {applyDefaults}));} + catch (failure) { + if (failure instanceof DataValidationError) throw new DataValidationError(failure.reason, `/${phase}${failure.path}`, failure.code === ErrorCode.BUDGET_EXCEEDED); + if (failure instanceof PluginSdkError) throw new DataValidationError('json_budget', `/${phase}`, failure.code === ErrorCode.BUDGET_EXCEEDED); + throw failure; + } +} +/** Outputs never receive schema defaults. */ +export function parseCommandOutput(value, command) { + command = parseCommandDefinition(command); + return command.outputSchema ? commandData(command.outputSchema, value, 'output', false) : parseJsonValue(value); +} + // Internal port validators shared with the portable host. export function parseWorkspaceRead(value, path) { record(value, ['path', 'content', 'revision'], [], 'workspace.readFile'); diff --git a/src/data-schema-schemas.mjs b/src/data-schema-schemas.mjs new file mode 100644 index 0000000..54fd860 --- /dev/null +++ b/src/data-schema-schemas.mjs @@ -0,0 +1,44 @@ +import {DATA_SCHEMA_LIMITS} from './data-schema.mjs'; +import {SETTINGS_LIMITS} from './settings.mjs'; + +const object = (properties, required = Object.keys(properties)) => ({type: 'object', properties, required, additionalProperties: false}); +const text = (maxLength, minLength = 0) => ({type: 'string', minLength, maxLength}); +const integer = (maximum, minimum = 0) => ({type: 'integer', minimum, maximum}); +const array = (items, maxItems, minItems = 0) => ({type: 'array', items, minItems, maxItems}); +const key = {...text(128, 1), pattern: '^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$'}; +const pluginId = {...text(128, 1), pattern: '^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$'}; +const uuid = {...text(36, 36), pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$'}; +export const SECRET_REFERENCE_SCHEMA = object({kind: {const: 'dds-secret-reference'}, id: uuid}); +export const DISPLAY_METADATA_SCHEMA = object({label: text(256, 1), help: text(2048, 1), order: integer(10_000), labelKey: key, helpKey: key, + accessibility: object({name: text(256, 1), description: text(2048, 1), nameKey: key, descriptionKey: key, shortcut: text(256, 1)}, []), +}, []); +const common = {display: DISPLAY_METADATA_SCHEMA}; +const primitive = (type, additions, value) => object({type: {const: type}, ...common, ...additions, enum: {...array(value, DATA_SCHEMA_LIMITS.enumItems, 1), uniqueItems: true}, default: value}, ['type']); +const dataNode = {oneOf: [ + primitive('string', {minLength: integer(DATA_SCHEMA_LIMITS.stringCharacters), maxLength: integer(DATA_SCHEMA_LIMITS.stringCharacters)}, text(DATA_SCHEMA_LIMITS.stringCharacters)), + ...['number', 'integer'].map(type => primitive(type, {minimum: {type}, maximum: {type}}, {type})), + primitive('boolean', {}, {type: 'boolean'}), primitive('null', {}, {type: 'null'}), + object({type: {const: 'array'}, ...common, items: {$ref: '#/$defs/dataNode'}, minItems: integer(DATA_SCHEMA_LIMITS.arrayItems), maxItems: integer(DATA_SCHEMA_LIMITS.arrayItems), default: array({}, DATA_SCHEMA_LIMITS.arrayItems)}, ['type', 'items']), + object({type: {const: 'object'}, ...common, properties: {type: 'object', maxProperties: DATA_SCHEMA_LIMITS.properties, propertyNames: key, additionalProperties: {$ref: '#/$defs/dataNode'}}, additionalProperties: {const: false}, required: {...array(key, DATA_SCHEMA_LIMITS.properties), uniqueItems: true}, default: {type: 'object'}}, ['type', 'properties', 'additionalProperties']), + object({type: {const: 'object'}, format: {const: 'dds-secret-reference'}, ...common}, ['type', 'format']), +]}; +export const DATA_SCHEMA_CONTRACT = object({schemaVersion: {const: 1}, schema: {$ref: '#/$defs/dataNode'}}); +export const DATA_SCHEMA_DEFINITIONS = Object.freeze({dataNode}); +const withDataDefinitions = body => ({...body, $defs: DATA_SCHEMA_DEFINITIONS}); +const values = {type: 'object', propertyNames: key, maxProperties: SETTINGS_LIMITS.keys}; +const workspaceId = {...text(128, 1), pattern: '^(?!__proto__$|prototype$|constructor$)[^\\u0000-\\u001f\\u007f]+$'}; +const layers = {user: values, workspaces: {type: 'object', maxProperties: SETTINGS_LIMITS.workspaces, propertyNames: workspaceId, additionalProperties: values}}; +const setting = object({schema: DATA_SCHEMA_CONTRACT, scopes: {...array({enum: ['user', 'workspace']}, 2), uniqueItems: true}, readOnly: {type: 'boolean'}, display: DISPLAY_METADATA_SCHEMA}, ['schema', 'scopes']); +export const CONFIGURATION_SCHEMAS = Object.freeze({ + 'data-schema': withDataDefinitions({...DATA_SCHEMA_CONTRACT, 'x-maxUtf8Bytes': DATA_SCHEMA_LIMITS.schemaBytes, + $comment: 'Closed version-1 subset. Runtime enforces depth 8, 128 schema nodes, finite numbers/safe integers, matching required keys, range ordering, typed enums and valid defaults. No input $ref, pattern, union or external resolution. Reference defaults are forbidden, including nested references. Strings use Unicode code-point length. Values/default expansion are bounded to 64 KiB of plain JSON.'}), + 'display-metadata': {...DISPLAY_METADATA_SCHEMA, $comment: 'All fields are literal display data. Runtime additionally checks well-formed UTF-16 and rejects NUL. Locale key cross-checks are separate.'}, + 'secret-reference': SECRET_REFERENCE_SCHEMA, + 'secret-scope': object({pluginId, workspaceId: text(128, 1), commandId: pluginId, executionId: uuid}), + 'settings-definition': withDataDefinitions({...object({schemaVersion: {const: 1}, pluginId, version: integer(Number.MAX_SAFE_INTEGER, 1), settings: {type: 'object', maxProperties: SETTINGS_LIMITS.keys, propertyNames: key, additionalProperties: setting}}), 'x-maxUtf8Bytes': SETTINGS_LIMITS.definitionBytes, + $comment: 'Runtime validates each data schema. Read-only settings require a default and no writable scopes; other settings require at least one scope. Unknown/deleted keys never migrate implicitly.'}), + 'settings-state': {...object({schemaVersion: {const: 1}, pluginId, definitionVersion: integer(Number.MAX_SAFE_INTEGER, 1), revision: integer(Number.MAX_SAFE_INTEGER), ...layers}), 'x-maxUtf8Bytes': SETTINGS_LIMITS.stateBytes, + $comment: 'An operator-owned memory store export. Import must match the selected plugin and definition version, validate each layer against the definition, reject read-only/scope overrides and bound each layer to 64 KiB. No secret resolver values are included.'}, + 'settings-snapshot': {...object({schemaVersion: {const: 1}, pluginId, workspaceId, definitionVersion: integer(Number.MAX_SAFE_INTEGER, 1), revision: integer(Number.MAX_SAFE_INTEGER), values, sources: {type: 'object', maxProperties: SETTINGS_LIMITS.keys, propertyNames: key, additionalProperties: {enum: ['default', 'user', 'workspace']}}}), 'x-maxUtf8Bytes': SETTINGS_LIMITS.snapshotBytes, + $comment: 'Runtime requires the same keys in values and sources. Host reads and notifications must match the active plugin and workspace. Default < user < workspace replaces an entire setting value. References are opaque identifiers, not secret values.'}, +}); diff --git a/src/data-schema.d.mts b/src/data-schema.d.mts new file mode 100644 index 0000000..b55b060 --- /dev/null +++ b/src/data-schema.d.mts @@ -0,0 +1,25 @@ +import type {JsonValue} from './index.mjs'; +import {PluginSdkError} from './index.mjs'; +export type SecretReference = {readonly kind: 'dds-secret-reference'; readonly id: string}; +export interface AccessibilityMetadata {readonly name?: string; readonly description?: string; readonly nameKey?: string; readonly descriptionKey?: string; readonly shortcut?: string;} +export interface DisplayMetadata {readonly label?: string; readonly help?: string; readonly order?: number; readonly labelKey?: string; readonly helpKey?: string; readonly accessibility?: AccessibilityMetadata;} +interface DataNodeBase {readonly display?: DisplayMetadata;} +export type DataSchemaNode = DataNodeBase & ( + {readonly type: 'string'; readonly minLength?: number; readonly maxLength?: number; readonly enum?: readonly string[]; readonly default?: string} | + {readonly type: 'number' | 'integer'; readonly minimum?: number; readonly maximum?: number; readonly enum?: readonly number[]; readonly default?: number} | + {readonly type: 'boolean'; readonly enum?: readonly boolean[]; readonly default?: boolean} | + {readonly type: 'null'; readonly enum?: readonly null[]; readonly default?: null} | + {readonly type: 'array'; readonly items: DataSchemaNode; readonly minItems?: number; readonly maxItems?: number; readonly default?: readonly JsonValue[]} | + {readonly type: 'object'; readonly properties: Readonly>; readonly required?: readonly string[]; readonly additionalProperties: false; readonly default?: Readonly>} | + {readonly type: 'object'; readonly format: 'dds-secret-reference'} +); +export interface DataSchema {readonly schemaVersion: 1; readonly schema: DataSchemaNode;} +export const DATA_SCHEMA_LIMITS: Readonly<{schemaBytes: number; valueBytes: number; depth: number; nodes: number; properties: number; arrayItems: number; stringCharacters: number; enumItems: number}>; +export class DataValidationError extends PluginSdkError {readonly reason: string; readonly path: string; constructor(reason: string, path?: string, budget?: boolean);} +export function parseDataSchema(value: unknown): DataSchema; +export function parseDisplayMetadata(value: unknown): DisplayMetadata; +export function parseSecretReference(value: unknown): SecretReference; +export function validateDataValue(schema: DataSchema, value: unknown, options?: {readonly applyDefaults?: boolean}): JsonValue; +export function collectSecretReferences(schema: DataSchema, value: unknown): readonly {readonly path: string; readonly reference: SecretReference}[]; +export interface DataFormField {readonly path: string; readonly type: DataSchemaNode['type']; readonly required: boolean; readonly secretReference: boolean; readonly display?: DisplayMetadata; readonly choices?: readonly (null | boolean | number | string)[]; readonly default?: JsonValue;} +export function describeDataForm(schema: DataSchema): readonly DataFormField[]; diff --git a/src/data-schema.mjs b/src/data-schema.mjs new file mode 100644 index 0000000..bfe7ac3 --- /dev/null +++ b/src/data-schema.mjs @@ -0,0 +1,129 @@ +import {DataValidationError, configurationCopy, configurationFailure as fail, configurationObject as object, configurationInteger as integer, configurationKey as key, displayMetadata, freezeConfiguration, parseSecretReference} from './configuration-values.mjs'; + +export {DataValidationError, parseSecretReference, parseDisplayMetadata} from './configuration-values.mjs'; +export const DATA_SCHEMA_LIMITS = Object.freeze({schemaBytes: 32_768, valueBytes: 65_536, depth: 8, nodes: 128, properties: 64, arrayItems: 256, stringCharacters: 16_384, enumItems: 64}); +const parsed = new WeakSet(); +const own = (value, name) => Object.hasOwn(value, name); +const primitive = value => value === null || ['boolean', 'string', 'number'].includes(typeof value); +const same = (a, b) => a === b; +const bounded = (value, maximum, path) => integer(value, maximum, 0, path); + +function validate(node, value, path, applyDefaults) { + if (value === undefined && applyDefaults && own(node, 'default')) value = node.default; + const type = node.type; + if (type === 'object' && node.format === 'dds-secret-reference') { + try {return parseSecretReference(value);} catch {fail('secret_reference', path);} + } + if (type === 'object') { + if (!value || typeof value !== 'object' || Array.isArray(value)) fail('type', path); + if (Object.keys(value).some(name => !own(node.properties, name))) fail('unknown_field', path); + const result = {}; + for (const [name, child] of Object.entries(node.properties)) { + if (own(value, name) || applyDefaults && own(child, 'default')) result[name] = validate(child, value[name], `${path}/${name}`, applyDefaults); + else if (node.required?.includes(name)) fail('required', `${path}/${name}`); + } + return Object.freeze(result); + } + if (type === 'array') { + if (!Array.isArray(value)) fail('type', path); + if (value.length < (node.minItems ?? 0) || value.length > (node.maxItems ?? DATA_SCHEMA_LIMITS.arrayItems)) fail('range', path); + return Object.freeze(value.map((item, index) => validate(node.items, item, `${path}/${index}`, applyDefaults))); + } + if (type === 'null' ? value !== null : type === 'integer' ? !Number.isSafeInteger(value) : typeof value !== type) fail('type', path); + if (type === 'string') { + const count = [...value].length; + if (count < (node.minLength ?? 0) || count > (node.maxLength ?? DATA_SCHEMA_LIMITS.stringCharacters)) fail('range', path); + } + if (type === 'number' || type === 'integer') { + if (!Number.isFinite(value) || own(node, 'minimum') && value < node.minimum || own(node, 'maximum') && value > node.maximum) fail('range', path); + } + if (node.enum && !node.enum.some(item => same(item, value))) fail('enum', path); + return value; +} + +/** A closed, bounded JSON Schema subset. No remote references or executable expressions. */ +export function parseDataSchema(input) { + if (parsed.has(input)) return input; + const value = configurationCopy(input, DATA_SCHEMA_LIMITS.schemaBytes); + object(value, ['schemaVersion', 'schema']); + if (value.schemaVersion !== 1) fail('schema_version', '/schemaVersion'); + let nodes = 0; + function visit(node, path, depth) { + if (++nodes > DATA_SCHEMA_LIMITS.nodes || depth > DATA_SCHEMA_LIMITS.depth) fail('budget', path, true); + object(node, ['type'], ['properties', 'required', 'additionalProperties', 'items', 'minItems', 'maxItems', 'minLength', 'maxLength', 'minimum', 'maximum', 'enum', 'default', 'format', 'display'], path); + if (!['object', 'array', 'string', 'number', 'integer', 'boolean', 'null'].includes(node.type)) fail('type', `${path}/type`); + const secret = node.type === 'object' && node.format === 'dds-secret-reference'; + const allowed = ['type', 'display', ...(!secret ? ['default'] : []), ...({object: secret ? ['format'] : ['properties', 'required', 'additionalProperties'], array: ['items', 'minItems', 'maxItems'], string: ['minLength', 'maxLength', 'enum'], number: ['minimum', 'maximum', 'enum'], integer: ['minimum', 'maximum', 'enum'], boolean: ['enum'], null: ['enum']}[node.type])]; + if (Object.keys(node).some(name => !allowed.includes(name))) fail('unsupported_keyword', path); + if (own(node, 'display')) displayMetadata(node.display, `${path}/display`); + if (node.type === 'object' && !secret) { + if (node.additionalProperties !== false) fail('additional_properties', path); + if (!node.properties || typeof node.properties !== 'object' || Array.isArray(node.properties)) fail('properties', path); + const names = Object.keys(node.properties); + if (names.length > DATA_SCHEMA_LIMITS.properties) fail('budget', path, true); + for (const name of names) {key(name, `${path}/properties`); visit(node.properties[name], `${path}/properties/${name}`, depth + 1);} + if (own(node, 'required')) { + if (!Array.isArray(node.required) || node.required.length > names.length || new Set(node.required).size !== node.required.length || node.required.some(name => !names.includes(name))) fail('required', `${path}/required`); + } + } else if (node.type === 'array') { + if (!own(node, 'items')) fail('items', path); + visit(node.items, `${path}/items`, depth + 1); + for (const name of ['minItems', 'maxItems']) if (own(node, name)) bounded(node[name], DATA_SCHEMA_LIMITS.arrayItems, `${path}/${name}`); + if ((node.minItems ?? 0) > (node.maxItems ?? DATA_SCHEMA_LIMITS.arrayItems)) fail('range', path); + } else if (node.type === 'string') { + for (const name of ['minLength', 'maxLength']) if (own(node, name)) bounded(node[name], DATA_SCHEMA_LIMITS.stringCharacters, `${path}/${name}`); + if ((node.minLength ?? 0) > (node.maxLength ?? DATA_SCHEMA_LIMITS.stringCharacters)) fail('range', path); + } else if (node.type === 'number' || node.type === 'integer') { + for (const name of ['minimum', 'maximum']) if (own(node, name) && (typeof node[name] !== 'number' || !Number.isFinite(node[name]) || node.type === 'integer' && !Number.isSafeInteger(node[name]))) fail('range', `${path}/${name}`); + if (own(node, 'minimum') && own(node, 'maximum') && node.minimum > node.maximum) fail('range', path); + } + if (own(node, 'enum')) { + if (!Array.isArray(node.enum) || node.enum.length < 1 || node.enum.length > DATA_SCHEMA_LIMITS.enumItems || node.enum.some(item => !primitive(item)) || new Set(node.enum).size !== node.enum.length) fail('enum', path); + for (const item of node.enum) validate({...node, enum: undefined}, item, `${path}/enum`, false); + } + if (own(node, 'default')) { + try {validate(node, node.default, `${path}/default`, false);} catch (error) {if (error instanceof DataValidationError) fail('default', `${path}/default`); throw error;} + // Defaults must not smuggle an authority reference through an ordinary object. + if (collect(node, node.default).length) fail('secret_default', `${path}/default`); + } + } + visit(value.schema, '/schema', 0); + parsed.add(value); + return value; +} + +export function validateDataValue(schema, input, options = {}) { + object(options, [], ['applyDefaults']); + const {applyDefaults = false} = options; + schema = parseDataSchema(schema); + if (typeof applyDefaults !== 'boolean') fail('options'); + const value = input === undefined && applyDefaults && own(schema.schema, 'default') ? undefined : configurationCopy(input, DATA_SCHEMA_LIMITS.valueBytes); + // Recopy the expanded result to enforce total bytes/nodes after defaults too. + return configurationCopy(validate(schema.schema, value, '', applyDefaults), DATA_SCHEMA_LIMITS.valueBytes); +} + +function collect(node, value, path = '', output = []) { + if (value === undefined) return output; + if (node.format === 'dds-secret-reference') output.push(Object.freeze({path, reference: value})); + else if (node.type === 'object') for (const [name, child] of Object.entries(node.properties)) {if (own(value, name)) collect(child, value[name], `${path}/${name}`, output);} + else if (node.type === 'array') value.forEach((item, index) => collect(node.items, item, `${path}/${index}`, output)); + return output; +} +/** Only references in fields explicitly declared with the reference format. */ +export function collectSecretReferences(schema, input) { + schema = parseDataSchema(schema); + return Object.freeze(collect(schema.schema, validateDataValue(schema, input))); +} + +/** Literal metadata for host-owned forms; this function does not create UI. */ +export function describeDataForm(schema) { + schema = parseDataSchema(schema); + const fields = []; + const visit = (node, path, required) => { + fields.push(freezeConfiguration({path, type: node.type, required, secretReference: node.format === 'dds-secret-reference', ...(node.display ? {display: node.display} : {}), ...(node.enum ? {choices: node.enum} : {}), ...(own(node, 'default') ? {default: node.default} : {})})); + if (node.type === 'object' && !node.format) for (const [name, child] of Object.entries(node.properties)) visit(child, `${path}/${name}`, node.required?.includes(name) ?? false); + if (node.type === 'array') visit(node.items, `${path}/*`, false); + }; + visit(schema.schema, '', true); + return Object.freeze(fields); +} diff --git a/src/host.mjs b/src/host.mjs index bff8b1a..b93befa 100644 --- a/src/host.mjs +++ b/src/host.mjs @@ -6,11 +6,16 @@ import { LANGUAGE_FEATURES, parseLanguageRequest } from './contracts.mjs'; import {LANGUAGE_LIMITS} from './language-assistance.mjs'; import {LANGUAGE_DISPLAY_LIMITS} from './language-display.mjs'; import {SDK_VERSION} from './version.mjs'; +import {settingsPorts, createSettingsApi} from './settings-host.mjs'; import { ErrorCode, LIMITS, PluginSdkError } from './limits.mjs'; import {createJobRegistry} from './job-registry.mjs'; import {parseBinaryArtifactSource, parseBinaryChunk,parseBinaryArtifactRange,decodeBinaryArtifactData} from './artifacts.mjs'; import {parseStoredArtifact,parseStoredArtifactReference,parseStoredArtifactChunk} from './artifact-storage.mjs'; import {parseJobId, parseJobOptions} from './jobs.mjs'; +import {parseCommandOutput} from './contracts.mjs'; +import {collectSecretReferences} from './data-schema.mjs'; +import {createSecretExecution} from './secrets.mjs'; +import {configurationMethod} from './configuration-values.mjs'; const hostFailures = new WeakSet(); const error = (code, message) => { @@ -20,10 +25,15 @@ const error = (code, message) => { }; /** Executes trusted local plugins in this process. This developer host is not a sandbox. */ -export function createPluginHost({hostId = 'test-host', scope = {projectId: 'example-project', sessionId: 'example-session'}, grants = [], workspace, backends = {}, jobs = false, binaryArtifacts = false, jobStorage} = {}) { +export function createPluginHost({hostId = 'test-host', scope = {projectId: 'example-project', sessionId: 'example-session'}, grants = [], workspace, backends = {}, jobs = false, binaryArtifacts = false, jobStorage, settings = {}, secrets} = {}) { if (!['test-host', 'workspace-host'].includes(hostId)) throw error(ErrorCode.UNSUPPORTED_HOST, 'Unknown plugin host'); scope = parseScope(scope); grants = parseGrants(grants); + const settingPorts = settingsPorts(settings); + if (secrets !== undefined) { + if (!secrets || typeof secrets !== 'object') throw error(ErrorCode.INVALID_CONTRACT, 'Expected a secret resolver port'); + secrets = Object.freeze({withSecret: configurationMethod(secrets, 'withSecret', true)}); + } if (workspace !== undefined && (!workspace || typeof workspace !== 'object')) throw error(ErrorCode.INVALID_CONTRACT, 'Expected a workspace port'); if (!backends || typeof backends !== 'object' || Array.isArray(backends) || ![Object.prototype, null].includes(Object.getPrototypeOf(backends))) throw error(ErrorCode.INVALID_CONTRACT, 'Expected named backend handlers'); const backendMap = new Map(); @@ -105,7 +115,7 @@ export function createPluginHost({hostId = 'test-host', scope = {projectId: 'exa } } throw error(ErrorCode.PROVIDER_FAILED, 'Plugin operation failed'); - }); + }).finally(() => {pending.delete(controller); entry?.pending.delete(controller);}); const result = await Promise.race([operationPromise, aborted]); assertActive(state); if (controller.signal.aborted) throw controller.signal.reason; @@ -114,7 +124,6 @@ export function createPluginHost({hostId = 'test-host', scope = {projectId: 'exa clearTimeout(timer); signal?.removeEventListener('abort', abortCaller); state.controller.signal.removeEventListener('abort', abortPlugin); controller.signal.removeEventListener('abort', onAbort); - pending.delete(controller); entry?.pending.delete(controller); } } @@ -148,6 +157,10 @@ export function createPluginHost({hostId = 'test-host', scope = {projectId: 'exa scope, grants: effectiveGrants, signal: state.controller.signal, + settings: createSettingsApi({pluginId: manifest.id, workspaceId: scope.projectId, port: settingPorts.get(manifest.id), registrations: state.registrations, + permit() {permit(state, 'settings.read'); if (!manifest.capabilities.includes('settings')) throw error(ErrorCode.PERMISSION_DENIED, 'Settings capability was not declared');}, + run: (operation, options, validate) => run(state, operation, options, {validate, trusted: true}), + }), registerDiagnosticsProvider(selector, provider) { assertOpen(); if (!state.active) throw error(ErrorCode.DISPOSED, 'Plugin is deactivated'); @@ -275,6 +288,28 @@ export function createPluginHost({hostId = 'test-host', scope = {projectId: 'exa } } + function commandReferences(state, entry, value) { + const refs = entry.command.inputSchema ? collectSecretReferences(entry.command.inputSchema, value).map(item => item.reference) : []; + if (refs.length) { + permit(state, 'secrets.resolve'); + if (!secrets) throw error(ErrorCode.CAPABILITY_UNAVAILABLE, 'Secret resolver is unavailable'); + } + return refs; + } + async function executeHandler(state, entry, value, options, refs) { + let execution; + try { + if (refs.length) { + permit(state, 'secrets.resolve'); + execution = createSecretExecution(secrets, {pluginId: state.manifest.id, workspaceId: scope.projectId, commandId: entry.command.id, executionId: globalThis.crypto.randomUUID()}, refs, {signal: options.signal}); + } + const api = execution && Object.freeze({async withSecret(...args) { + try {return await execution.secrets.withSecret(...args);} + catch (failure) {throw error(failure.code, 'Secret access failed');} + }}); + return await entry.handler(value, Object.freeze({...options, ...(api ? {secrets: api} : {})})); + } finally {execution?.dispose();} + } function startCommandJob(pluginId, commandId, input, options, attemptOf) { assertOpen(); const state = plugins.get(pluginId); @@ -282,10 +317,12 @@ export function createPluginHost({hostId = 'test-host', scope = {projectId: 'exa const entry = state.commands.get(commandId); if (!entry) throw error(ErrorCode.CAPABILITY_UNAVAILABLE, 'Command is unavailable'); const value = parseCommandInput(input, entry.command); + const refs = commandReferences(state, entry, value); return jobRegistry.start({pluginId, commandId, input: value, options, attemptOf, grants: state.grants, signal: state.controller.signal, assertActive: () => {assertActive(state); if (state.commands.get(commandId) !== entry) throw error(ErrorCode.DISPOSED, 'Command is disposed');}, assertRead: () => permit(state, 'workspace.read'), - execute: (input, options) => entry.handler(input, Object.freeze(options)), + execute: (input, options) => executeHandler(state, entry, input, options, refs), + validateOutput: output => parseCommandOutput(output, entry.command), registerController: controller => {entry.pending.add(controller); return () => entry.pending.delete(controller);}, readFile: (path, signal, timeoutMs = LIMITS.defaultTimeoutMs) => { permit(state, 'workspace.read'); @@ -417,7 +454,8 @@ export function createPluginHost({hostId = 'test-host', scope = {projectId: 'exa const entry = state.commands.get(commandId); if (!entry) throw error(ErrorCode.CAPABILITY_UNAVAILABLE, 'Command is unavailable'); const value = parseCommandInput(input, entry.command); - return run(state, signal => entry.handler(value, Object.freeze({signal})), options, {entry}); + const refs = commandReferences(state, entry, value); + return run(state, signal => executeHandler(state, entry, value, {signal}, refs), options, {entry, validate: output => parseCommandOutput(output, entry.command)}); }, jobCapabilities() {assertOpen(); return jobRegistry.capabilities();}, binaryArtifactCapabilities() {assertOpen(); return jobRegistry.binaryCapabilities();}, diff --git a/src/index.d.mts b/src/index.d.mts index 57dc060..455c6a4 100644 --- a/src/index.d.mts +++ b/src/index.d.mts @@ -18,7 +18,7 @@ export class PluginSdkError extends Error { readonly code: PluginErrorCode; constructor(code: PluginErrorCode, message: string); } -export type Permission = 'document.read' | 'diagnostics.publish' | 'workspace.read' | 'workspace.write' | 'backend.invoke' | 'language.provide'; +export type Permission = 'document.read' | 'diagnostics.publish' | 'workspace.read' | 'workspace.write' | 'backend.invoke' | 'language.provide' | 'settings.read' | 'secrets.resolve'; export interface PluginManifestV1 { readonly manifestVersion: 1; readonly id: string; @@ -33,9 +33,10 @@ export interface PluginManifestV1 { readonly license: string; } export interface PluginManifestV2 extends Omit { + readonly display?: DisplayMetadata; readonly manifestVersion: 2; readonly runtime: 'ui' | 'workspace'; - readonly capabilities: readonly ('diagnostics' | 'commands' | LanguageCapability)[]; + readonly capabilities: readonly ('diagnostics' | 'commands' | 'settings' | LanguageCapability)[]; readonly permissions: readonly Permission[]; readonly supportedHosts: readonly HostId[]; readonly source: { @@ -54,6 +55,10 @@ export interface CommandParameter { export interface CommandDefinition { readonly id: string; readonly title: string; readonly description?: string; readonly parameters?: readonly CommandParameter[]; + /** Mutually exclusive with parameters. Inputs apply defaults; outputs do not. */ + readonly inputSchema?: DataSchema; + readonly outputSchema?: DataSchema; + readonly display?: DisplayMetadata; } export interface RegisteredCommand extends CommandDefinition {readonly pluginId: string;} import type {JobReporter, JobOptions, JobSnapshot, JobEvents, JobArtifactContent, JobCapabilities} from './jobs.mjs'; @@ -61,8 +66,11 @@ import type {BinaryArtifactSource, BinaryArtifactCapabilities, BinaryArtifactLis import type {JobStorageOptions, JobStorageCapabilities, JobRecovery} from './job-storage.mjs'; import type {JobHistoryQuery, JobHistoryPage} from './job-history.mjs'; import type {ArtifactStorageCapabilities,StoredArtifactList,StoredArtifactReference,StoredArtifactChunk} from './artifact-storage.mjs'; +import type {SettingsApi, SettingsReadPort} from './settings.mjs'; +import type {DataSchema, DisplayMetadata} from './data-schema.mjs'; +import type {SecretResolverPort, SecretExecutionApi} from './secrets.mjs'; export type {JobReporter, JobOptions, JobSnapshot, JobEvents, JobArtifactContent, JobCapabilities} from './jobs.mjs'; -export type CommandHandler = (input: JsonValue, options: {readonly signal: AbortSignal; readonly job?: JobReporter}) => JsonValue | Promise; +export type CommandHandler = (input: JsonValue, options: {readonly signal: AbortSignal; readonly job?: JobReporter; readonly secrets?: SecretExecutionApi}) => JsonValue | Promise; export interface WorkspaceFile {readonly path: string; readonly content: string; readonly revision: string;} export interface WorkspaceWriteResult {readonly path: string; readonly revision: string;} export interface WorkspaceEntry {readonly path: string; readonly kind: 'file' | 'directory'; readonly revision?: string; readonly name?: string; readonly size?: number;} @@ -121,6 +129,7 @@ export interface PluginContext { readonly scope: Scope; readonly grants: readonly Permission[]; readonly signal: AbortSignal; + readonly settings: SettingsApi; registerDiagnosticsProvider(selector: ProviderSelector, provider: DiagnosticsProvider): Disposable; registerLanguageProvider(kind: K, selector: ProviderSelector, provider: LanguageProvider): Disposable; registerCommand(command: CommandDefinition, handler: CommandHandler): Disposable; @@ -139,6 +148,8 @@ export interface DiagnosticsRegistry extends Disposable { } export function parseManifest(value: unknown): PluginManifest; export function parseCommandDefinition(value: unknown): CommandDefinition; +export function parseCommandInput(value: unknown, command: CommandDefinition): JsonValue; +export function parseCommandOutput(value: unknown, command: CommandDefinition): JsonValue; export function parseJsonValue(value: unknown): JsonValue; export function parseLicenseExpression(value: unknown): string; export function parseWorkspacePath(value: unknown, options?: {readonly allowRoot?: boolean}): string; @@ -150,6 +161,8 @@ export function definePlugin(manifest: PluginManifest, activate: Plugin['activat /** For trusted host implementers. Authorization and isolation belong to the host. */ export function createDiagnosticsRegistry(options?: {readonly isCurrent?: (request: DiagnosticsRequest) => boolean}): DiagnosticsRegistry; export interface PluginHostOptions { + readonly secrets?: SecretResolverPort; + readonly settings?: Readonly>; readonly jobStorage?: JobStorageOptions; readonly jobs?: boolean; readonly binaryArtifacts?: boolean; diff --git a/src/index.mjs b/src/index.mjs index d0a1ab5..9ad5aa7 100644 --- a/src/index.mjs +++ b/src/index.mjs @@ -3,7 +3,7 @@ import { ErrorCode, PluginSdkError } from './limits.mjs'; export { ErrorCode, LIMITS, PluginSdkError, PROTOCOL_VERSION } from './limits.mjs'; export { parseManifest, parseDocumentSnapshot, parseDiagnosticsRequest, parseDiagnosticsResult, createDiagnosticsResult } from './contracts.mjs'; -export { parseCommandDefinition, parseJsonValue, parseLicenseExpression, parseWorkspacePath } from './contracts.mjs'; +export { parseCommandDefinition, parseCommandInput, parseCommandOutput, parseJsonValue, parseLicenseExpression, parseWorkspacePath } from './contracts.mjs'; export { createDiagnosticsRegistry } from './lifecycle.mjs'; export { createLanguageRegistry } from './lifecycle.mjs'; export { LANGUAGE_FEATURES, LANGUAGE_CAPABILITIES, parseLanguageRequest, parseLanguageResult, createLanguageResult } from './contracts.mjs'; diff --git a/src/job-registry.mjs b/src/job-registry.mjs index 19d8795..b733fb5 100644 --- a/src/job-registry.mjs +++ b/src/job-registry.mjs @@ -83,7 +83,7 @@ export function createJobRegistry({scope, enabled = false, binaryArtifacts = fal if (!result.record || result.record.requestSha256 !== requestSha256 || result.record.attemptOf !== attemptOf || result.record.snapshot.commandId !== commandId) throw failure(ErrorCode.CONFLICT); return result; }, - start({pluginId, commandId, input, options, attemptOf, grants = [], signal, assertActive, assertRead, execute, readFile, captureBinaryFile, readBinaryChunk, invokeBackend, registerController}) { + start({pluginId, commandId, input, options, attemptOf, grants = [], signal, assertActive, assertRead, execute, validateOutput = parseJsonValue, readFile, captureBinaryFile, readBinaryChunk, invokeBackend, registerController}) { assertOpen(); assertActive(); options = parseJobOptions(options); input = parseJsonValue(input); collect(); if (attemptOf !== undefined) {parseJobId(attemptOf); if (!storage.enabled || attemptOf === options.jobId) throw failure(ErrorCode.CONFLICT);} const signature = canonical({pluginId, commandId, input, timeoutMs: options.timeoutMs, ...(attemptOf === undefined ? {} : {attemptOf})}); @@ -138,7 +138,7 @@ export function createJobRegistry({scope, enabled = false, binaryArtifacts = fal }); // Keep this slot until the actual provider settles, even after cancellation. Promise.resolve().then(async () => {if (storage.enabled) await storage.beforeExecute(r); live(r); return execute(input, {signal: controller.signal, job});}).then(async result => { - await drain(r); live(r); const parsed = parseJsonValue(result); + await drain(r); live(r); const parsed = validateOutput(result); // Include metadata overhead in the snapshot budget before committing success. const candidate = {...r, state: 'succeeded', result: parsed}; snapshot(candidate); r.result = parsed; terminal(r, 'succeeded'); diff --git a/src/localization-schemas.mjs b/src/localization-schemas.mjs new file mode 100644 index 0000000..5e350d8 --- /dev/null +++ b/src/localization-schemas.mjs @@ -0,0 +1,12 @@ +import {LOCALIZATION_LIMITS} from './localization.mjs'; +const object = (properties, required = Object.keys(properties)) => ({type: 'object', properties, required, additionalProperties: false}); +const locale = {type: 'string', minLength: 2, maxLength: 64, $comment: 'Runtime requires a canonical BCP 47 base tag supported by Intl.getCanonicalLocales; extensions/private-use are rejected.'}; +const key = {type: 'string', minLength: 1, maxLength: 128, pattern: '^(?!constructor$|prototype$|__proto__$)[A-Za-z][A-Za-z0-9_.-]{0,127}$'}; +export const LOCALIZATION_SCHEMAS = Object.freeze({ + 'locale-catalog': {...object({schemaVersion: {const: 1}, defaultLocale: locale, + messages: {type: 'object', minProperties: 1, maxProperties: LOCALIZATION_LIMITS.locales, propertyNames: locale, additionalProperties: {type: 'object', maxProperties: LOCALIZATION_LIMITS.keys, propertyNames: key, additionalProperties: {type: 'string', maxLength: LOCALIZATION_LIMITS.messageCharacters}}}, + fallbacks: {type: 'object', maxProperties: LOCALIZATION_LIMITS.locales, propertyNames: locale, additionalProperties: {type: 'array', maxItems: LOCALIZATION_LIMITS.locales, uniqueItems: true, items: locale}}, + }, ['schemaVersion', 'defaultLocale', 'messages']), 'x-maxUtf8Bytes': LOCALIZATION_LIMITS.catalogBytes, + $comment: 'Runtime requires a nonempty default catalog and matching placeholder sets for every translation. Other locales may omit keys; unknown keys, noncanonical locales, undefined fallback targets, cycles and fallback edges from the default locale are rejected. Display cross-checks validate locale keys independently. Values are literal text; no HTML, ICU expression or remote loading.'}, + 'localized-message': object({key, text: {type: 'string', maxLength: LOCALIZATION_LIMITS.formattedCharacters}, locale, requestedLocale: locale, fallback: {type: 'boolean'}, direction: {enum: ['ltr', 'rtl']}}), +}); diff --git a/src/localization.d.mts b/src/localization.d.mts new file mode 100644 index 0000000..2eb31f4 --- /dev/null +++ b/src/localization.d.mts @@ -0,0 +1,20 @@ +import type {CommandDefinition, PluginManifest} from './index.mjs'; +import type {DisplayMetadata} from './data-schema.mjs'; +import type {SettingsDefinition} from './settings.mjs'; +import type {DdsTheme} from './themes.mjs'; +export const LOCALIZATION_LIMITS: Readonly<{catalogBytes: number; locales: number; keys: number; messageCharacters: number; formattedCharacters: number; placeholders: number; surfaces: number}>; +export interface LocaleCatalog {readonly schemaVersion: 1; readonly defaultLocale: string; readonly messages: Readonly>>>; readonly fallbacks?: Readonly>;} +export type MessageArguments = Readonly>; +export interface LocalizedMessage {readonly key: string; readonly text: string; readonly locale: string; readonly requestedLocale: string; readonly fallback: boolean; readonly direction: 'ltr' | 'rtl';} +export function canonicalLocale(input: string): string; +export function messagePlaceholders(input: string): readonly string[]; +export function parseLocaleCatalog(input: unknown): LocaleCatalog; +export function negotiateLocale(catalog: LocaleCatalog, requested: string | readonly string[]): string; +export function localeDirection(locale: string): 'ltr' | 'rtl'; +export function resolveMessage(catalog: LocaleCatalog, locale: string, key: string, args?: MessageArguments): LocalizedMessage; +export function formatMessage(catalog: LocaleCatalog, locale: string, key: string, args?: MessageArguments): string; +export function formatLocaleNumber(locale: string, value: number, options?: {readonly style?: 'decimal' | 'percent'; readonly minimumFractionDigits?: number; readonly maximumFractionDigits?: number; readonly useGrouping?: boolean}): string; +export function formatLocaleDate(locale: string, epochMs: number, options?: {readonly dateStyle?: 'full' | 'long' | 'medium' | 'short'; readonly timeStyle?: 'full' | 'long' | 'medium' | 'short'; readonly timeZone?: string}): string; +export function selectLocalePlural(locale: string, value: number, options?: {readonly type?: 'cardinal' | 'ordinal'}): Intl.LDMLPluralRule; +export function localizeDisplay(catalog: LocaleCatalog, locale: string, display: DisplayMetadata): DisplayMetadata; +export function validateLocalizedSurfaces(catalog: LocaleCatalog, surfaces: {readonly manifest?: PluginManifest; readonly commands?: readonly CommandDefinition[]; readonly settings?: SettingsDefinition; readonly themes?: readonly {readonly theme: DdsTheme; readonly display: DisplayMetadata}[]}): Readonly<{keys: readonly string[]; missingTranslations: readonly {readonly locale: string; readonly key: string}[]; surfaces: number}>; diff --git a/src/localization.mjs b/src/localization.mjs new file mode 100644 index 0000000..a253958 --- /dev/null +++ b/src/localization.mjs @@ -0,0 +1,178 @@ +import {configurationCopy as copy, configurationObject as object, configurationFailure as fail, configurationText as text, configurationKey as key, freezeConfiguration as freeze, parseDisplayMetadata} from './configuration-values.mjs'; +import {parseManifest, parseCommandDefinition} from './contracts.mjs'; +import {parseSettingsDefinition} from './settings.mjs'; +import {describeDataForm} from './data-schema.mjs'; +import {parseTheme} from './themes.mjs'; + +export const LOCALIZATION_LIMITS = Object.freeze({catalogBytes: 262_144, locales: 32, keys: 256, messageCharacters: 4096, formattedCharacters: 16_384, placeholders: 32, surfaces: 2048}); +const catalogs = new WeakSet(), owns = (object, name) => Object.hasOwn(object, name); +const placeholderPattern = /\{([A-Za-z][A-Za-z0-9_.-]{0,127})\}/g; + +/** Canonical BCP 47 base tags; extension/private-use negotiation is not supported. */ +export function canonicalLocale(input) { + text(input, 64); + try { + const [locale] = Intl.getCanonicalLocales(input); + if (!locale || new Intl.Locale(locale).baseName !== locale) throw new Error(); + return locale; + } catch {fail('locale');} +} +export function messagePlaceholders(input) { + text(input, LOCALIZATION_LIMITS.messageCharacters, '', 0); + const names = [...new Set([...input.matchAll(placeholderPattern)].map(match => match[1]))].sort(); + if (/[{}]/.test(input.replace(placeholderPattern, '')) || names.some(name => {try {key(name); return false;} catch {return true;}})) fail('placeholder_syntax'); + if (names.length > LOCALIZATION_LIMITS.placeholders) fail('budget', '', true); + return Object.freeze(names); +} + +export function parseLocaleCatalog(input) { + if (catalogs.has(input)) return input; + const value = copy(input, LOCALIZATION_LIMITS.catalogBytes); + object(value, ['schemaVersion', 'defaultLocale', 'messages'], ['fallbacks']); + if (value.schemaVersion !== 1) fail('schema_version'); + if (canonicalLocale(value.defaultLocale) !== value.defaultLocale) fail('canonical_locale'); + if (!value.messages || typeof value.messages !== 'object' || Array.isArray(value.messages)) fail('messages'); + const locales = Object.keys(value.messages); + if (!locales.length || locales.length > LOCALIZATION_LIMITS.locales) fail('budget', '', true); + if (!locales.includes(value.defaultLocale)) fail('default_locale'); + const primary = value.messages[value.defaultLocale]; + if (!primary || typeof primary !== 'object' || Array.isArray(primary)) fail('messages'); + const keys = Object.keys(primary); + if (!keys.length || keys.length > LOCALIZATION_LIMITS.keys) fail('budget', '', true); + keys.forEach(name => key(name)); + const requiredPlaceholders = new Map(keys.map(name => [name, messagePlaceholders(primary[name]).join('\0')])); + for (const locale of locales) { + if (canonicalLocale(locale) !== locale) fail('canonical_locale'); + const messages = value.messages[locale]; + if (!messages || typeof messages !== 'object' || Array.isArray(messages)) fail('messages'); + for (const [name, message] of Object.entries(messages)) { + if (!owns(primary, name)) fail('unknown_message_key'); + if (messagePlaceholders(message).join('\0') !== requiredPlaceholders.get(name)) fail('placeholder_mismatch'); + } + } + const fallbacks = value.fallbacks ?? {}; + if (!fallbacks || typeof fallbacks !== 'object' || Array.isArray(fallbacks)) fail('fallbacks'); + for (const [locale, targets] of Object.entries(fallbacks)) { + if (!locales.includes(locale) || !Array.isArray(targets) || targets.length > LOCALIZATION_LIMITS.locales || new Set(targets).size !== targets.length || targets.some(target => !locales.includes(target))) fail('fallbacks'); + if (locale === value.defaultLocale && targets.length) fail('default_fallback'); + } + const visited = new Set(); + const visit = (locale, active = new Set()) => { + if (active.has(locale)) fail('fallback_cycle'); + if (visited.has(locale)) return; + const next = new Set(active); next.add(locale); + for (const target of fallbacks[locale] ?? []) visit(target, next); + visited.add(locale); + }; + locales.forEach(locale => visit(locale)); + catalogs.add(value); return value; +} + +export function negotiateLocale(catalog, requested) { + catalog = parseLocaleCatalog(catalog); + const values = copy(typeof requested === 'string' ? [requested] : requested, 2048); + if (!Array.isArray(values) || !values.length || values.length > 8) fail('locale_preferences'); + // Validate every requested entry even if the first one is supported. + const locales = values.map(canonicalLocale); + for (let locale of locales) { + while (locale) {if (owns(catalog.messages, locale)) return locale; const separator = locale.lastIndexOf('-'); if (separator < 0) break; locale = locale.slice(0, separator);} + } + return catalog.defaultLocale; +} +function chain(catalog, locale) { + const order = [], visited = new Set(); + function visit(current) {if (visited.has(current)) return; visited.add(current); order.push(current); for (const target of catalog.fallbacks?.[current] ?? []) visit(target);} + visit(locale); visit(catalog.defaultLocale); return order; +} +/** A layout hint based on the tag's maximized script, not a font/shaping check. */ +export function localeDirection(locale) { + const script = new Intl.Locale(canonicalLocale(locale)).maximize().script; + return ['Arab', 'Hebr', 'Thaa', 'Nkoo', 'Adlm', 'Rohg', 'Syrc', 'Mand', 'Samr'].includes(script) ? 'rtl' : 'ltr'; +} +export function resolveMessage(catalog, requested, name, argumentsInput = {}) { + catalog = parseLocaleCatalog(catalog); key(name); + const requestedLocale = canonicalLocale(requested), selected = negotiateLocale(catalog, requestedLocale); + if (!owns(catalog.messages[catalog.defaultLocale], name)) fail('missing_message_key'); + const locale = chain(catalog, selected).find(locale => owns(catalog.messages[locale], name)); + const message = catalog.messages[locale][name], names = messagePlaceholders(message), args = copy(argumentsInput, 65_536); + if (!args || typeof args !== 'object' || Array.isArray(args) || Object.keys(args).length !== names.length || names.some(name => !owns(args, name))) fail('message_arguments'); + for (const value of Object.values(args)) { + if (typeof value === 'string') text(value, LOCALIZATION_LIMITS.messageCharacters, '', 0); + else if (!(typeof value === 'number' && Number.isFinite(value)) && typeof value !== 'boolean') fail('message_argument_type'); + } + // One replacement pass. Values are literal text, never recursively interpolated. + const result = message.replace(placeholderPattern, (_match, name) => String(args[name])); + text(result, LOCALIZATION_LIMITS.formattedCharacters, '', 0); + return Object.freeze({key: name, text: result, locale, requestedLocale, fallback: locale !== requestedLocale, direction: localeDirection(locale)}); +} +export function formatMessage(catalog, locale, name, args) {return resolveMessage(catalog, locale, name, args).text;} + +function supportedIntl(constructor, locale) { + locale = canonicalLocale(locale); + if (!constructor.supportedLocalesOf([locale]).length) fail('intl_unavailable'); + return locale; +} +export function formatLocaleNumber(locale, value, options = {}) { + locale = supportedIntl(Intl.NumberFormat, locale); options = copy(options, 2048); object(options, [], ['style', 'minimumFractionDigits', 'maximumFractionDigits', 'useGrouping']); + if (typeof value !== 'number' || !Number.isFinite(value)) fail('number'); + if (options.style !== undefined && !['decimal', 'percent'].includes(options.style) || options.useGrouping !== undefined && typeof options.useGrouping !== 'boolean') fail('number_options'); + for (const name of ['minimumFractionDigits', 'maximumFractionDigits']) if (owns(options, name) && (!Number.isInteger(options[name]) || options[name] < 0 || options[name] > 10)) fail('number_options'); + try {return new Intl.NumberFormat(locale, options).format(value);} catch {fail('number_options');} +} +export function formatLocaleDate(locale, epochMs, options = {}) { + locale = supportedIntl(Intl.DateTimeFormat, locale); options = copy(options, 2048); object(options, [], ['dateStyle', 'timeStyle', 'timeZone']); + if (!Number.isSafeInteger(epochMs) || Math.abs(epochMs) > 8_640_000_000_000_000) fail('date'); + for (const name of ['dateStyle', 'timeStyle']) if (owns(options, name) && !['full', 'long', 'medium', 'short'].includes(options[name])) fail('date_options'); + if (options.timeZone !== undefined) text(options.timeZone, 128); + try {return new Intl.DateTimeFormat(locale, {dateStyle: 'medium', timeZone: 'UTC', ...options}).format(epochMs);} catch {fail('date_options');} +} +export function selectLocalePlural(locale, value, options = {}) { + locale = supportedIntl(Intl.PluralRules, locale); options = copy(options, 1024); object(options, [], ['type']); + if (typeof value !== 'number' || !Number.isFinite(value)) fail('number'); + if (options.type !== undefined && !['cardinal', 'ordinal'].includes(options.type)) fail('plural_options'); + return new Intl.PluralRules(locale, options).select(value); +} + +export function localizeDisplay(catalog, locale, display) { + catalog = parseLocaleCatalog(catalog); locale = canonicalLocale(locale); display = parseDisplayMetadata(display); + const result = {...display}; + for (const name of ['label', 'help']) if (display[name + 'Key']) result[name] = formatMessage(catalog, locale, display[name + 'Key']); + if (display.accessibility) { + result.accessibility = {...display.accessibility}; + for (const name of ['name', 'description']) if (display.accessibility[name + 'Key']) result.accessibility[name] = formatMessage(catalog, locale, display.accessibility[name + 'Key']); + } + return parseDisplayMetadata(result); +} + +/** Cross-check explicit module exports; no catalog file loading or automatic translation. */ +export function validateLocalizedSurfaces(catalog, surfaces) { + catalog = parseLocaleCatalog(catalog); surfaces = copy(surfaces, 1_048_576); object(surfaces, [], ['manifest', 'commands', 'settings', 'themes']); + const entries = [], names = new Set(); + const add = (id, display) => {if (display) {if (entries.length >= LOCALIZATION_LIMITS.surfaces) fail('budget', '', true); entries.push({id, display: parseDisplayMetadata(display)});}}; + if (surfaces.manifest) {const manifest = parseManifest(surfaces.manifest); add('manifest', manifest.display);} + if (surfaces.commands) { + if (!Array.isArray(surfaces.commands) || surfaces.commands.length > 64) fail('commands'); + surfaces.commands.forEach(input => { + const command = parseCommandDefinition(input); if (names.has(command.id)) fail('duplicate_command'); names.add(command.id); add('command/' + command.id, command.display); + for (const field of ['inputSchema', 'outputSchema']) if (command[field]) for (const item of describeDataForm(command[field])) add('command/' + command.id + '/' + field + item.path, item.display); + }); + } + if (surfaces.settings) { + const definition = parseSettingsDefinition(surfaces.settings); + for (const [name, setting] of Object.entries(definition.settings)) {add('settings/' + name, setting.display); for (const item of describeDataForm(setting.schema)) add('settings/' + name + item.path, item.display);} + } + if (surfaces.themes) { + if (!Array.isArray(surfaces.themes) || surfaces.themes.length > 16) fail('themes'); + surfaces.themes.forEach((entry, index) => {object(entry, ['theme', 'display']); parseTheme(entry.theme); add('theme/' + index, entry.display);}); + } + const used = new Set(); + for (const {display} of entries) { + const keys = [display.labelKey, display.helpKey, display.accessibility?.nameKey, display.accessibility?.descriptionKey].filter(Boolean); + for (const name of keys) {if (!owns(catalog.messages[catalog.defaultLocale], name)) fail('missing_display_key'); if (messagePlaceholders(catalog.messages[catalog.defaultLocale][name]).length) fail('display_placeholder'); used.add(name);} + // Every offered locale must produce bounded display data, including fallback. + for (const locale of Object.keys(catalog.messages)) localizeDisplay(catalog, locale, display); + } + const missingTranslations = []; + for (const locale of Object.keys(catalog.messages)) for (const name of used) if (!owns(catalog.messages[locale], name)) missingTranslations.push({locale, key: name}); + return freeze({keys: [...used].sort(), missingTranslations, surfaces: entries.length}); +} diff --git a/src/publishing.mjs b/src/publishing.mjs index 71b8431..9a7e3fd 100644 --- a/src/publishing.mjs +++ b/src/publishing.mjs @@ -163,7 +163,7 @@ function inspectContents(contents, archived = false) { if (archived) { // Only the generated inert npm metadata is accepted, including the tested // SDK line. Additional scripts or dependencies cannot be smuggled into it. - const accepted = [metadataBytes, ...['>=0.3.0 <0.4.0', '>=0.4.0 <0.5.0', '>=0.5.0 <0.6.0', '>=0.6.0 <0.7.0', '>=0.7.0 <0.8.0', '>=0.8.0 <0.9.0', '>=0.9.0 <0.10.0', '>=0.10.0 <0.11.0'].map(range => Buffer.from(`${JSON.stringify({...metadata, peerDependencies: {'@altifigence/dds-plugin-sdk': range}}, null, 2)}\n`))]; + const accepted = [metadataBytes, ...['>=0.3.0 <0.4.0', '>=0.4.0 <0.5.0', '>=0.5.0 <0.6.0', '>=0.6.0 <0.7.0', '>=0.7.0 <0.8.0', '>=0.8.0 <0.9.0', '>=0.9.0 <0.10.0', '>=0.10.0 <0.11.0', '>=0.11.0 <0.12.0'].map(range => Buffer.from(`${JSON.stringify({...metadata, peerDependencies: {'@altifigence/dds-plugin-sdk': range}}, null, 2)}\n`))]; if (!accepted.some(bytes => contents.get('package.json').equals(bytes))) fail('package.json must match the generated SDK metadata'); } else { contents.set('package.json', metadataBytes); diff --git a/src/schemas.d.mts b/src/schemas.d.mts index 46dd46a..d2e72a8 100644 --- a/src/schemas.d.mts +++ b/src/schemas.d.mts @@ -1 +1,2 @@ -export const SCHEMAS: Readonly>>>; +// Generated by npm run schemas:write. +export const SCHEMAS: Readonly>>>; diff --git a/src/schemas.mjs b/src/schemas.mjs index 259221d..5d817f6 100644 --- a/src/schemas.mjs +++ b/src/schemas.mjs @@ -19,6 +19,8 @@ import {SDK_VERSION} from './version.mjs'; import {WORKSPACE_EDIT_SCHEMA, WORKSPACE_EDIT_SCHEMAS} from './workspace-edit-schemas.mjs'; import {FORMATTING_OPTIONS_SCHEMA, CODE_ACTION_SCHEMA, CODE_ACTION_CONTEXT_SCHEMA, LANGUAGE_EDITING_SCHEMAS} from './language-editing-schemas.mjs'; import {LANGUAGE_DISPLAY_SCHEMAS, SEMANTIC_TOKENS_SCHEMA, FOLDING_SCHEMA, INLAY_SCHEMA} from './language-display-schemas.mjs'; +import {CONFIGURATION_SCHEMAS, DATA_SCHEMA_CONTRACT, DATA_SCHEMA_DEFINITIONS, DISPLAY_METADATA_SCHEMA} from './data-schema-schemas.mjs'; +import {LOCALIZATION_SCHEMAS} from './localization-schemas.mjs'; const schema = 'https://json-schema.org/draft/2020-12/schema'; const base = `https://github.com/Altifigence/dds-plugin-sdk/blob/v${SDK_VERSION}/schemas/`; @@ -53,8 +55,8 @@ const manifestV1 = object({ }); const manifestV2 = object({ manifestVersion: {const: 2}, ...commonManifest, runtime: {enum: ['ui', 'workspace']}, - capabilities: list({enum: ['diagnostics', 'commands', ...LANGUAGE_CAPABILITIES]}, LANGUAGE_CAPABILITIES.length + 2, 1, true), - permissions: list({enum: ['document.read', 'diagnostics.publish', 'workspace.read', 'workspace.write', 'backend.invoke', 'language.provide']}, 6, 0, true), + capabilities: list({enum: ['diagnostics', 'commands', 'settings', ...LANGUAGE_CAPABILITIES]}, LANGUAGE_CAPABILITIES.length + 3, 1, true), + permissions: list({enum: ['document.read', 'diagnostics.publish', 'workspace.read', 'workspace.write', 'backend.invoke', 'language.provide', 'settings.read', 'secrets.resolve']}, 8, 0, true), supportedHosts: list({enum: ['test-host', 'workspace-host']}, 2, 1, true), license: {...text(512), pattern: '^[A-Za-z0-9.+:()\\s-]+$', 'x-licenseExpression': true, $comment: 'Runtime validates bounded SPDX-style expression grammar; this is not legal permission.'}, source: object({ @@ -63,7 +65,8 @@ const manifestV2 = object({ repository: {...text(2048), pattern: '^https://[^\\s?#]+$', format: 'uri', $comment: 'Runtime also rejects credentials.'}, }, ['visibility', 'licenseFile']), }); -manifestV2.allOf = [{if: {properties: {runtime: {const: 'ui'}}}, then: {properties: {permissions: {items: {enum: ['document.read', 'diagnostics.publish', 'language.provide']}}}}}]; +manifestV2.properties.display = DISPLAY_METADATA_SCHEMA; +manifestV2.allOf = [{if: {properties: {runtime: {const: 'ui'}}}, then: {properties: {permissions: {items: {enum: ['document.read', 'diagnostics.publish', 'language.provide', 'settings.read']}}}}}]; manifestV2.allOf.push({if: {properties: {capabilities: {contains: {enum: ['completion-resolve', 'completion-snippets']}}}}, then: {properties: {capabilities: {contains: {const: 'completion'}}}}}); manifestV2.allOf.push({if: {properties: {capabilities: {contains: {const: 'code-action-resolve'}}}}, then: {properties: {capabilities: {contains: {const: 'code-actions'}}}}}); manifestV2.allOf.push({if: {properties: {capabilities: {contains: {const: 'semantic-tokens-delta'}}}}, then: {properties: {capabilities: {contains: {const: 'semantic-tokens'}}}}}); @@ -117,6 +120,8 @@ const historyDisposition = {enum:['live','completed','interrupted','expired']}; const historyCursor = {...text(40),pattern:'^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}:(?:0|[1-9][0-9]{0,2})$'}; const historyItem = object({jobId,commandId:text(128),state:{enum:JOB_STATES},disposition:historyDisposition,startedAt:integer(Number.MAX_SAFE_INTEGER),updatedAt:integer(Number.MAX_SAFE_INTEGER),expiresAt:integer(Number.MAX_SAFE_INTEGER,1),revision:integer(Number.MAX_SAFE_INTEGER,1),attemptOf:{oneOf:[jobId,{type:'null'}]},contentPolicy:{enum:['metadata-only','host-redacted']},artifactCount:integer(JOB_LIMITS.artifacts),snapshotCount:integer(JOB_LIMITS.artifacts),resultAvailability:{enum:['none','source-references','snapshot-references','mixed-references','expired']}}); export const SCHEMAS = Object.freeze({ + ...Object.fromEntries(Object.entries(CONFIGURATION_SCHEMAS).map(([name, body]) => [name, define(name, body)])), + ...Object.fromEntries(Object.entries(LOCALIZATION_SCHEMAS).map(([name, body]) => [name, define(name, body)])), ...Object.fromEntries(Object.entries(LANGUAGE_DISPLAY_SCHEMAS).map(([name, body]) => [name, define(name, body)])), ...Object.fromEntries(Object.entries(LANGUAGE_EDITING_SCHEMAS).map(([name, body]) => [name, define(name, body)])), ...Object.fromEntries(Object.entries(WORKSPACE_EDIT_SCHEMAS).map(([name, body]) => [name, define(name, body)])), @@ -152,9 +157,10 @@ export const SCHEMAS = Object.freeze({ 'job-capabilities': define('job-capabilities', object({protocolVersion: {const: 1}, enabled: {type: 'boolean'}, limits: object(Object.fromEntries(Object.entries(JOB_LIMITS).map(([key, value]) => [key, integer(value, 1)])))})), theme: THEME_SCHEMA, manifest: define('manifest', {properties: commonManifest, oneOf: [manifestV1, manifestV2]}), - command: define('command', object({ + command: define('command', {...object({ id: identifier(128), title: text(128), description: text(2048), parameters: list(commandParameter, 32), - }, ['id', 'title'])), + inputSchema: DATA_SCHEMA_CONTRACT, outputSchema: DATA_SCHEMA_CONTRACT, display: DISPLAY_METADATA_SCHEMA, + }, ['id', 'title']), not: {required: ['parameters', 'inputSchema']}, $defs: DATA_SCHEMA_DEFINITIONS, 'x-maxUtf8Bytes': LIMITS.commandBytes}), 'json-value': define('json-value', { $ref: '#/$defs/value', 'x-maxUtf8Bytes': LIMITS.jsonBytes, 'x-maxDepth': LIMITS.jsonDepth, 'x-maxNodes': LIMITS.jsonNodes, $defs: {value: {anyOf: [ diff --git a/src/secrets.d.mts b/src/secrets.d.mts new file mode 100644 index 0000000..c1b7e46 --- /dev/null +++ b/src/secrets.d.mts @@ -0,0 +1,22 @@ +import type {Disposable, RequestOptions} from './index.mjs'; +import type {SecretReference} from './data-schema.mjs'; +export type {SecretReference} from './data-schema.mjs'; +export {parseSecretReference} from './data-schema.mjs'; +export const SECRET_LIMITS: Readonly<{references: number; commands: number; pending: number; valueBytes: number; defaultTtlMs: number; maxTtlMs: number}>; +export interface SecretExecutionScope {readonly pluginId: string; readonly workspaceId: string; readonly commandId: string; readonly executionId: string;} +export type SecretLeaseCallback = (bytes: Uint8Array, options: {readonly signal: AbortSignal}) => void | Promise; +export interface SecretResolverPort {withSecret(reference: SecretReference, scope: SecretExecutionScope, callback: SecretLeaseCallback, options?: RequestOptions): void | Promise;} +export interface SecretResolver extends SecretResolverPort, Disposable { + issue(input: {readonly secretId: string; readonly pluginId: string; readonly workspaceId: string; readonly commandIds: readonly string[]; readonly ttlMs?: number}): SecretReference; + withSecret(reference: SecretReference, scope: SecretExecutionScope, callback: SecretLeaseCallback, options?: RequestOptions): Promise; + revoke(reference: SecretReference): boolean; + inspect(): Readonly<{references: number; pending: number}>; +} +export function createSecretResolver(options: { + readonly resolve: (request: {readonly secretId: string; readonly reference: SecretReference; readonly scope: SecretExecutionScope; readonly expiresAt: number}, options: {readonly signal: AbortSignal}) => Uint8Array | Promise; + readonly authorize: (request: SecretExecutionScope & {readonly reference: SecretReference; readonly expiresAt: number}) => boolean; + readonly now?: () => number; +}): SecretResolver; +export interface SecretExecutionApi {withSecret(reference: SecretReference, callback: SecretLeaseCallback, options?: RequestOptions): Promise;} +export interface SecretExecution extends Disposable {readonly scope: SecretExecutionScope; readonly secrets: SecretExecutionApi;} +export function createSecretExecution(resolver: SecretResolverPort, scope: SecretExecutionScope, references: readonly SecretReference[], options?: {readonly signal?: AbortSignal}): SecretExecution; diff --git a/src/secrets.mjs b/src/secrets.mjs new file mode 100644 index 0000000..987dcbb --- /dev/null +++ b/src/secrets.mjs @@ -0,0 +1,119 @@ +import {parseSecretReference, configurationCopy as copy, configurationObject as object, configurationInteger as integer, configurationText as text, configurationPluginId, configurationMethod, configurationFailure as fail} from './configuration-values.mjs'; +import {createConfigurationOperations, configurationOptions, configurationError as error} from './configuration-operations.mjs'; +import {ErrorCode} from './limits.mjs'; + +export {parseSecretReference} from './configuration-values.mjs'; +export const SECRET_LIMITS = Object.freeze({references: 128, commands: 32, pending: 8, valueBytes: 16_384, defaultTtlMs: 60_000, maxTtlMs: 3_600_000}); +const typedArrayBytes = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(Uint8Array.prototype), 'byteLength').get; +function copySecretBytes(material) { + try { + if (!(material instanceof Uint8Array)) throw new Error(); + const length = typedArrayBytes.call(material); + if (length < 1 || length > SECRET_LIMITS.valueBytes) throw new Error(); + const bytes = new Uint8Array(length); Uint8Array.prototype.set.call(bytes, material); return bytes; + } catch {throw error(ErrorCode.INVALID_CONTRACT);} +} +function scope(input) { + const value = copy(input, 2048); + object(value, ['pluginId', 'workspaceId', 'commandId', 'executionId']); + configurationPluginId(value.pluginId); configurationPluginId(value.commandId); text(value.workspaceId, 128); + if (typeof value.executionId !== 'string' || !/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(value.executionId)) fail('execution_id'); + return value; +} + +/** Host-owned references. The provider keeps actual values outside settings and metadata. */ +export function createSecretResolver(options) { + object(options, ['resolve', 'authorize'], ['now']); + if (typeof options.resolve !== 'function' || typeof options.authorize !== 'function' || options.now !== undefined && typeof options.now !== 'function') fail('secret_port'); + const {resolve, authorize, now = Date.now} = options; + const records = new Map(), operations = createConfigurationOperations(SECRET_LIMITS.pending); let closed = false; + const open = () => {if (closed) throw error(ErrorCode.DISPOSED);}; + const time = () => {let value; try {value = now();} catch {throw error(ErrorCode.PROVIDER_FAILED);} return integer(value, Number.MAX_SAFE_INTEGER - SECRET_LIMITS.maxTtlMs);}; + function retire(entry) {entry.revoked = true; records.delete(entry.reference.id); operations.abort(entry);} + function prune() {const current = time(); for (const entry of records.values()) if (entry.expiresAt <= current) retire(entry);} + function check(entry, execution, signal) { + open(); if (signal?.aborted) throw signal.reason; + if (!entry || entry.revoked || entry.expiresAt <= time() || records.get(entry.reference.id) !== entry || entry.pluginId !== execution.pluginId || entry.workspaceId !== execution.workspaceId || !entry.commandIds.includes(execution.commandId)) throw error(ErrorCode.PERMISSION_DENIED); + let permitted; + try {permitted = authorize(Object.freeze({...execution, reference: entry.reference, expiresAt: entry.expiresAt}));} + catch {throw error(ErrorCode.PERMISSION_DENIED);} + if (permitted !== true) throw error(ErrorCode.PERMISSION_DENIED); + } + return Object.freeze({ + issue(input) { + open(); prune(); const value = copy(input, 8192); + object(value, ['secretId', 'pluginId', 'workspaceId', 'commandIds'], ['ttlMs']); + text(value.secretId, 128); configurationPluginId(value.pluginId); text(value.workspaceId, 128); + if (!Array.isArray(value.commandIds) || value.commandIds.length < 1 || value.commandIds.length > SECRET_LIMITS.commands || new Set(value.commandIds).size !== value.commandIds.length) fail('commands'); + value.commandIds.forEach(name => configurationPluginId(name)); + const ttlMs = integer(value.ttlMs ?? SECRET_LIMITS.defaultTtlMs, SECRET_LIMITS.maxTtlMs, 1); + if (records.size >= SECRET_LIMITS.references) throw error(ErrorCode.BUDGET_EXCEEDED); + const reference = parseSecretReference({kind: 'dds-secret-reference', id: globalThis.crypto.randomUUID()}); + records.set(reference.id, {...value, reference, expiresAt: time() + ttlMs, revoked: false}); + return reference; + }, + async withSecret(input, executionInput, callback, requestOptions = {}) { + open(); prune(); const reference = parseSecretReference(input), execution = scope(executionInput), entry = records.get(reference.id); + if (typeof callback !== 'function') fail('secret_callback'); + check(entry, execution); + return operations.run(async signal => { + let bytes; + const erase = () => {bytes?.fill(0);}; + signal.addEventListener('abort', erase, {once: true}); + try { + check(entry, execution, signal); + let material; + try {material = await resolve(Object.freeze({secretId: entry.secretId, reference, scope: execution, expiresAt: entry.expiresAt}), Object.freeze({signal}));} + catch {if (signal.aborted) throw signal.reason; throw error(ErrorCode.PROVIDER_FAILED);} + // Copy provider-owned bytes; clear the SDK-owned copy at every exit. + bytes = copySecretBytes(material); material = undefined; + check(entry, execution, signal); + let result; + try {result = await callback(bytes, Object.freeze({signal}));} + catch {if (signal.aborted) throw signal.reason; throw error(ErrorCode.PROVIDER_FAILED);} + check(entry, execution, signal); + if (result !== undefined) throw error(ErrorCode.INVALID_CONTRACT); + } finally {erase(); signal.removeEventListener('abort', erase);} + }, requestOptions, {tag: entry, expiresIn: entry.expiresAt - time()}); + }, + revoke(input) {open(); const reference = parseSecretReference(input), entry = records.get(reference.id); if (!entry) return false; retire(entry); return true;}, + inspect() {open(); prune(); return Object.freeze({references: records.size, pending: operations.pending});}, + dispose() {if (closed) return; closed = true; for (const entry of records.values()) entry.revoked = true; records.clear(); operations.dispose();}, + }); +} + +/** An independent host binds only reviewed input references to one execution. */ +export function createSecretExecution(resolver, executionInput, references, options = {}) { + if (!resolver || typeof resolver !== 'object') fail('secret_port'); + const withSecret = configurationMethod(resolver, 'withSecret', true); + const execution = scope(executionInput), allowed = new Set(); + if (!Array.isArray(references) || references.length > SECRET_LIMITS.references) fail('references'); + for (const reference of references) allowed.add(parseSecretReference(reference).id); + object(options, [], ['signal']); + if (options.signal !== undefined && !(options.signal instanceof AbortSignal)) fail('signal'); + const controller = new AbortController(); let closed = false; + const parentAbort = () => controller.abort(error(ErrorCode.CANCELLED)); + options.signal?.addEventListener('abort', parentAbort, {once: true}); + if (options.signal?.aborted) parentAbort(); + const api = Object.freeze({withSecret(reference, callback, requestOptions = {}) { + const result = Promise.resolve().then(async () => { + if (closed) throw error(ErrorCode.DISPOSED); + if (controller.signal.aborted) throw controller.signal.reason; + const checked = parseSecretReference(reference); + if (!allowed.has(checked.id)) throw error(ErrorCode.PERMISSION_DENIED); + const request = configurationOptions(requestOptions); + const signal = request.signal ? AbortSignal.any([request.signal, controller.signal]) : controller.signal; + try {await withSecret(checked, execution, callback, {...request, signal});} + catch (failure) { + const code = failure && typeof failure === 'object' ? Object.getOwnPropertyDescriptor(failure, 'code')?.value : undefined; + throw error(Object.values(ErrorCode).includes(code) ? code : ErrorCode.PROVIDER_FAILED); + } + if (closed) throw error(ErrorCode.DISPOSED); + if (signal.aborted) throw error(ErrorCode.CANCELLED); + }); + // A host can close while a misbehaving plugin neglects to await its lease. + result.catch(() => {}); + return result; + }}); + return Object.freeze({scope: execution, secrets: api, dispose() {if (closed) return; closed = true; options.signal?.removeEventListener('abort', parentAbort); controller.abort(error(ErrorCode.DISPOSED)); allowed.clear();}}); +} diff --git a/src/settings-host.mjs b/src/settings-host.mjs new file mode 100644 index 0000000..4c06f3c --- /dev/null +++ b/src/settings-host.mjs @@ -0,0 +1,64 @@ +import {parseSettingsSnapshot, SETTINGS_LIMITS} from './settings.mjs'; +import {configurationPluginId} from './configuration-values.mjs'; +import {configurationError as error} from './configuration-operations.mjs'; +import {ErrorCode, LIMITS} from './limits.mjs'; + +function method(port, name, required = false) { + let object = port; + for (let depth = 0; object && depth < 16; depth++, object = Object.getPrototypeOf(object)) { + const descriptor = Object.getOwnPropertyDescriptor(object, name); + if (descriptor) { + if (!('value' in descriptor) || typeof descriptor.value !== 'function') throw error(ErrorCode.INVALID_CONTRACT); + return (...args) => descriptor.value.call(port, ...args); + } + } + if (required) throw error(ErrorCode.INVALID_CONTRACT); +} +export function settingsPorts(input) { + if (!input || typeof input !== 'object' || Array.isArray(input) || ![Object.prototype, null].includes(Object.getPrototypeOf(input))) throw error(ErrorCode.INVALID_CONTRACT); + const names = Reflect.ownKeys(input); if (names.length > LIMITS.maxRegistrations) throw error(ErrorCode.BUDGET_EXCEEDED); + const ports = new Map(); + for (const name of names) { + configurationPluginId(name); + const descriptor = Object.getOwnPropertyDescriptor(input, name), port = descriptor?.value; + if (!descriptor?.enumerable || !('value' in descriptor) || !port || typeof port !== 'object') throw error(ErrorCode.INVALID_CONTRACT); + ports.set(name, {read: method(port, 'read', true), subscribe: method(port, 'subscribe')}); + } + return ports; +} + +export function createSettingsApi({pluginId, workspaceId, port, permit, run, registrations}) { + let subscriptions = 0; + const check = () => {permit(); if (!port) throw error(ErrorCode.CAPABILITY_UNAVAILABLE);}; + const validate = input => { + const value = parseSettingsSnapshot(input); + if (value.pluginId !== pluginId || value.workspaceId !== workspaceId) throw error(ErrorCode.PERMISSION_DENIED); + return value; + }; + return Object.freeze({ + async read(options = {}) {check(); const result = await run(signal => port.read(workspaceId, Object.freeze({signal})), options, validate); check(); return result;}, + subscribe(callback) { + check(); if (typeof callback !== 'function') throw error(ErrorCode.INVALID_CONTRACT); + if (!port.subscribe) throw error(ErrorCode.CAPABILITY_UNAVAILABLE); + if (subscriptions >= SETTINGS_LIMITS.subscriptions) throw error(ErrorCode.BUDGET_EXCEEDED); + let raw, active = true; subscriptions++; + const registration = Object.freeze({dispose() { + if (!active) return; active = false; subscriptions--; registrations.delete(registration); + try {raw?.();} catch { /* port cleanup errors are private */ } + }}); + registrations.add(registration); + try { + const result = port.subscribe(workspaceId, input => { + if (!active) return; + let value; + try {check(); value = validate(input);} catch {registration.dispose(); return;} + try {Promise.resolve(callback(value)).catch(() => {});} catch { /* isolated plugin listener */ } + }); + if (!result || typeof result !== 'object') throw error(ErrorCode.INVALID_CONTRACT); + raw = method(result, 'dispose', true); + if (!active) raw(); + } catch {registration.dispose(); throw error(ErrorCode.PROVIDER_FAILED);} + return registration; + }, + }); +} diff --git a/src/settings.d.mts b/src/settings.d.mts new file mode 100644 index 0000000..b1b5fca --- /dev/null +++ b/src/settings.d.mts @@ -0,0 +1,28 @@ +import type {JsonValue, Disposable, RequestOptions} from './index.mjs'; +import type {DataSchema, DisplayMetadata} from './data-schema.mjs'; +export type SettingsScope = 'user' | 'workspace'; +export interface SettingDefinition {readonly schema: DataSchema; readonly scopes: readonly SettingsScope[]; readonly readOnly?: boolean; readonly display?: DisplayMetadata;} +export interface SettingsDefinition {readonly schemaVersion: 1; readonly pluginId: string; readonly version: number; readonly settings: Readonly>;} +export type SettingsValues = Readonly>; +export interface SettingsSnapshot {readonly schemaVersion: 1; readonly pluginId: string; readonly workspaceId: string; readonly definitionVersion: number; readonly revision: number; readonly values: SettingsValues; readonly sources: Readonly>;} +export interface SettingsLayers {readonly user: SettingsValues; readonly workspaces: Readonly>;} +export interface SettingsState extends SettingsLayers {readonly schemaVersion: 1; readonly pluginId: string; readonly definitionVersion: number; readonly revision: number;} +/** A host-owned port. A plugin only receives the host's scoped read/subscribe API. */ +export interface SettingsReadPort { + read(workspaceId: string, options?: {readonly signal: AbortSignal}): SettingsSnapshot | Promise; + subscribe?(workspaceId: string, callback: (snapshot: SettingsSnapshot) => void): Disposable; +} +export interface SettingsApi {read(options?: RequestOptions): Promise; subscribe(callback: (snapshot: SettingsSnapshot) => void): Disposable;} +export interface SettingsStore extends SettingsReadPort, Disposable { + readonly definition: SettingsDefinition; + read(workspaceId: string): SettingsSnapshot; + exportState(): SettingsState; + update(request: {readonly workspaceId: string; readonly scope: SettingsScope; readonly expectedRevision: number; readonly values?: SettingsValues; readonly reset?: readonly string[]}): SettingsSnapshot; + subscribe(workspaceId: string, callback: (snapshot: SettingsSnapshot) => void): Disposable; + migrate(next: SettingsDefinition, migration: (before: SettingsState, options: {readonly signal: AbortSignal; readonly definition: SettingsDefinition}) => SettingsLayers | Promise, options: RequestOptions & {readonly expectedRevision: number}): Promise; + inspect(): Readonly<{revision: number; definitionVersion: number; workspaces: number; subscriptions: number; pendingMigrations: number}>; +} +export const SETTINGS_LIMITS: Readonly<{definitionBytes: number; keys: number; layerBytes: number; snapshotBytes: number; stateBytes: number; workspaces: number; subscriptions: number; pendingMigrations: number}>; +export function parseSettingsDefinition(value: unknown): SettingsDefinition; +export function parseSettingsSnapshot(value: unknown): SettingsSnapshot; +export function createSettingsStore(definition: SettingsDefinition, options?: {readonly state?: SettingsState}): SettingsStore; diff --git a/src/settings.mjs b/src/settings.mjs new file mode 100644 index 0000000..4182ae6 --- /dev/null +++ b/src/settings.mjs @@ -0,0 +1,167 @@ +import {parseDataSchema, validateDataValue} from './data-schema.mjs'; +import {configurationCopy as copy, configurationFailure as fail, configurationObject as object, configurationInteger as integer, configurationText as text, configurationKey as key, configurationPluginId, displayMetadata, freezeConfiguration} from './configuration-values.mjs'; +import {createConfigurationOperations, configurationError as error} from './configuration-operations.mjs'; +import {ErrorCode} from './limits.mjs'; + +export const SETTINGS_LIMITS = Object.freeze({definitionBytes: 65_536, keys: 64, layerBytes: 65_536, snapshotBytes: 262_144, stateBytes: 1_048_576, workspaces: 32, subscriptions: 64, pendingMigrations: 1}); +const definitions = new WeakSet(); +const own = (value, name) => Object.hasOwn(value, name); +const canonical = value => JSON.stringify(value, (_name, item) => item && typeof item === 'object' && !Array.isArray(item) ? Object.fromEntries(Object.keys(item).sort().map(name => [name, item[name]])) : item); +function workspaceId(value) {text(value, 128); if (/[\u0000-\u001f\u007f]/u.test(value) || ['__proto__', 'prototype', 'constructor'].includes(value)) fail('workspace_id'); return value;} + +export function parseSettingsDefinition(input) { + if (definitions.has(input)) return input; + const value = copy(input, SETTINGS_LIMITS.definitionBytes); + object(value, ['schemaVersion', 'pluginId', 'version', 'settings']); + if (value.schemaVersion !== 1) fail('schema_version'); + configurationPluginId(value.pluginId); integer(value.version, Number.MAX_SAFE_INTEGER, 1); + if (!value.settings || typeof value.settings !== 'object' || Array.isArray(value.settings)) fail('settings'); + const names = Object.keys(value.settings); + if (names.length > SETTINGS_LIMITS.keys) fail('budget', '/settings', true); + for (const name of names) { + key(name, '/settings'); + const item = value.settings[name], path = `/settings/${name}`; + object(item, ['schema', 'scopes'], ['readOnly', 'display'], path); + parseDataSchema(item.schema); + if (!Array.isArray(item.scopes) || item.scopes.length > 2 || new Set(item.scopes).size !== item.scopes.length || item.scopes.some(scope => !['user', 'workspace'].includes(scope))) fail('scopes', path); + if (own(item, 'readOnly') && typeof item.readOnly !== 'boolean') fail('read_only', path); + if (item.readOnly ? item.scopes.length !== 0 || !own(item.schema.schema, 'default') : item.scopes.length === 0) fail('scopes', path); + if (own(item, 'display')) displayMetadata(item.display, `${path}/display`); + } + definitions.add(value); + return value; +} + +function layer(input, definition, scope) { + const value = copy(input, SETTINGS_LIMITS.layerBytes); + if (!value || typeof value !== 'object' || Array.isArray(value)) fail('settings_layer'); + if (Object.keys(value).some(name => !own(definition.settings, name))) fail('unknown_setting'); + const output = {}; + for (const [name, item] of Object.entries(definition.settings)) if (own(value, name)) { + if (item.readOnly || !item.scopes.includes(scope)) throw error(ErrorCode.PERMISSION_DENIED); + output[name] = validateDataValue(item.schema, value[name], {applyDefaults: true}); + } + return copy(output, SETTINGS_LIMITS.layerBytes); +} +function layers(input, definition) { + const value = copy(input, SETTINGS_LIMITS.stateBytes); + object(value, ['user', 'workspaces']); + if (!value.workspaces || typeof value.workspaces !== 'object' || Array.isArray(value.workspaces)) fail('workspaces'); + if (Object.keys(value.workspaces).length > SETTINGS_LIMITS.workspaces) fail('budget', '/workspaces', true); + const workspaces = {}; + for (const name of Object.keys(value.workspaces).sort()) workspaces[workspaceId(name)] = layer(value.workspaces[name], definition, 'workspace'); + return copy({user: layer(value.user, definition, 'user'), workspaces}, SETTINGS_LIMITS.stateBytes); +} + +export function parseSettingsSnapshot(input) { + const value = copy(input, SETTINGS_LIMITS.snapshotBytes); + object(value, ['schemaVersion', 'pluginId', 'workspaceId', 'definitionVersion', 'revision', 'values', 'sources']); + if (value.schemaVersion !== 1) fail('schema_version'); + configurationPluginId(value.pluginId); workspaceId(value.workspaceId); + integer(value.definitionVersion, Number.MAX_SAFE_INTEGER, 1); integer(value.revision, Number.MAX_SAFE_INTEGER); + for (const name of ['values', 'sources']) if (!value[name] || typeof value[name] !== 'object' || Array.isArray(value[name]) || Object.keys(value[name]).length > SETTINGS_LIMITS.keys) fail('settings_snapshot'); + if (Object.keys(value.values).length !== Object.keys(value.sources).length) fail('settings_sources'); + for (const name of Object.keys(value.values)) {key(name); if (!own(value.sources, name) || !['default', 'user', 'workspace'].includes(value.sources[name])) fail('settings_sources');} + return value; +} + +/** One plugin's operator-owned in-memory settings. The owner retains write authority. */ +export function createSettingsStore(input, options = {}) { + object(options, [], ['state']); + let definition = parseSettingsDefinition(input), revision = 0, closed = false, queued = false; + let values = layers({user: {}, workspaces: {}}, definition); + if (options.state !== undefined) { + const state = copy(options.state, SETTINGS_LIMITS.stateBytes); + object(state, ['schemaVersion', 'pluginId', 'definitionVersion', 'revision', 'user', 'workspaces']); + if (state.schemaVersion !== 1 || state.pluginId !== definition.pluginId || state.definitionVersion !== definition.version) throw error(ErrorCode.VERSION_MISMATCH); + revision = integer(state.revision, Number.MAX_SAFE_INTEGER - 1); + values = layers({user: state.user, workspaces: state.workspaces}, definition); + } + const subscribers = new Set(), operations = createConfigurationOperations(SETTINGS_LIMITS.pendingMigrations); + function open() {if (closed) throw error(ErrorCode.DISPOSED);} + function compare(expected) {integer(expected, Number.MAX_SAFE_INTEGER); if (expected !== revision) throw error(ErrorCode.CONFLICT);} + function read(id) { + open(); id = workspaceId(id); + const result = {}, sources = {}, selected = values.workspaces[id] ?? {}; + for (const [name, item] of Object.entries(definition.settings)) { + if (own(item.schema.schema, 'default')) {result[name] = validateDataValue(item.schema, item.schema.schema.default); sources[name] = 'default';} + if (own(values.user, name)) {result[name] = values.user[name]; sources[name] = 'user';} + if (own(selected, name)) {result[name] = selected[name]; sources[name] = 'workspace';} + } + return copy({schemaVersion: 1, pluginId: definition.pluginId, workspaceId: id, definitionVersion: definition.version, revision, values: result, sources}, SETTINGS_LIMITS.snapshotBytes); + } + function exportState() {open(); return copy({schemaVersion: 1, pluginId: definition.pluginId, definitionVersion: definition.version, revision, ...values}, SETTINGS_LIMITS.stateBytes);} + function notify() { + if (queued) return; queued = true; + queueMicrotask(() => { + queued = false; if (closed) return; + for (const listener of [...subscribers]) { + if (!subscribers.has(listener) || listener.revision === revision) continue; + listener.revision = revision; + try {Promise.resolve(listener.callback(read(listener.workspaceId))).catch(() => {});} catch { /* a subscriber cannot roll back or expose an error */ } + } + }); + } + function commit(next, nextDefinition = definition) { + if (revision >= Number.MAX_SAFE_INTEGER - 1) throw error(ErrorCode.BUDGET_EXCEEDED); + copy({schemaVersion: 1, pluginId: nextDefinition.pluginId, definitionVersion: nextDefinition.version, revision: revision + 1, ...next}, SETTINGS_LIMITS.stateBytes); + // Verify every possible effective view before committing any state. + const oldValues = values, oldDefinition = definition; + values = next; definition = nextDefinition; + try {for (const id of ['settings-default-view', ...Object.keys(next.workspaces)]) read(id);} + catch (failure) {values = oldValues; definition = oldDefinition; throw failure;} + revision++; notify(); + } + // Validate the aggregate default/user/workspace sizes at construction too. + for (const id of ['settings-default-view', ...Object.keys(values.workspaces)]) read(id); + return Object.freeze({ + get definition() {open(); return definition;}, + read, + exportState, + update(request) { + open(); request = copy(request, SETTINGS_LIMITS.layerBytes + 4096); + object(request, ['workspaceId', 'scope', 'expectedRevision'], ['values', 'reset']); + const id = workspaceId(request.workspaceId); compare(request.expectedRevision); + if (!['user', 'workspace'].includes(request.scope)) fail('scope'); + const changed = layer(request.values ?? {}, definition, request.scope), reset = request.reset ?? []; + if (!Array.isArray(reset) || reset.length > SETTINGS_LIMITS.keys || new Set(reset).size !== reset.length) fail('reset'); + for (const name of reset) { + key(name); if (!own(definition.settings, name)) fail('unknown_setting'); + const item = definition.settings[name]; + if (item.readOnly || !item.scopes.includes(request.scope)) throw error(ErrorCode.PERMISSION_DENIED); + if (own(changed, name)) fail('reset_conflict'); + } + const current = request.scope === 'user' ? values.user : values.workspaces[id] ?? {}; + const nextLayer = {...current, ...changed}; for (const name of reset) delete nextLayer[name]; + const workspaces = {...values.workspaces}; + if (request.scope === 'workspace') {if (Object.keys(nextLayer).length) workspaces[id] = nextLayer; else delete workspaces[id];} + const next = layers({user: request.scope === 'user' ? nextLayer : values.user, workspaces}, definition); + if (canonical(next) !== canonical(values)) commit(next); + return read(id); + }, + subscribe(id, callback) { + open(); workspaceId(id); + if (typeof callback !== 'function') fail('subscriber'); + if (subscribers.size >= SETTINGS_LIMITS.subscriptions) throw error(ErrorCode.BUDGET_EXCEEDED); + const entry = {workspaceId: id, callback, revision}; subscribers.add(entry); + return Object.freeze({dispose() {subscribers.delete(entry);}}); + }, + async migrate(nextInput, migration, options) { + open(); object(options, ['expectedRevision'], ['signal', 'timeoutMs']); compare(options.expectedRevision); + const expected = revision, before = exportState(), nextDefinition = parseSettingsDefinition(nextInput); + if (nextDefinition.pluginId !== definition.pluginId || nextDefinition.version <= definition.version) throw error(ErrorCode.VERSION_MISMATCH); + if (typeof migration !== 'function') fail('migration'); + return operations.run(async signal => { + let proposed; + try {proposed = await migration(before, Object.freeze({signal, definition: nextDefinition}));} + catch {if (signal.aborted) throw signal.reason; throw error(ErrorCode.PROVIDER_FAILED);} + open(); if (signal.aborted) throw signal.reason; compare(expected); + const next = layers(proposed, nextDefinition); + commit(next, nextDefinition); + return exportState(); + }, {signal: options.signal, timeoutMs: options.timeoutMs}); + }, + inspect() {open(); return freezeConfiguration({revision, definitionVersion: definition.version, workspaces: Object.keys(values.workspaces).length, subscriptions: subscribers.size, pendingMigrations: operations.pending});}, + dispose() {if (closed) return; closed = true; subscribers.clear(); values = undefined; operations.dispose();}, + }); +} diff --git a/src/version.mjs b/src/version.mjs index 4258c96..3d02bde 100644 --- a/src/version.mjs +++ b/src/version.mjs @@ -1,3 +1,3 @@ // The release checks compare this value with the package metadata. -export const SDK_VERSION = '0.11.0'; -export const SDK_PLUGIN_PEER_RANGE = '>=0.11.0 <0.12.0'; +export const SDK_VERSION = '0.12.0'; +export const SDK_PLUGIN_PEER_RANGE = '>=0.12.0 <0.13.0'; diff --git a/src/workspace-node.d.mts b/src/workspace-node.d.mts index ec1f395..8da07b1 100644 --- a/src/workspace-node.d.mts +++ b/src/workspace-node.d.mts @@ -4,6 +4,8 @@ import type {BinaryArtifactSource} from './artifacts.mjs'; import type {JobStore,JobStorageOptions} from './job-storage.mjs'; import type {ArtifactStore} from './artifact-storage.mjs'; import type {NodeUploadConfiguration} from './uploads-node.mjs'; +import type {SettingsReadPort} from './settings.mjs'; +import type {SecretResolverPort} from './secrets.mjs'; export {downloadJobBinaryArtifact} from './artifact-download-node.mjs'; export {downloadStoredJobArtifact} from './stored-artifact-download-node.mjs'; export type {StoredArtifactDownloadReceipt} from './stored-artifact-download-node.mjs'; @@ -26,6 +28,8 @@ export type ProcessBackend=(input:JsonValue,options?:{readonly signal?:AbortSign export function createProcessBackend(options:{readonly executable:string;readonly args?:readonly string[];readonly cwd:string;readonly env?:Readonly>;readonly timeoutMs?:number;readonly maxOutputBytes?:number}):ProcessBackend; export interface ConfiguredWorkspacePlugin {readonly plugin:Plugin;readonly artifactSha256:string;readonly licenseText?:string;} export interface WorkspaceServerOptions { + readonly settings?: Readonly>; + readonly secrets?: SecretResolverPort; /** Optional durable upload store; the server owns and closes this store. */ readonly uploads?:NodeUploadConfiguration; /** Optional operator-scoped project tools on a server-owned Node workspace. */ diff --git a/src/workspace-node.mjs b/src/workspace-node.mjs index b25db6f..fe8d6e5 100644 --- a/src/workspace-node.mjs +++ b/src/workspace-node.mjs @@ -290,7 +290,7 @@ function safeOperationFailure(failure) { } /** HTTP server for explicitly configured trusted plugins in the user environment. */ -export async function createWorkspaceServer({workspace,root,workspaceId,name='User workspace',token,plugins=[],pluginHost,grants=[],backends={},jobs=false,binaryArtifacts=false,jobStorage,projects,uploads,notice,host='127.0.0.1',port=0,writable=true,manage=writable,allowedOrigins=[],timeoutMs=WORKSPACE_LIMITS.defaultTimeoutMs}={}) { +export async function createWorkspaceServer({workspace,root,workspaceId,name='User workspace',token,plugins=[],pluginHost,grants=[],backends={},jobs=false,binaryArtifacts=false,jobStorage,settings,secrets,projects,uploads,notice,host='127.0.0.1',port=0,writable=true,manage=writable,allowedOrigins=[],timeoutMs=WORKSPACE_LIMITS.defaultTimeoutMs}={}) { requireUuid(workspaceId);requireText(name,128);requireText(host,253);token=requireToken(token); if(typeof jobs!=='boolean'||typeof binaryArtifacts!=='boolean')throw workspaceFailure('invalid_request','Invalid job capability'); const ownsPluginHost=pluginHost===undefined; @@ -316,7 +316,7 @@ export async function createWorkspaceServer({workspace,root,workspaceId,name='Us workspace??=await createNodeWorkspace({root,writable,manage,binaryArtifacts}); const generation=randomUUID(),scope=Object.freeze({projectId:workspaceId,sessionId:generation}); let projectTransport; - try{projectTransport=await createWorkspaceProjectTransport(workspace,projects,scope);pluginHost??=createPluginHost({hostId:'workspace-host',scope,grants,workspace,backends,jobs,binaryArtifacts,jobStorage:storageOptions});} + try{projectTransport=await createWorkspaceProjectTransport(workspace,projects,scope);pluginHost??=createPluginHost({hostId:'workspace-host',scope,grants,workspace,backends,jobs,binaryArtifacts,jobStorage:storageOptions,settings,secrets});} catch(failure){await projectTransport?.close();workspace.dispose?.();throw safeOperationFailure(failure);} const pins=new Map(); try{ diff --git a/src/workspace-protocol.mjs b/src/workspace-protocol.mjs index af92540..dafd362 100644 --- a/src/workspace-protocol.mjs +++ b/src/workspace-protocol.mjs @@ -100,7 +100,7 @@ function validatePlugins(plugins) { if (!Array.isArray(plugin.commands) || plugin.commands.length > 64) invalid(); const commandIds = new Set(); for (const command of plugin.commands) { - exactObject(command, ['pluginId', 'id', 'title'], ['description', 'parameters']); + exactObject(command, ['pluginId', 'id', 'title'], ['description', 'parameters', 'inputSchema', 'outputSchema', 'display']); if (command.pluginId !== id || commandIds.has(command.id)) invalid(); const {pluginId: _, ...definition} = command; try { parseCommandDefinition(definition); } catch { invalid(); } diff --git a/tests/command-schema.test.mjs b/tests/command-schema.test.mjs new file mode 100644 index 0000000..6c6c555 --- /dev/null +++ b/tests/command-schema.test.mjs @@ -0,0 +1,93 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {createPluginHost, definePlugin, parseCommandDefinition, parseCommandInput, parseCommandOutput} from '../src/index.mjs'; +import {DataValidationError} from '../src/data-schema.mjs'; +import {createSecretResolver} from '../src/secrets.mjs'; +import {deferred, hasCode} from './fixtures.mjs'; + +const schema = schema => ({schemaVersion: 1, schema}); +const inputSchema = schema({type: 'object', additionalProperties: false, properties: {options: {type: 'object', additionalProperties: false, properties: {count: {type: 'integer', minimum: 1, maximum: 8, default: 2}, names: {type: 'array', items: {type: 'string', minLength: 1}, maxItems: 3}}, required: ['count', 'names']}}, required: ['options']}); +const outputSchema = schema({type: 'object', additionalProperties: false, properties: {total: {type: 'integer', minimum: 0, default: 0}}, required: ['total']}); +const command = {id: 'run', title: 'Run', inputSchema, outputSchema}; +const manifest = {manifestVersion: 2, id: 'structured', name: 'Structured', publisher: 'example', version: '1.0.0', protocolVersion: 1, entry: './plugin.mjs', runtime: 'workspace', capabilities: ['commands'], permissions: [], supportedHosts: ['workspace-host'], license: 'MIT', source: {visibility: 'open', licenseFile: 'LICENSE'}}; +const flush = () => new Promise(resolve => setImmediate(resolve)); +async function hostFor(handler, definition = command, options = {}, permissions = []) { + const host = createPluginHost({hostId: 'workspace-host', jobs: true, ...options}); + await host.activate(definePlugin({...manifest, permissions}, context => context.registerCommand(definition, handler))); + return host; +} +async function terminal(host, jobId) {for (let count = 0; count < 100; count++) {const snapshot = host.getJob(jobId); if (snapshot.state !== 'running') return snapshot; await flush();} assert.fail('Job did not settle');} +const validInput = {options: {names: ['Ada', '한글']}}; + +test('nested inputs apply defaults with stable paths; outputs never receive defaults', () => { + assert.deepEqual(parseCommandInput(validInput, command), {options: {count: 2, names: ['Ada', '한글']}}); + assert.throws(() => parseCommandInput({options: {names: ['']}}, command), error => error instanceof DataValidationError && error.path === '/input/options/names/0' && error.reason === 'range'); + assert.throws(() => parseCommandOutput({}, command), error => error.path === '/output/total' && error.reason === 'required'); + assert.throws(() => parseCommandInput({options: {names: [], unknown: 'do-not-echo'}}, command), error => error.path === '/input/options' && !error.message.includes('do-not-echo')); + assert.throws(() => parseCommandInput({options: {names: [], count: NaN}}, command), hasCode('invalid_contract')); + assert.throws(() => parseCommandInput(JSON.parse('{"options":{"names":[],"__proto__":{}}}'), command), hasCode('invalid_contract')); +}); + +test('schema declaration rejects ambiguous legacy parameters, bad defaults and unsupported keywords', () => { + assert.throws(() => parseCommandDefinition({...command, parameters: []}), hasCode('invalid_contract')); + for (const bad of [{type: 'string', default: 2}, {type: 'string', pattern: '.*'}, {type: 'object', $ref: 'https://example.invalid/schema'}, {type: 'integer', maximum: Infinity}]) assert.throws(() => parseCommandDefinition({...command, inputSchema: schema(bad)}), hasCode('invalid_contract')); + assert.deepEqual(parseCommandInput({name: 'Ada'}, {id: 'old', title: 'Old', parameters: [{name: 'name', label: 'Name', type: 'string', required: true}]}), {name: 'Ada'}); + assert.equal(parseCommandInput(null, {id: 'old', title: 'Old'}), null); +}); + +test('regular and job commands validate before execution and reject malformed output', async () => { + let calls = 0; const host = await hostFor(input => {calls++; assert.equal(input.options.count, 2); return {total: input.options.names.length};}); + await assert.rejects(host.executeCommand('structured', 'run', {options: {names: [false]}}), hasCode('invalid_contract')); + assert.throws(() => host.startCommandJob('structured', 'run', {} ,{jobId: crypto.randomUUID()}), hasCode('invalid_contract')); assert.equal(calls, 0); + assert.deepEqual(await host.executeCommand('structured', 'run', validInput), {total: 2}); + const job = host.startCommandJob('structured', 'run', validInput, {jobId: crypto.randomUUID()}); assert.equal((await terminal(host, job.jobId)).result.total, 2); host.dispose(); + const wrong = await hostFor(() => ({total: 'sensitive provider value'})); + await assert.rejects(wrong.executeCommand('structured', 'run', validInput), error => error.path === '/output/total' && !error.message.includes('sensitive')); + const failed = await terminal(wrong, wrong.startCommandJob('structured', 'run', validInput, {jobId: crypto.randomUUID()}).jobId); + assert.equal(failed.state, 'failed'); assert.equal(failed.error.code, 'invalid_contract'); assert.equal(failed.result, undefined); assert.equal(JSON.stringify(failed).includes('sensitive'), false); wrong.dispose(); +}); + +const secretCommand = {id: 'secret', title: 'Secret', inputSchema: schema({type: 'object', additionalProperties: false, properties: {token: {type: 'object', format: 'dds-secret-reference'}}, required: ['token']}), outputSchema: schema({type: 'boolean'})}; +const issue = resolver => resolver.issue({secretId: 'operator-secret', pluginId: 'structured', workspaceId: 'example-project', commandIds: ['secret']}); + +test('secret references require explicit permission and a resolver before the command starts', async () => { + let calls = 0; const resolver = createSecretResolver({authorize: () => true, resolve: () => new Uint8Array([1])}); const reference = issue(resolver); + for (const [options, permissions, code] of [[{secrets: resolver}, [], 'permission_denied'], [{grants: ['secrets.resolve']}, ['secrets.resolve'], 'capability_unavailable']]) { + const host = await hostFor(() => {calls++; return true;}, secretCommand, options, permissions); + await assert.rejects(host.executeCommand('structured', 'secret', {token: reference}), hasCode(code)); + assert.throws(() => host.startCommandJob('structured', 'secret', {token: reference}, {jobId: crypto.randomUUID()}), hasCode(code)); host.dispose(); + } + assert.equal(calls, 0); resolver.dispose(); +}); + +test('each command execution binds reviewed references, current authority and clears secret bytes', async () => { + let allowed = true, savedApi, bytes, currentReference; const executionIds = []; + const resolver = createSecretResolver({authorize: () => allowed, resolve: request => {executionIds.push(request.scope.executionId); return new Uint8Array([1, 2, 3]);}}); + const reference = issue(resolver), foreign = issue(resolver); currentReference = reference; + const host = await hostFor(async (_input, options) => {savedApi = options.secrets; await options.secrets.withSecret(currentReference, value => {bytes = value; assert.equal(value[1], 2);}); return true;}, secretCommand, {secrets: resolver, grants: ['secrets.resolve']}, ['secrets.resolve']); + assert.equal(await host.executeCommand('structured', 'secret', {token: reference}), true); assert.deepEqual([...bytes], [0, 0, 0]); + await assert.rejects(savedApi.withSecret(reference, () => {}), hasCode('disposed')); + assert.equal((await terminal(host, host.startCommandJob('structured', 'secret', {token: reference}, {jobId: crypto.randomUUID()}).jobId)).state, 'succeeded'); + assert.equal(new Set(executionIds).size, 2); + currentReference = foreign; await assert.rejects(host.executeCommand('structured', 'secret', {token: reference}), hasCode('permission_denied')); + currentReference = reference; allowed = false; await assert.rejects(host.executeCommand('structured', 'secret', {token: reference}), hasCode('permission_denied')); + allowed = true; resolver.revoke(reference); await assert.rejects(host.executeCommand('structured', 'secret', {token: reference}), hasCode('permission_denied')); + host.dispose(); resolver.dispose(); +}); + +test('secret provider errors are redacted and cancellation prevents late command success', async () => { + const gate = deferred(), entered = deferred(); let bytes; + const resolver = createSecretResolver({authorize: () => true, resolve: () => new Uint8Array([7])}); const reference = issue(resolver); + const host = await hostFor(async (input, {secrets}) => {await secrets.withSecret(input.token, async value => {bytes = value; entered.resolve(); await gate.promise;}); return true;}, secretCommand, {secrets: resolver, grants: ['secrets.resolve']}, ['secrets.resolve']); + const controller = new AbortController(), outcome = assert.rejects(host.executeCommand('structured', 'secret', {token: reference}, {signal: controller.signal}), hasCode('cancelled')); + await entered.promise; controller.abort(); await outcome; assert.equal(bytes[0], 0); gate.resolve(); await flush(); host.dispose(); resolver.dispose(); + const broken = createSecretResolver({authorize: () => true, resolve: () => {throw new Error('secret fixture do not echo');}}), token = issue(broken); + const redacted = await hostFor(async (input, {secrets}) => {await secrets.withSecret(input.token, () => {}); return true;}, secretCommand, {secrets: broken, grants: ['secrets.resolve']}, ['secrets.resolve']); + await assert.rejects(redacted.executeCommand('structured', 'secret', {token}), error => error.code === 'provider_failed' && !error.message.includes('fixture') && error.cause === undefined); redacted.dispose(); broken.dispose(); +}); + +test('secret port accessors are rejected without invoking foreign code', () => { + let reads = 0; + assert.throws(() => createPluginHost({secrets: {get withSecret() {reads++; return () => {};}}}), hasCode('invalid_contract')); + assert.equal(reads, 0); +}); diff --git a/tests/core-v2.test.mjs b/tests/core-v2.test.mjs index 3866189..8fd56e6 100644 --- a/tests/core-v2.test.mjs +++ b/tests/core-v2.test.mjs @@ -221,17 +221,25 @@ for (const action of ['dispose', 'deactivate', 'registration']) test(`command ${ await rejected; finish.resolve(); host.dispose(); }); -test('command bounds and pending slots remain reusable after cancellation', async () => { - let context; - const host = createPluginHost(); await host.activate(plugin(value => {context = value; value.registerCommand({id: 'wait', title: 'Wait'}, () => new Promise(() => {}));})); +test('command bounds retain cancelled pending slots until the provider actually settles', async () => { + let context; const gates = []; + const host = createPluginHost(); await host.activate(plugin(value => {context = value; value.registerCommand({id: 'wait', title: 'Wait'}, () => {const gate = deferred(); gates.push(gate); return gate.promise;});})); for (let index = 1; index < LIMITS.maxCommands; index++) context.registerCommand({id: `command-${index}`, title: 'Command'}, () => null); assert.throws(() => context.registerCommand({id: 'extra', title: 'Extra'}, () => null), hasCode(ErrorCode.BUDGET_EXCEEDED)); const controllers = Array.from({length: LIMITS.maxPendingRequests}, () => new AbortController()); const outcomes = controllers.map(controller => assert.rejects(host.executeCommand(manifest.id, 'wait', null, {signal: controller.signal}), hasCode(ErrorCode.CANCELLED))); await assert.rejects(host.executeCommand(manifest.id, 'wait', null), hasCode(ErrorCode.BUDGET_EXCEEDED)); + assert.equal(gates.length, LIMITS.maxPendingRequests); controllers[0].abort(); await outcomes[0]; + await assert.rejects(host.executeCommand(manifest.id, 'wait', null), hasCode(ErrorCode.BUDGET_EXCEEDED)); + gates[0].resolve(null); await new Promise(resolve => setImmediate(resolve)); const replacement = new AbortController(); const replacementOutcome = assert.rejects(host.executeCommand(manifest.id, 'wait', null, {signal: replacement.signal}), hasCode(ErrorCode.CANCELLED)); - replacement.abort(); controllers.forEach(controller => controller.abort()); await Promise.all([...outcomes, replacementOutcome]); host.dispose(); + await new Promise(resolve => setImmediate(resolve)); + assert.equal(gates.length, LIMITS.maxPendingRequests + 1); + replacement.abort(); controllers.forEach(controller => controller.abort()); await Promise.all([...outcomes, replacementOutcome]); + gates.forEach(gate => gate.resolve(null)); await new Promise(resolve => setImmediate(resolve)); + assert.equal(await host.executeCommand(manifest.id, 'command-1', null), null); + host.dispose(); }); test('schemas include both manifest versions, command metadata, bounded JSON and exact theme schema', () => { diff --git a/tests/data-schema.test.mjs b/tests/data-schema.test.mjs new file mode 100644 index 0000000..2ce88e2 --- /dev/null +++ b/tests/data-schema.test.mjs @@ -0,0 +1,71 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {parseDataSchema, validateDataValue, collectSecretReferences, describeDataForm, DataValidationError, DATA_SCHEMA_LIMITS} from '../src/data-schema.mjs'; + +const schema = node => ({schemaVersion: 1, schema: node}); +const record = (properties, required = []) => ({type: 'object', properties, required, additionalProperties: false}); +const code = (reason, path) => error => error instanceof DataValidationError && error.reason === reason && (path === undefined || error.path === path); + +test('structured values validate nested objects and arrays, apply defaults and report known field paths', () => { + const source = schema(record({count: {type: 'integer', minimum: 1, maximum: 10, default: 2}, rows: {type: 'array', minItems: 1, maxItems: 3, items: record({name: {type: 'string', minLength: 1, maxLength: 10}, enabled: {type: 'boolean', default: true}}, ['name', 'enabled'])}}, ['count', 'rows'])); + const parsed = parseDataSchema(source); source.schema.properties.count.default = 9; + const value = validateDataValue(parsed, {rows: [{name: '界🌍'}]}, {applyDefaults: true}); + assert.deepEqual(value, {count: 2, rows: [{name: '界🌍', enabled: true}]}); assert.ok(Object.isFrozen(value.rows[0])); + assert.throws(() => validateDataValue(parsed, {rows: []}, {applyDefaults: true}), code('range', '/rows')); + assert.throws(() => validateDataValue(parsed, {count: 1, rows: [{}]}), code('required', '/rows/0/name')); + assert.throws(() => validateDataValue(parsed, {count: 2, rows: [{name: 'x', enabled: false, privateRejectedKey: 'value'}]}), code('unknown_field', '/rows/0')); + assert.throws(() => validateDataValue(parsed, {count: 1.1, rows: []}), code('type', '/count')); +}); + +test('the schema subset rejects unsupported keywords, invalid defaults and inconsistent enums', () => { + for (const node of [ + {type: 'string', pattern: '.*'}, {type: 'string', $ref: 'https://example.invalid/schema'}, {type: 'object', properties: {}}, + {type: 'array'}, {type: 'array', items: {type: 'string'}, minItems: 2, maxItems: 1}, {type: 'number', minimum: Infinity}, + {type: 'integer', minimum: 1.5}, {type: 'string', enum: ['a', 'a']}, {type: 'string', enum: [1]}, + {type: 'string', default: 1}, {type: 'string', enum: ['a'], default: 'b'}, {type: 'boolean', display: null}, + record({name: {type: 'string'}}, ['missing']), {type: 'object', format: 'remote-reference'}, + ]) assert.throws(() => parseDataSchema(schema(node)), DataValidationError); + assert.throws(() => parseDataSchema({...schema({type: 'null'}), schemaVersion: 2}), code('schema_version')); + for (const [node, value] of [[{type: 'null', enum: [null]}, null], [{type: 'boolean', enum: [true]}, true], [{type: 'number', minimum: -2, maximum: 2}, 1.5]]) assert.equal(validateDataValue(schema(node), value), value); +}); + +test('plain JSON boundaries reject prototypes, accessors, cycles and nonfinite values without invoking hooks', () => { + let calls = 0; + for (const input of [{get type() {calls++; return 'string';}}, {type: 'string', toJSON() {calls++;}}, JSON.parse('{"type":"object","properties":{"__proto__":{"type":"string"}},"additionalProperties":false}')]) assert.throws(() => parseDataSchema(schema(input))); + const cycle = {}; cycle.self = cycle; + for (const input of [NaN, Infinity, 1n, undefined, new Date(), cycle, [1, , 2], {get name() {calls++;}}]) assert.throws(() => validateDataValue(schema(record({name: {type: 'string'}})), input)); + assert.equal(calls, 0); + assert.throws(() => validateDataValue(schema({type: 'string'}), '\ud800'), code('invalid_json')); +}); + +test('schema depth, node, value and expanded-default budgets remain bounded', () => { + let deep = {type: 'string'}; + for (let i = 0; i < DATA_SCHEMA_LIMITS.depth + 1; i++) deep = {type: 'array', items: deep}; + assert.throws(() => parseDataSchema(schema(deep)), code('budget')); + const expanded = schema({type: 'array', items: record({label: {type: 'string', default: 'x'.repeat(16_384)}}, ['label'])}); + assert.throws(() => validateDataValue(expanded, Array.from({length: 8}, () => ({})), {applyDefaults: true}), code('budget')); + assert.throws(() => validateDataValue(schema({type: 'array', items: {type: 'null'}}), Array(257).fill(null)), code('range')); + const many = Object.fromEntries(Array.from({length: 65}, (_, i) => ['field'+i, {type: 'null'}])); + assert.throws(() => parseDataSchema(schema(record(many))), code('budget')); +}); + +test('secret references are explicit typed fields, not arbitrary strings, objects or defaults', () => { + const ref = {kind: 'dds-secret-reference', id: crypto.randomUUID()}; + const reference = {type: 'object', format: 'dds-secret-reference'}; + const typed = schema(record({credentials: {type: 'array', items: reference}}, ['credentials'])); + assert.deepEqual(collectSecretReferences(typed, {credentials: [ref]}), [{path: '/credentials/0', reference: ref}]); + assert.throws(() => validateDataValue(schema(reference), 'opaque-value'), code('secret_reference')); + assert.throws(() => validateDataValue(schema(reference), {...ref, value: 'hidden'}), code('secret_reference')); + assert.throws(() => parseDataSchema(schema({...reference, default: ref}))); + assert.throws(() => parseDataSchema(schema({...record({ref: reference}, ['ref']), default: {ref}})), code('secret_default')); + const ordinary = schema(record({kind: {type: 'string'}, id: {type: 'string'}})); + assert.deepEqual(collectSecretReferences(ordinary, ref), []); +}); + +test('form metadata stays literal, bounded and detached from schema input', () => { + const s = schema(record({mode: {type: 'string', enum: ['safe', 'fast'], default: 'safe', display: {label: '', help: 'Use {mode}', labelKey: 'mode.label', order: 2, accessibility: {name: 'Mode', descriptionKey: 'mode.help', shortcut: 'Alt+M'}}}})); + const fields = describeDataForm(s); + assert.equal(fields[1].path, '/mode'); assert.equal(fields[1].display.label, ''); assert.deepEqual(fields[1].choices, ['safe', 'fast']); + assert.ok(Object.isFrozen(fields[1].display.accessibility)); + assert.throws(() => parseDataSchema(schema({type: 'string', display: {html: 'bad'}}))); +}); diff --git a/tests/localization.test.mjs b/tests/localization.test.mjs new file mode 100644 index 0000000..7174554 --- /dev/null +++ b/tests/localization.test.mjs @@ -0,0 +1,96 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {parseLocaleCatalog, canonicalLocale, negotiateLocale, resolveMessage, formatMessage, messagePlaceholders, localeDirection, localizeDisplay, validateLocalizedSurfaces, formatLocaleNumber, formatLocaleDate, selectLocalePlural, LOCALIZATION_LIMITS} from '../src/localization.mjs'; +import {inspectAccessibility, normalizeShortcut, contrastRatio, inspectThemeContrast} from '../src/accessibility.mjs'; +import {parseCommandDefinition, parseCommandInput, createPluginHost, definePlugin} from '../src/index.mjs'; +import {SCHEMAS} from '../src/schemas.mjs'; +import {deferred, hasCode} from './fixtures.mjs'; + +const base = () => ({schemaVersion: 1, defaultLocale: 'en', messages: {en: {greet: 'Hello {name}', title: 'Run', help: 'Start the command', count: '{count} results'}, ko: {greet: '안녕하세요 {name}', title: '실행'}, ar: {greet: 'مرحبًا {name}', title: 'تشغيل'}, fr: {title: 'Exécuter'}}, fallbacks: {ko: ['fr'], ar: ['en']}}); + +test('canonical locales, preference negotiation and deterministic per-key fallback preserve source locale', () => { + const catalog = parseLocaleCatalog(base()); + assert.equal(canonicalLocale('ko-kr'), 'ko-KR'); assert.equal(negotiateLocale(catalog, ['de-DE', 'ko-KR']), 'ko'); + assert.equal(negotiateLocale(catalog, 'zh-Hans-CN'), 'en'); assert.equal(resolveMessage(catalog, 'ko-KR', 'title').locale, 'ko'); + assert.equal(resolveMessage(catalog, 'ko', 'help').locale, 'en'); assert.equal(resolveMessage(catalog, 'ko', 'help').fallback, true); + assert.equal(formatMessage(catalog, 'ar', 'greet', {name: 'Ada'}), 'مرحبًا Ada'); + assert.equal(localeDirection('ar-EG'), 'rtl'); assert.equal(localeDirection('az-Arab'), 'rtl'); assert.equal(localeDirection('ko-KR'), 'ltr'); + for (const locale of ['not_a_locale', 'en-u-ca-gregory', '', 'x-private']) assert.throws(() => canonicalLocale(locale), hasCode('invalid_contract')); + assert.throws(() => negotiateLocale(catalog, ['en', 'bad_']), hasCode('invalid_contract')); +}); + +test('catalogs reject cycles, unresolved fallbacks, unknown keys and placeholder mismatches', () => { + for (const change of [ + value => {value.fallbacks = {ko: ['fr'], fr: ['ko']};}, + value => {value.fallbacks = {ko: ['de']};}, + value => {value.fallbacks = {en: ['ko']};}, + value => {value.messages.ko.greet = 'Hi {different}';}, + value => {value.messages.ko.extra = 'unregistered';}, + value => {value.messages.en.greet = 'Hello {{name}}';}, + value => {value.messages['en-us'] = {};}, + value => {value.defaultLocale = 'de';}, + ]) {const input = base(); change(input); assert.throws(() => parseLocaleCatalog(input), hasCode('invalid_contract'));} + assert.throws(() => formatMessage(base(), 'en', 'absent'), hasCode('invalid_contract')); +}); + +test('messages and arguments remain literal and bounded without executing hooks', () => { + const catalog = parseLocaleCatalog(base()); const malicious = ' {name}'; + assert.equal(formatMessage(catalog, 'en', 'greet', {name: malicious}), 'Hello ' + malicious); + assert.deepEqual(messagePlaceholders('{a} {a} {b}'), ['a', 'b']); + for (const args of [{}, {name: 'A', extra: 'B'}, {name: null}, {name: NaN}]) assert.throws(() => formatMessage(catalog, 'en', 'greet', args), hasCode('invalid_contract')); + let hooks = 0; + assert.throws(() => parseLocaleCatalog({...base(), get messages() {hooks++; return {};}})); + assert.throws(() => formatMessage(catalog, 'en', 'greet', {get name() {hooks++; return '';}})); assert.equal(hooks, 0); + const long = base(); long.messages.en.title = 'x'.repeat(LOCALIZATION_LIMITS.messageCharacters + 1); assert.throws(() => parseLocaleCatalog(long)); + const expanding = {schemaVersion: 1, defaultLocale: 'en', messages: {en: {many: Array(20).fill('{name}').join('')}}}; + assert.throws(() => formatMessage(expanding, 'en', 'many', {name: 'x'.repeat(4096)})); + const detached = base(), parsed = parseLocaleCatalog(detached); detached.messages.en.title = 'mutated'; assert.equal(formatMessage(parsed, 'en', 'title'), 'Run'); +}); + +test('explicit Intl helpers use bounded options, UTC dates and supported locales', () => { + assert.equal(formatLocaleNumber('en-US', 1234.5), '1,234.5'); assert.equal(formatLocaleNumber('de-DE', 1234.5), '1.234,5'); + assert.equal(formatLocaleDate('en-US', Date.UTC(2026, 9, 4), {dateStyle: 'short'}), '10/4/26'); + assert.equal(selectLocalePlural('en', 1), 'one'); assert.equal(selectLocalePlural('en', 2), 'other'); assert.equal(selectLocalePlural('ar', 2), 'two'); + for (const run of [() => formatLocaleNumber('en', NaN), () => formatLocaleNumber('en', 1, {maximumFractionDigits: 11}), () => formatLocaleNumber('en', 1, {currency: 'USD'}), () => formatLocaleDate('en', 0, {timeZone: 'Not/AZone'}), () => formatLocaleDate('en', Infinity), () => selectLocalePlural('en', 1, {type: 'expression'})]) assert.throws(run); +}); + +test('manifest, commands, settings and theme display cross-checks leave IDs and values unchanged', () => { + const catalog = base(), display = {labelKey: 'title', helpKey: 'help', accessibility: {nameKey: 'title', descriptionKey: 'help', shortcut: 'Control+Enter'}}; + const command = parseCommandDefinition({id: 'stable-command', title: 'Fallback', display, inputSchema: {schemaVersion: 1, schema: {type: 'string', display, default: 'stable-value'}}}); + const settings = {schemaVersion: 1, pluginId: 'stable', version: 1, settings: {stableKey: {scopes: ['workspace'], display, schema: {schemaVersion: 1, schema: {type: 'boolean', default: true}}}}}; + const report = validateLocalizedSurfaces(catalog, {commands: [command], settings, themes: [{theme, display}]}); + assert.ok(report.missingTranslations.some(item => item.locale === 'ko' && item.key === 'help')); + assert.equal(localizeDisplay(catalog, 'ko', display).accessibility.name, '실행'); assert.equal(command.id, 'stable-command'); assert.equal(parseCommandInput(undefined, command), 'stable-value'); + assert.throws(() => validateLocalizedSurfaces(catalog, {commands: [{id: 'bad', title: 'Bad', display: {labelKey: 'missing'}}]}), error => error.reason === 'missing_display_key'); + assert.throws(() => validateLocalizedSurfaces(catalog, {commands: [{id: 'bad', title: 'Bad', display: {labelKey: 'greet'}}]}), error => error.reason === 'display_placeholder'); + const oversized = base(); oversized.messages.ko.title = '한'.repeat(257); assert.throws(() => validateLocalizedSurfaces(oversized, {commands: [command]})); +}); + +const theme = {name: 'Contrast fixture', colors: {light: {backdrop: '#ffffff', navigation: '#ffffff', tool: '#ffffff', main: '#ffffff', text: '#000000', muted: '#777777'}, dark: {backdrop: '#000000', navigation: '#000000', tool: '#000000', main: '#000000', text: '#ffffff', muted: '#777777'}}}; +test('accessibility reports exact keyboard conflicts and unrounded opaque-color contrast', () => { + assert.equal(normalizeShortcut('shift+control+k'), 'Control+Shift+K'); + for (const value of ['Ctrl+K', 'Control+Control+K', 'Control', '