From 4b507fb83187db96eb974504efb9356dc12746fd Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Fri, 16 Oct 2026 23:00:04 +0300 Subject: [PATCH 1/5] feat(extensions): manifestVersion, the format version of manifest.json Absent means format 1. The model reads it, the protocol profile says which host version reads which format, the tester's manifest check rejects a format the emulated host does not read, and the app does not list, install or load an extension in a newer format. --- CHANGELOG.md | 1 + docs/extension-registry.md | 33 +++++++++++-- lib/core/extensions/extension_support.dart | 12 +++++ .../harness/extension_manifest_validator.dart | 14 ++++++ .../harness/extension_protocol_profile.dart | 32 +++++++++++-- .../extensions/local_extension_installer.dart | 3 ++ .../extensions/local_extension_registry.dart | 7 +++ .../extensions/models/extension_manifest.dart | 15 ++++++ .../market/http_marketplace_repository.dart | 3 ++ lib/core/market/marketplace_repository.dart | 3 ++ .../market/static_registry_repository.dart | 2 + .../extensions/extension_support_test.dart | 46 +++++++++++++++++++ .../extension_manifest_validator_test.dart | 38 +++++++++++++++ .../local_extension_registry_test.dart | 25 ++++++++++ .../static_registry_repository_test.dart | 12 +++++ 15 files changed, 238 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d46fb235..851d26b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **`querya-ext-tester --strict`** — warnings fail the run (exit 1), for example a recommended method that errors or an OS sandbox that could not be applied; with `--sandbox` in CI a green run now means the plugin really ran isolated. The `test-extension` action has a matching `strict` input. - **Extension registry** — `StaticRegistryRepository` reads a static `index.json` published by [QueryaHub/extensions](https://github.com/QueryaHub/extensions) (GitHub Pages): yanked entries, non-GitHub-release URLs and extensions the running version is too old for are hidden, the index is cached, installs keep the SHA-256 / archive / sandbox checks. The Extensions → Marketplace tab now shows this catalog instead of the built-in demo one (which listed fake extensions and wrote placeholder folders into the real extensions directory, #1568); while the registry is empty or unreachable the tab says so, with a *Retry*. Also new for extension authors: the `ghcr.io/queryahub/features/querya-ext-tester` Dev Container Feature, `querya-testdb-*` test database images, and the [extension template](https://github.com/QueryaHub/extension-template). See [docs/extension-registry.md](docs/extension-registry.md). +- **`manifestVersion` in `manifest.json`** — the version of the manifest format itself, apart from the extension's `version` and the host range `engines`. Absent means format 1, so nothing existing changes; `querya-ext-tester` rejects a format the emulated host does not read, and the app does not offer, install or load an extension in a format newer than it reads. See [docs/extension-registry.md](docs/extension-registry.md#versions-what-is-checked-against-what). - **Release hooks** — after a Release the registry and the template are told about the new version (`repository_dispatch`); needs the `QUERYA_DISPATCH_TOKEN` secret. ### Fixed diff --git a/docs/extension-registry.md b/docs/extension-registry.md index 1b1397f1..ce32be23 100644 --- a/docs/extension-registry.md +++ b/docs/extension-registry.md @@ -40,6 +40,32 @@ The extension's binary runs in the registry's CI, inside the OS sandbox, in a jo with no secrets and a read-only token. Only the Linux x86-64 build is exercised; the other platforms' archives are checked (hash, archive, manifest) but not run. +## Versions: what is checked against what + +Three different versions meet in `manifest.json`: + +| Field | What it versions | Checked | +|-------|------------------|---------| +| `version` | the extension itself | the registry record and the archive must agree | +| `engines.querya_desktop` | the Querya Desktop versions the author says it runs on | the app hides the extension outside the range (`>=x.y.z` or `^x.y.z`) | +| `manifestVersion` | the **format of `manifest.json`** (apart from the other two) | see below | + +`manifestVersion` is a whole number, **1 when absent** (every manifest written so +far). It exists so the format can change later without guessing from the host +version: a release that starts reading a new format raises +`ExtensionManifest.currentManifestVersion` and lists the first host version that +reads it in `ExtensionProtocolProfile`. Until a format 2 exists there is nothing +to opt into; new manifests may set `"manifestVersion": 1` explicitly (the +template does). + +- **The tester** checks `manifestVersion` against the host it emulates + (`--target-version`): a format newer than that host reads is an error, a value + that is not a whole number >= 1 is an error. +- **The app** does not offer, install or load an extension in a format newer than + it reads, and says to update Querya Desktop. +- **The registry** refuses a record whose archive uses a format Querya does not + define yet, and copies the format into the index entry. + ## What the app trusts `StaticRegistryRepository` reads `https://queryahub.github.io/extensions/index.json`: @@ -64,11 +90,12 @@ checked (hash, archive, manifest) but not run. `macos-x64`, `windows-x64`), each with its own `downloadUrl`, `sha256` and `main`. The app resolves the map to the build for the platform it runs on and does not offer the extension where there is none. -- An index with another `schema` is refused, not half read. +- An index with another `schema` is refused, not half read. (`schema` is the + index's own format; `manifestVersion` of an entry is its manifest's.) - Hidden from the list: `yanked` entries, entries without a checksum, a `downloadUrl` that is not `https://github.com///releases/download//`, - and extensions whose `engines.querya_desktop` range excludes the running - version. One malformed entry does not hide the others. + extensions whose `engines.querya_desktop` range excludes the running + version, and extensions whose `manifestVersion` is newer than the app reads. One malformed entry does not hide the others. - The index is cached for ten minutes; if a refresh fails the last good copy is served. - Installing is the same pipeline as any marketplace install: the archive's diff --git a/lib/core/extensions/extension_support.dart b/lib/core/extensions/extension_support.dart index cd8336c3..7d5db540 100644 --- a/lib/core/extensions/extension_support.dart +++ b/lib/core/extensions/extension_support.dart @@ -25,6 +25,18 @@ class ExtensionSupport { static const databaseDriverMissingEntryMessage = 'Driver package is missing its main entry file. Installation aborted.'; + /// Why this build cannot read [manifest], or null when it can: the manifest + /// is in a newer format than [ExtensionManifest.currentManifestVersion]. + static String? manifestFormatProblem(ExtensionManifest manifest) { + if (manifest.manifestVersion <= ExtensionManifest.currentManifestVersion) { + return null; + } + return 'Extension "${manifest.id}" uses manifest format ' + '${manifest.manifestVersion}; this Querya Desktop reads format ' + '${ExtensionManifest.currentManifestVersion} or older. ' + 'Update Querya Desktop to use it.'; + } + /// Type-level preview heuristic (drivers default to preview). /// Prefer [isPreviewOnlyManifest] when a full manifest is available. static bool isPreviewOnly(ExtensionType type) => diff --git a/lib/core/extensions/harness/extension_manifest_validator.dart b/lib/core/extensions/harness/extension_manifest_validator.dart index d04a4cd0..663cb6ba 100644 --- a/lib/core/extensions/harness/extension_manifest_validator.dart +++ b/lib/core/extensions/harness/extension_manifest_validator.dart @@ -51,6 +51,20 @@ List validateExtensionManifest( requireString('id'); requireString('name'); + final format = manifest['manifestVersion']; + if (format != null) { + if (format is! int || format < 1) { + error('manifestVersion', 'must be a whole number, 1 or more, got "$format"'); + } else if (format > profile.manifestVersion) { + error( + 'manifestVersion', + 'format $format is newer than Querya Desktop ${profile.targetVersion} ' + 'reads (formats up to ${profile.manifestVersion}); an older host ' + 'would not understand this manifest', + ); + } + } + final version = manifest['version']; if (version == null) { warning('version', 'missing; the host falls back to 0.0.0'); diff --git a/lib/core/extensions/harness/extension_protocol_profile.dart b/lib/core/extensions/harness/extension_protocol_profile.dart index b4290fa1..459cace6 100644 --- a/lib/core/extensions/harness/extension_protocol_profile.dart +++ b/lib/core/extensions/harness/extension_protocol_profile.dart @@ -68,7 +68,11 @@ final class HarnessProtocolMethod { /// `since` values come from the first release tag whose sources contain the /// method. The protocol did not exist before 0.4.11. final class ExtensionProtocolProfile { - ExtensionProtocolProfile._(this.targetVersion, this.methods); + ExtensionProtocolProfile._( + this.targetVersion, + this.methods, + this.manifestVersion, + ); /// Oldest host version with the extension RPC protocol. static const minSupported = HostVersion(0, 4, 11); @@ -98,9 +102,20 @@ final class ExtensionProtocolProfile { 'commands.execute', HostVersion(0, 4, 16), HarnessMethodLevel.recommended), ]; + /// `manifest.json` formats and the first Querya Desktop release that reads + /// each. Format 1 is the original; a manifest without `manifestVersion` is + /// format 1. Add an entry here when a release starts reading a new format, and + /// raise `ExtensionManifest.currentManifestVersion` in the same change. + static const _manifestFormats = <(int, HostVersion)>[ + (1, minSupported), + ]; + /// Host version this profile emulates. final HostVersion targetVersion; + /// Newest `manifestVersion` that host reads. + final int manifestVersion; + /// Methods that host version may send. final List methods; @@ -115,10 +130,17 @@ final class ExtensionProtocolProfile { 'Extensions are not supported before Querya Desktop $minSupported', ); } - return ExtensionProtocolProfile._(version, [ - for (final m in _all) - if (version >= m.since) m, - ]); + return ExtensionProtocolProfile._( + version, + [ + for (final m in _all) + if (version >= m.since) m, + ], + [ + for (final (format, since) in _manifestFormats) + if (version >= since) format, + ].fold(1, (a, b) => b > a ? b : a), + ); } bool supports(String method) => methods.any((m) => m.name == method); diff --git a/lib/core/extensions/local_extension_installer.dart b/lib/core/extensions/local_extension_installer.dart index e8b0f3f4..1afaafa6 100644 --- a/lib/core/extensions/local_extension_installer.dart +++ b/lib/core/extensions/local_extension_installer.dart @@ -105,6 +105,9 @@ class LocalExtensionInstaller { throw MarketplaceException(ExtensionSupport.databaseDriverPreviewNotice); } + final formatProblem = ExtensionSupport.manifestFormatProblem(manifest); + if (formatProblem != null) throw MarketplaceException(formatProblem); + final sandboxViolations = SandboxPolicy.validate(manifest); if (sandboxViolations.isNotEmpty) { throw MarketplaceException( diff --git a/lib/core/extensions/local_extension_registry.dart b/lib/core/extensions/local_extension_registry.dart index 2bbf375a..23ac974c 100644 --- a/lib/core/extensions/local_extension_registry.dart +++ b/lib/core/extensions/local_extension_registry.dart @@ -6,6 +6,7 @@ import 'package:path/path.dart' as p; import 'extension_command_sync.dart'; import 'extension_paths.dart'; +import 'extension_support.dart'; import 'models/extension_manifest.dart'; import 'sandbox/sandbox_policy.dart'; @@ -59,6 +60,12 @@ class LocalExtensionRegistry { json, installPath: entity.path, ); + final formatProblem = + ExtensionSupport.manifestFormatProblem(manifest); + if (formatProblem != null) { + debugPrint('LocalExtensionRegistry: skipped — $formatProblem'); + continue; + } final violations = SandboxPolicy.validate(manifest); if (violations.isNotEmpty) { debugPrint( diff --git a/lib/core/extensions/models/extension_manifest.dart b/lib/core/extensions/models/extension_manifest.dart index 6e0ab098..5d2f0356 100644 --- a/lib/core/extensions/models/extension_manifest.dart +++ b/lib/core/extensions/models/extension_manifest.dart @@ -10,6 +10,16 @@ import 'sandbox_capabilities.dart'; class ExtensionManifest { static const typeTheme = ExtensionType.theme; + /// The newest `manifest.json` format this build reads. A manifest without + /// `manifestVersion` is format 1. Raise it only together with + /// `ExtensionProtocolProfile` (which host version first reads the new format) + /// and docs/extension-registry.md. + static const currentManifestVersion = 1; + + /// `manifestVersion` of `manifest.json`: the format of the file itself, apart + /// from the extension's own [version] and the host range in [engines]. + final int manifestVersion; + final String id; final String name; final String version; @@ -39,6 +49,7 @@ class ExtensionManifest { final ExtensionContributions? contributions; const ExtensionManifest({ + this.manifestVersion = 1, required this.id, required this.name, required this.version, @@ -110,7 +121,10 @@ class ExtensionManifest { factory ExtensionManifest.fromJson(Map json, {String? installPath}) { + final rawFormat = json['manifestVersion']; return ExtensionManifest( + // Not a whole number >= 1: the validator reports it; here it reads as 1. + manifestVersion: rawFormat is int && rawFormat >= 1 ? rawFormat : 1, id: json['id'] as String, name: json['name'] as String, version: json['version'] as String? ?? '0.0.0', @@ -180,6 +194,7 @@ class ExtensionManifest { Map toJson() { return { + if (manifestVersion != 1) 'manifestVersion': manifestVersion, 'id': id, 'name': name, 'version': version, diff --git a/lib/core/market/http_marketplace_repository.dart b/lib/core/market/http_marketplace_repository.dart index 7ae19fb5..164674ea 100644 --- a/lib/core/market/http_marketplace_repository.dart +++ b/lib/core/market/http_marketplace_repository.dart @@ -146,6 +146,9 @@ class HttpMarketplaceRepository implements MarketplaceRepository { throw MarketplaceException(ExtensionSupport.databaseDriverPreviewNotice); } + final formatProblem = ExtensionSupport.manifestFormatProblem(manifest); + if (formatProblem != null) throw MarketplaceException(formatProblem); + final sandboxViolations = SandboxPolicy.validate(manifest); if (sandboxViolations.isNotEmpty) { throw MarketplaceException( diff --git a/lib/core/market/marketplace_repository.dart b/lib/core/market/marketplace_repository.dart index 7ab5371f..ab49033c 100644 --- a/lib/core/market/marketplace_repository.dart +++ b/lib/core/market/marketplace_repository.dart @@ -190,6 +190,9 @@ class MockMarketplaceRepository implements MarketplaceRepository { throw MarketplaceException(ExtensionSupport.databaseDriverPreviewNotice); } + final formatProblem = ExtensionSupport.manifestFormatProblem(manifest); + if (formatProblem != null) throw MarketplaceException(formatProblem); + final sandboxViolations = SandboxPolicy.validate(manifest); if (sandboxViolations.isNotEmpty) { throw MarketplaceException( diff --git a/lib/core/market/static_registry_repository.dart b/lib/core/market/static_registry_repository.dart index 71f1403f..75c1f55f 100644 --- a/lib/core/market/static_registry_repository.dart +++ b/lib/core/market/static_registry_repository.dart @@ -4,6 +4,7 @@ import 'dart:isolate'; import 'package:flutter/foundation.dart'; import 'package:http/http.dart' as http; +import 'package:querya_desktop/core/extensions/extension_support.dart'; import 'package:querya_desktop/core/extensions/harness/extension_manifest_validator.dart'; import 'package:querya_desktop/core/extensions/harness/extension_protocol_profile.dart'; import 'package:querya_desktop/core/extensions/models/extension_manifest.dart'; @@ -156,6 +157,7 @@ class StaticRegistryRepository extends HttpMarketplaceRepository { final url = Uri.tryParse(manifest.downloadUrl ?? ''); if (url == null || !isReleaseAssetUrl(url)) continue; if ((manifest.sha256Checksum ?? '').trim().isEmpty) continue; + if (ExtensionSupport.manifestFormatProblem(manifest) != null) continue; final range = manifest.engines['querya_desktop']; if (range != null && engineRangeAllows(host, range) == false) continue; out.add(manifest); diff --git a/test/core/extensions/extension_support_test.dart b/test/core/extensions/extension_support_test.dart index c0681dbb..3bbcc35f 100644 --- a/test/core/extensions/extension_support_test.dart +++ b/test/core/extensions/extension_support_test.dart @@ -9,6 +9,52 @@ import 'package:querya_desktop/core/extensions/models/sandbox_capabilities.dart' import 'package:querya_desktop/core/market/marketplace_repository.dart'; void main() { + group('manifest format', () { + ExtensionManifest withFormat(int format) => ExtensionManifest( + manifestVersion: format, + id: 'test.fmt', + name: 'Fmt', + version: '1.0.0', + publisher: 'Test', + type: ExtensionType.theme, + engines: const {'querya_desktop': '*'}, + ); + + test('this build reads its own format and older ones', () { + expect(ExtensionSupport.manifestFormatProblem(withFormat(1)), isNull); + expect( + ExtensionSupport.manifestFormatProblem( + withFormat(ExtensionManifest.currentManifestVersion)), + isNull, + ); + }); + + test('a newer format is refused with a message that says what to do', () { + final problem = ExtensionSupport.manifestFormatProblem( + withFormat(ExtensionManifest.currentManifestVersion + 1)); + expect(problem, contains('test.fmt')); + expect(problem, contains('Update Querya Desktop')); + }); + + test('fromJson: absent or invalid reads as 1; toJson keeps only a real one', + () { + Map json(Object? v) => { + 'id': 'a.b', + 'name': 'A', + 'type': 'theme', + if (v != null) 'manifestVersion': v, + }; + expect(ExtensionManifest.fromJson(json(null)).manifestVersion, 1); + expect(ExtensionManifest.fromJson(json('2')).manifestVersion, 1); + expect(ExtensionManifest.fromJson(json(0)).manifestVersion, 1); + final v3 = ExtensionManifest.fromJson(json(3)); + expect(v3.manifestVersion, 3); + expect(v3.toJson()['manifestVersion'], 3); + expect(ExtensionManifest.fromJson(json(1)).toJson(), + isNot(contains('manifestVersion'))); + }); + }); + group('ExtensionSupport', () { test('marks bare database drivers as preview-only', () { expect( diff --git a/test/core/extensions/harness/extension_manifest_validator_test.dart b/test/core/extensions/harness/extension_manifest_validator_test.dart index d7a49b84..5509e712 100644 --- a/test/core/extensions/harness/extension_manifest_validator_test.dart +++ b/test/core/extensions/harness/extension_manifest_validator_test.dart @@ -101,4 +101,42 @@ void main() { expect(d.single.path, 'contributions.commands'); expect(_check(m, '0.4.16'), isEmpty); }); + + group('manifestVersion', () { + List errors(Object? manifest, [String target = '0.5.0']) => + _check(manifest, target).where((d) => d.isError).toList(); + + test('absent means format 1 and is fine', () { + expect(_check(_valid()), isEmpty); + }); + + test('format 1 is fine on every host that has the protocol', () { + final m = _valid()..['manifestVersion'] = 1; + for (final host in ['0.4.11', '0.4.18', '0.5.0']) { + expect(_check(m, host), isEmpty, reason: host); + } + }); + + test('a format newer than the host reads is an error naming the path', () { + final d = errors(_valid()..['manifestVersion'] = 2); + expect(d, hasLength(1)); + expect(d.single.path, 'manifestVersion'); + expect(d.single.message, contains('newer than Querya Desktop 0.5.0')); + }); + + test('a value that is not a whole number >= 1 is an error', () { + for (final bad in [0, -1, 1.5, '1', true, []]) { + final d = errors(_valid()..['manifestVersion'] = bad); + expect(d.map((e) => e.path), ['manifestVersion'], reason: '$bad'); + } + }); + }); + + group('protocol profile manifest formats', () { + test('every host with the protocol reads format 1', () { + for (final host in ['0.4.11', '0.4.19', '0.5.0']) { + expect(ExtensionProtocolProfile.forTarget(host).manifestVersion, 1); + } + }); + }); } diff --git a/test/core/extensions/local_extension_registry_test.dart b/test/core/extensions/local_extension_registry_test.dart index 21d43ab3..ff02ad8c 100644 --- a/test/core/extensions/local_extension_registry_test.dart +++ b/test/core/extensions/local_extension_registry_test.dart @@ -29,6 +29,31 @@ void main() { } }); + test('skips an extension whose manifest format is newer than this build reads', + () async { + Future write(String dir, int? format) async { + final d = Directory(p.join(tempDir.path, dir))..createSync(); + File(p.join(d.path, 'manifest.json')).writeAsStringSync(jsonEncode({ + 'id': 'test.$dir', + 'name': dir, + 'version': '1.0.0', + 'publisher': 'Test', + 'type': 'theme', + 'engines': {'querya_desktop': '*'}, + if (format != null) 'manifestVersion': format, + })); + } + + await write('plain', null); + await write('current', 1); + await write('future', 99); + + await LocalExtensionRegistry.instance.reload(); + final ids = LocalExtensionRegistry.instance.manifests.map((m) => m.id); + expect(ids, containsAll(['test.plain', 'test.current'])); + expect(ids, isNot(contains('test.future'))); + }); + test('loads extensions from manifest files', () async { // Create a valid extension folder final ext1Dir = Directory(p.join(tempDir.path, 'ext1')); diff --git a/test/core/market/static_registry_repository_test.dart b/test/core/market/static_registry_repository_test.dart index 6aa9eb63..ca20b172 100644 --- a/test/core/market/static_registry_repository_test.dart +++ b/test/core/market/static_registry_repository_test.dart @@ -79,6 +79,18 @@ void main() { expect(ids(decoded, host: '0.5.0'), ['new.only', 'any']); }); + test('hides an extension in a manifest format this Querya cannot read', () { + final decoded = { + 'schema': 1, + 'extensions': [ + _entry('fmt.current')..['manifestVersion'] = 1, + _entry('fmt.absent'), + _entry('fmt.future')..['manifestVersion'] = 99, + ], + }; + expect(ids(decoded), ['fmt.current', 'fmt.absent']); + }); + test('keeps an engines range it cannot read (the host does not enforce it)', () { expect(ids({'schema': 1, 'extensions': [_entry('x', engines: '*')]}), ['x']); }); From 73dae510449c089a943a5bc178afda3f64937e9b Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Fri, 16 Oct 2026 23:00:22 +0300 Subject: [PATCH 2/5] docs: manifestVersion in the tester's manifest check --- docs/extension-testing.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/extension-testing.md b/docs/extension-testing.md index e1c3789d..ffe24895 100644 --- a/docs/extension-testing.md +++ b/docs/extension-testing.md @@ -8,7 +8,8 @@ server, Flutter or the Querya Desktop source tree. ## What it checks 1. **`manifest.json`** — `id`, `name`, `version`, `type: "database_driver"`, - `main`, `engines.querya_desktop` (against the emulated host version), + `main`, `engines.querya_desktop` and `manifestVersion` (both against the + emulated host version; see [extension-registry.md](extension-registry.md#versions-what-is-checked-against-what)), `contributions.drivers[]` (`driverId`, `displayName`). Every problem is reported with its JSON path. 2. **Launch** — the `main` entry point exists and starts. From 37436d01297e8557d5ef030d718160ddd0e6a358 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Fri, 16 Oct 2026 23:01:22 +0300 Subject: [PATCH 3/5] test: the template manifest sets manifestVersion 1 --- test/core/market/template_manifest_policy_test.dart | 3 +++ 1 file changed, 3 insertions(+) diff --git a/test/core/market/template_manifest_policy_test.dart b/test/core/market/template_manifest_policy_test.dart index 89a2d0ae..09f6f7e6 100644 --- a/test/core/market/template_manifest_policy_test.dart +++ b/test/core/market/template_manifest_policy_test.dart @@ -10,6 +10,7 @@ import 'package:querya_desktop/core/extensions/sandbox/sandbox_policy.dart'; /// it, so this pins the template to the app's sandbox policy. const _templateManifest = ''' { + "manifestVersion": 1, "id": "example.redis-template", "name": "Example driver (template)", "version": "0.1.0", @@ -44,6 +45,8 @@ void main() { final manifest = ExtensionManifest.fromJson( jsonDecode(_templateManifest) as Map, ); + expect(manifest.manifestVersion, 1); + expect(ExtensionSupport.manifestFormatProblem(manifest), isNull); expect(SandboxPolicy.validate(manifest), isEmpty); expect(ExtensionSupport.isPreviewOnlyManifest(manifest), isFalse); }); From fead48b8ddaf2a3aeb0d7dbdd7036254a47f3868 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Fri, 16 Oct 2026 23:02:43 +0300 Subject: [PATCH 4/5] docs: the extension manifest reference and how the manifest format changes --- docs/README.md | 1 + docs/extension-manifest.md | 130 +++++++++++++++++++++++++++++++++++++ docs/extension-registry.md | 2 + docs/extension-testing.md | 2 + 4 files changed, 135 insertions(+) create mode 100644 docs/extension-manifest.md diff --git a/docs/README.md b/docs/README.md index 808f2568..8d682b36 100644 --- a/docs/README.md +++ b/docs/README.md @@ -9,6 +9,7 @@ Index of Querya Desktop documentation, grouped by audience. - [Security / local data](security.md) — where metadata and secrets are stored. - [MCP server](mcp-server.md) — let Claude Desktop, Cursor, VS Code and other AI clients read shared connections (read-only). - [Testing extensions in CI](extension-testing.md) — `querya-ext-tester`, release binaries and the GitHub Action. +- [Extension manifest](extension-manifest.md) — every field of `manifest.json`, the sandbox policy, what checks it, and how the manifest format is versioned (`manifestVersion`). - [Extension registry](extension-registry.md) — from an author's machine to the Marketplace: the template, test databases, the registry's CI and index, and how it follows Querya releases. ## For contributors diff --git a/docs/extension-manifest.md b/docs/extension-manifest.md new file mode 100644 index 00000000..b94e0b85 --- /dev/null +++ b/docs/extension-manifest.md @@ -0,0 +1,130 @@ +# The extension manifest (`manifest.json`) + +Every extension is a folder or zip with a `manifest.json` at its root. This is +the reference for that file: what the fields mean, what the tester and the app +check, and how the format itself is versioned. + +Start from a working one: the [extension template](https://github.com/QueryaHub/extension-template) +has a complete manifest of a database driver. How an extension gets from there +to the Marketplace is in [extension-registry.md](extension-registry.md); how to +test it is in [extension-testing.md](extension-testing.md). + +## A database driver + +```json +{ + "manifestVersion": 1, + "id": "acme.redis-driver", + "name": "Redis driver", + "version": "1.0.0", + "publisher": "Acme", + "type": "database_driver", + "main": "bin/redis-driver", + "engines": { "querya_desktop": ">=0.4.19" }, + "capabilities": { "databaseDriver": true }, + "sandbox": { + "engine": "process", + "permissions": { + "network": { "mode": "connection_host_only", "allow_ssl": false }, + "filesystem": { "scratch_mb": 10, "access": "scratch_only" }, + "resources": { "memory_mb": 128, "max_open_files": 32, "timeout_seconds": 60 } + } + }, + "contributions": { + "drivers": [ + { + "driverId": "acme-redis", + "displayName": "Redis (Acme)", + "defaultPort": 6379, + "connectionFormSchema": "assets/connection_form.json" + } + ] + } +} +``` + +## Fields + +| Field | Required | Meaning | +|-------|----------|---------| +| `manifestVersion` | no | The format of this file: a whole number, **1 when absent**. See [Versioning](#versioning-of-the-manifest-format) | +| `id` | yes | `publisher.name` in lowercase, digits and dashes. The install folder, the registry record (`extensions/.json`) and everything else key on it | +| `name` | yes | Shown in the Marketplace and the Extensions list | +| `version` | yes | The extension's own version, `x.y.z`. The registry requires it to equal the record's | +| `publisher` | no | Shown as the author (`author` is read when `publisher` is missing) | +| `type` | yes | `database_driver`, `theme` or `script`. Only `database_driver` can use the OS process sandbox, and only it is run by the tester | +| `main` | drivers | The executable, relative to the root. Must be a file in the archive | +| `engines.querya_desktop` | yes | The Querya Desktop versions it runs on: `>=x.y.z` or `^x.y.z`. The app hides the extension outside the range; the tester warns when the emulated host is outside it | +| `description`, `icon`, `homepage`, `license`, `tags`, `preview` | no | Marketplace listing. `icon` is a path inside the archive | +| `capabilities` | no | `databaseDriver`, `sduiForms` flags | +| `sandbox` | drivers | What the driver is allowed to do; see below | +| `contributions` | drivers | `drivers[]` (`driverId`, `displayName`, `defaultPort`, `connectionFormSchema`, `icon`) and `commands[]` (`id`, `title`, `category`, `aliases`; commands need Querya Desktop 0.4.16 or newer). `contributes` is accepted as an alias | + +Fields the marketplace adds (`downloadUrl`, `sha256`) are not written by the +author: the registry builds them from the release asset. + +## The sandbox block + +A database driver runs as its own process inside an OS sandbox (`bubblewrap` on +Linux, `sandbox-exec` on macOS). It declares what it needs, and the app refuses +anything beyond the policy: + +| Setting | Allowed | Notes | +|---------|---------|-------| +| `engine` | `process` | `wasm` and `quickjs` are reserved for embedded scripts | +| `permissions.network.mode` | `connection_host_only` or `none` | Sockets only to the host the user configured. `allow_ssl` says it uses TLS | +| `permissions.filesystem` | `access: "scratch_only"`, `scratch_mb` up to 100 | The only writable place is a scratch directory | +| `permissions.resources` | `memory_mb` up to 512, `max_open_files` up to 64 | `timeout_seconds` is optional | + +A driver without a sandbox block, or with one the policy rejects, is shown in the +Marketplace as a **Preview** listing and cannot be installed. + +## What checks the manifest + +| Where | What it checks | +|-------|----------------| +| `querya-ext-tester` (the `manifest` step) | Required fields, `type`, `main`, `engines` against `--target-version`, `manifestVersion` against that host, `contributions.drivers[]`. Every problem comes with its JSON path | +| The registry's CI (`registry.py check`) | `manifest.json` at the archive root, `id` and `version` equal the record's, `engines.querya_desktop` present, a driver's `main` is in the archive, `manifestVersion` is a format Querya defines. All platform archives of a record agree on `id`, `version`, `name`, `type`, `engines` and `manifestVersion` | +| The app, when it lists, installs or loads | The sandbox policy, the preview rule, `manifestVersion` not newer than it reads, the driver package (`main` exists) | + +## Versioning of the manifest format + +Three different versions meet in the file: + +| Field | Versions | Who raises it | +|-------|----------|---------------| +| `version` | the extension | the author, for each release | +| `engines.querya_desktop` | the hosts the author tested | the author | +| `manifestVersion` | **the format of `manifest.json`** | Querya, when the format changes | + +`manifestVersion` is **1 when it is absent**, so every manifest written so far is +format 1 and nothing has to be changed. Writing `"manifestVersion": 1` is +optional but makes the format explicit (the template does). + +An older Querya Desktop must not guess what a newer manifest means, so one in a +format it does not read is refused outright: the tester reports an error for the +host it emulates, the Marketplace does not list it, and the installer and the +local loader say to update Querya Desktop. A format that a host reads is read +completely. + +### When the format changes + +Keep a change **additive** when it can be: a new optional field that an older +host ignores safely needs no new `manifestVersion`, only a line in this document +and, if the tester should warn about it, a rule in the validator for the hosts +that know it. Raise the format only for a change an older host would misread: a +field that changes the meaning of an existing one, a removed or renamed field, a +stricter requirement. + +To add format *N*: + +1. `ExtensionManifest.currentManifestVersion` → *N* (`lib/core/extensions/models/extension_manifest.dart`). +2. `ExtensionProtocolProfile._manifestFormats`: add `(N, )`. +3. Teach the reading code (`ExtensionManifest.fromJson` and what uses it) the new shape, keeping format 1 working. +4. Add the rule to `validateExtensionManifest`, gated on `profile.manifestVersion >= N` where it only applies to the new format. +5. `QueryaHub/extensions`: `MAX_MANIFEST_VERSION` in `scripts/registry.py` → *N*, with tests. +6. Update this document, the [extension template](https://github.com/QueryaHub/extension-template) if its manifest should use the new format, and the CHANGELOG. +7. Tests: the validator on a host before and after the release, the support check, the local registry skip, the registry reader. + +Authors then opt in with `"manifestVersion": N` once the hosts they support +(`engines`) all read it. diff --git a/docs/extension-registry.md b/docs/extension-registry.md index ce32be23..40b6dabc 100644 --- a/docs/extension-registry.md +++ b/docs/extension-registry.md @@ -42,6 +42,8 @@ checked (hash, archive, manifest) but not run. ## Versions: what is checked against what +The whole manifest is described in [extension-manifest.md](extension-manifest.md); this is the part about versions. + Three different versions meet in `manifest.json`: | Field | What it versions | Checked | diff --git a/docs/extension-testing.md b/docs/extension-testing.md index ffe24895..2ac27725 100644 --- a/docs/extension-testing.md +++ b/docs/extension-testing.md @@ -179,6 +179,8 @@ the container with `--security-opt seccomp=unconfined` (and, on hosts that restrict namespaces with AppArmor, `--security-opt apparmor=unconfined`), or run the tester directly on the CI runner with the action below. +The fields of `manifest.json` are in [extension-manifest.md](extension-manifest.md). + Starting a new extension? The [extension template](https://github.com/QueryaHub/extension-template) has all of this wired up, and [extension-registry.md](extension-registry.md) shows how a tested extension gets into the Marketplace. From 71e38481a9ad0db9c3a6d82e47ab012329da2ec5 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Fri, 16 Oct 2026 23:09:22 +0300 Subject: [PATCH 5/5] test: the manifestVersion check is about its own path only --- .../harness/extension_manifest_validator_test.dart | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/test/core/extensions/harness/extension_manifest_validator_test.dart b/test/core/extensions/harness/extension_manifest_validator_test.dart index 5509e712..30d76549 100644 --- a/test/core/extensions/harness/extension_manifest_validator_test.dart +++ b/test/core/extensions/harness/extension_manifest_validator_test.dart @@ -113,7 +113,13 @@ void main() { test('format 1 is fine on every host that has the protocol', () { final m = _valid()..['manifestVersion'] = 1; for (final host in ['0.4.11', '0.4.18', '0.5.0']) { - expect(_check(m, host), isEmpty, reason: host); + // Other diagnostics are fine here (the engines range of _valid() is + // ^0.4.11, which warns on 0.5.0); only manifestVersion must be clean. + expect( + _check(m, host).where((d) => d.path == 'manifestVersion'), + isEmpty, + reason: host, + ); } });