diff --git a/CHANGELOG.md b/CHANGELOG.md index 026e83b..23466c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,95 @@ # Changelog +## [0.38.2.0] — 2026-03-25 + +### Summary + +Library Packages. Reusable Starlark libraries that extensions can declare +as dependencies and load at runtime via `lib.require()`. Libraries export +functions, run with their own permission context, and are protected from +uninstall while active consumers exist. Includes a `test-tool` admin +endpoint for invoking extension tool calls without the AI chat loop. + +### Added + +- **Schema:** `ext_dependencies` table with `(consumer_id, library_id)` + composite PK, `version_spec` and `resolved_ver` columns. SQLite + migration also rebuilds `packages` table to add `'library'` to the + type CHECK constraint (SQLite cannot ALTER constraints). + (`server/database/migrations/023_ext_dependencies.sql`, + `server/database/migrations/sqlite/023_ext_dependencies.sql`) +- **Model:** `ExtDependency` struct with `ConsumerID`, `LibraryID`, + `VersionSpec`, `ResolvedVer`. + (`server/models/ext_dependency.go`) +- **Store:** `DependencyStore` interface — `Create`, `DeleteByConsumer`, + `ListByConsumer`, `ListByLibrary`, `ListAll`. Postgres and SQLite + implementations. + (`server/store/interfaces.go`, `postgres/ext_dependency.go`, + `sqlite/ext_dependency.go`) +- **Handlers:** Dependency admin endpoints: + - `GET /admin/packages/:id/dependencies` — list consumer's deps + - `GET /admin/packages/:id/consumers` — list library's consumers + - `GET /admin/dependencies` — full dependency graph + (`server/handlers/dependencies.go`) +- **Test-tool endpoint:** `POST /admin/packages/:id/test-tool` — invokes + a starlark extension's `on_tool_call` entry point directly for testing. + (`server/handlers/packages.go`) +- **Starlark module:** `lib.require(id)` — loads a declared library + dependency, executes its script with library-scoped permissions, + extracts manifest exports, freezes into a cached struct. Per-invocation + cache and cycle detection. + (`server/sandbox/lib_module.go`) +- **Install validation:** Library packages require `exports` array, + must not have `tools`, `pipes`, or `route`. Consumer `dependencies` + map validated against installed active libraries. Dependency records + created on install, cleaned up on consumer uninstall. Libraries with + active consumers return 409 on delete. + (`server/handlers/packages.go`) +- **SDK:** `sw.api.admin.packages.dependencies(id)`, + `sw.api.admin.packages.consumers(id)` domain methods. + (`src/js/sw/sdk/api-domains.js`) +- **ICD:** Extension dependencies section with all endpoint specs. + (`docs/ICD/extensions.md`) +- **OpenAPI:** Dependency endpoint schemas and `test-tool` endpoint. + (`server/static/openapi.yaml`) +- **SDK test runner:** `dependencies` domain — 8 tests covering library + install, dependency creation, consumer listing, uninstall protection, + cleanup, and library deletion. + (`packages/sdk-test-runner/js/domains/dependencies.js`) + +### Changed + +- **Runner:** `ExecPackage` creates a `libContext` per invocation and + passes it through `buildModulesWithLibCtx`. The `lib` module is always + injected (no permission required). Multi-file `packageLoader` reused + for library scripts. + (`server/sandbox/runner.go`) +- **Packages handler:** Install flow extended with dependency resolution + and `ext_dependencies` record creation. Delete flow checks for active + consumers before allowing library removal. + (`server/handlers/packages.go`) + +### Design Notes + +- `lib.require()` was originally `lib.load()` but `load` is a reserved + keyword in Starlark (used for `load("module.star", "symbol")`). Renamed + to `require` to avoid parser conflicts. +- Libraries run with their **own** permission context, not the consumer's. + A consumer without `api.http` can still call a library that uses + `http.get()` — the library's permissions are evaluated independently. +- The `test-tool` endpoint enables E2E testing of Starlark extensions + without a configured LLM provider. + +### Files + +- **8 created:** `lib_module.go`, `dependencies.go`, `ext_dependency.go` + (model), `ext_dependency.go` (pg), `ext_dependency.go` (sqlite), + `023_ext_dependencies.sql` (×2), `dependencies.js` (SDK runner) +- **11 modified:** `packages.go`, `runner.go`, `main.go`, `interfaces.go`, + `stores.go` (×2), `api-domains.js`, `openapi.yaml`, `extensions.md`, + `packages.js`, `main.js` (SDK runner) +- **+1,141 lines** (737 new files + 404 modified) + ## [0.38.1.0] — 2026-03-25 ### Summary diff --git a/VERSION b/VERSION index 5a0aaa6..dcc5e4a 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.37.19.0 +0.38.2.0 diff --git a/docs/ICD/extensions.md b/docs/ICD/extensions.md index 22b1355..3be8038 100644 --- a/docs/ICD/extensions.md +++ b/docs/ICD/extensions.md @@ -414,6 +414,50 @@ config fields with secrets decrypted. --- +### Library Packages (v0.38.2) + +Library packages (`type: "library"`) export Starlark functions for other +packages to consume. Libraries run with their own permission context, +isolating consumers from implementation details. + +**Manifest fields:** +- `type: "library"` — required +- `tier: "starlark"` — required (libraries are always starlark-tier) +- `exports: ["fn_a", "fn_b"]` — required, names of globals to expose +- `permissions: [...]` — the library's own permissions (not inherited by consumers) +- `dependencies: {"other-lib": ">=1.0.0"}` — optional, libraries can depend on other libraries + +**Restrictions:** Libraries cannot have `tools`, `pipes`, or a `route`. + +**Consumer usage (Starlark):** + +Consumers declare dependencies in their manifest: +```json +{ "dependencies": { "gitea-client": ">=1.0.0" } } +``` + +Then load at runtime: +```python +gitea = lib.load("gitea-client") +repos = gitea.get_repos(conn) +``` + +`lib.load()` validates the dependency record, loads the library's script +with the library's own permissions, extracts the declared exports, and +returns them as a frozen struct. Results are cached per invocation. + +**Admin endpoints:** + +| Method | Path | Description | +|--------|------|-------------| +| GET | `/api/v1/admin/packages/:id/dependencies` | Libraries this package depends on | +| GET | `/api/v1/admin/packages/:id/consumers` | Packages that depend on this library | +| GET | `/api/v1/admin/dependencies` | Full dependency graph | + +**Uninstall protection:** Libraries with active consumers return 409 on delete. + +--- + ### Builtin Seeding On startup, `SeedBuiltinExtensions()` scans `extensions/builtin/` diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 7537cb5..6f05fa6 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -281,7 +281,7 @@ See design docs for full specifications. |---------|---------|------------| | v0.38.0 ✅ | Multi-file Starlark | [DESIGN-MULTI-FILE-STARLARK.md](DESIGN-MULTI-FILE-STARLARK.md) — `load()` support, disk-based scripts, package-scoped loader. Prerequisite for libraries. 5 backend changesets. | | v0.38.1 ✅ | Extension Connections | [DESIGN-EXT-CONNECTIONS-LIBRARIES.md](DESIGN-EXT-CONNECTIONS-LIBRARIES.md) Part 1 — `ext_connections` table, scoped CRUD (personal → team → global resolution), `connections` Starlark module, 3 management UIs. | -| v0.38.2 | Library Packages | Part 2 — `library` package type, `ext_dependencies` table, `lib.load()` with per-library permission context, dependency resolution, uninstall protection. | +| v0.38.2 ✅ | Library Packages | Part 2 — `library` package type, `ext_dependencies` table, `lib.require()` with per-library permission context, dependency resolution, uninstall protection, `test-tool` admin endpoint. 8+3 SDK runner tests. | | v0.38.3 | Extension Config Sections | Manifest-driven `config_section` — packages declare a Preact component that the Settings/Admin surfaces discover and lazy-load as a nav section. Enables headless extensions (no surface) and libraries to own their configuration UX. Libraries can provide rich config for their connection types (OAuth flows, test buttons, pickers). | | v0.38.4 | Full Composition | Part 3 — Libraries declare connection types for consumers, reference `gitea-client` library. | | v0.38.5 | Git-board Rewrite | Capstone validation — rewrite git-board as multi-package, multi-platform extension (Gitea/GitLab/GitHub). Proves the entire extension architecture works end-to-end. Reference implementation for future extension authors. | diff --git a/packages/sdk-test-runner/js/domains/dependencies.js b/packages/sdk-test-runner/js/domains/dependencies.js new file mode 100644 index 0000000..f12aa8f --- /dev/null +++ b/packages/sdk-test-runner/js/domains/dependencies.js @@ -0,0 +1,219 @@ +/** + * SDK Test Runner — Domain: dependencies (v0.38.2) + * + * Tests library package install, dependency creation, uninstall + * protection, and admin dependency visibility endpoints. + * Requires admin role. + */ +(function () { + 'use strict'; + var T = window.SDKR; + if (!T) return; + + // ── Minimal zip builder (same as packages.js) ─────────── + function buildZip(entries) { + var localHeaders = []; + var centralHeaders = []; + var offset = 0; + entries.forEach(function (entry) { + var nameBytes = new TextEncoder().encode(entry.name); + var dataBytes = new TextEncoder().encode(entry.content); + var local = new Uint8Array(30 + nameBytes.length + dataBytes.length); + var dv = new DataView(local.buffer); + dv.setUint32(0, 0x04034b50, true); + dv.setUint16(4, 20, true); + dv.setUint16(8, 0, true); + dv.setUint16(10, 0, true); + dv.setUint16(12, 0, true); + dv.setUint32(14, crc32(dataBytes), true); + dv.setUint32(18, dataBytes.length, true); + dv.setUint32(22, dataBytes.length, true); + dv.setUint16(26, nameBytes.length, true); + dv.setUint16(28, 0, true); + local.set(nameBytes, 30); + local.set(dataBytes, 30 + nameBytes.length); + localHeaders.push(local); + var central = new Uint8Array(46 + nameBytes.length); + var cdv = new DataView(central.buffer); + cdv.setUint32(0, 0x02014b50, true); + cdv.setUint16(4, 20, true); + cdv.setUint16(6, 20, true); + cdv.setUint16(28, nameBytes.length, true); + cdv.setUint32(16, crc32(dataBytes), true); + cdv.setUint32(20, dataBytes.length, true); + cdv.setUint32(24, dataBytes.length, true); + cdv.setUint32(42, offset, true); + central.set(nameBytes, 46); + centralHeaders.push(central); + offset += local.length; + }); + var centralSize = centralHeaders.reduce(function (s, c) { return s + c.length; }, 0); + var eocd = new Uint8Array(22); + var edv = new DataView(eocd.buffer); + edv.setUint32(0, 0x06054b50, true); + edv.setUint16(8, entries.length, true); + edv.setUint16(10, entries.length, true); + edv.setUint32(12, centralSize, true); + edv.setUint32(16, offset, true); + var result = new Uint8Array(offset + centralSize + 22); + var pos = 0; + localHeaders.forEach(function (h) { result.set(h, pos); pos += h.length; }); + centralHeaders.forEach(function (h) { result.set(h, pos); pos += h.length; }); + result.set(eocd, pos); + return new Blob([result], { type: 'application/zip' }); + } + + var crcTable = null; + function crc32(bytes) { + if (!crcTable) { + crcTable = new Uint32Array(256); + for (var n = 0; n < 256; n++) { + var c = n; + for (var k = 0; k < 8; k++) c = (c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1); + crcTable[n] = c; + } + } + var crc = 0xFFFFFFFF; + for (var i = 0; i < bytes.length; i++) crc = crcTable[(crc ^ bytes[i]) & 0xFF] ^ (crc >>> 8); + return (crc ^ 0xFFFFFFFF) >>> 0; + } + + function makePkgFile(manifest, extraFiles) { + var entries = [{ name: 'manifest.json', content: JSON.stringify(manifest) }]; + if (extraFiles) entries = entries.concat(extraFiles); + var blob = buildZip(entries); + return new File([blob], manifest.id + '.pkg', { type: 'application/zip' }); + } + + // ── Tests ──────────────────────────────────────────────── + + T.registerDomain('dependencies', async function () { + + if (!window.sw || !sw.isAdmin) { + await T.test('dependencies', 'guard', 'skip — not admin', async function () { + T.assert(true, 'skipped (user is not admin)'); + }); + return; + } + + var libId = 'sdk-test-lib-' + Date.now(); + var consumerId = 'sdk-test-consumer-' + Date.now(); + + // ── 1. Install library ──────────────────────── + + await T.test('dependencies', 'install-library', 'install library package', async function () { + var manifest = { + id: libId, + title: 'SDK Test Library', + type: 'library', + tier: 'starlark', + version: '1.0.0', + exports: ['get_data', 'transform'], + permissions: [] + }; + var file = makePkgFile(manifest, [ + { name: 'script.star', content: 'def get_data():\n return {"ok": True}\n\ndef transform(x):\n return x\n' } + ]); + var r = await sw.api.admin.packages.install(file); + T.assert(r && r.id === libId, 'library should install'); + T.assert(r.type === 'library', 'type should be library'); + }); + + T.cleanup.push(async function () { + try { await sw.api.admin.packages.del(libId); } catch (_) {} + }); + + // ── 2. Verify library in package list ──────────────── + + await T.test('dependencies', 'library-in-list', 'library appears in package list', async function () { + var r = await sw.api.admin.packages.get(libId); + T.assert(r && r.type === 'library', 'should be type library'); + T.assert(r.version === '1.0.0', 'version should match'); + }); + + // ── 3. Install consumer with dependency ──────────────── + + await T.test('dependencies', 'install-consumer', 'install consumer with dependency on library', async function () { + var manifest = { + id: consumerId, + title: 'SDK Test Consumer', + type: 'extension', + tier: 'starlark', + version: '0.1.0', + tools: [{ name: 'noop', description: 'test', parameters: { type: 'object', properties: {} } }], + dependencies: {} + }; + manifest.dependencies[libId] = '>=1.0.0'; + var file = makePkgFile(manifest, [ + { name: 'script.star', content: 'def on_tool_call(call):\n return {"ok": True}\n' } + ]); + var r = await sw.api.admin.packages.install(file); + T.assert(r && r.id === consumerId, 'consumer should install'); + }); + + T.cleanup.push(async function () { + try { await sw.api.admin.packages.del(consumerId); } catch (_) {} + }); + + // ── 4. Verify dependency records ──────────────── + + await T.test('dependencies', 'list-dependencies', 'admin can list consumer dependencies', async function () { + var r = await sw.api.admin.packages.dependencies(consumerId); + T.assert(r && r.data, 'should return data'); + T.assert(r.data.length === 1, 'should have 1 dependency'); + T.assert(r.data[0].library_id === libId, 'library_id should match'); + T.assert(r.data[0].version_spec === '>=1.0.0', 'version_spec should match'); + }); + + // ── 5. Verify consumer records ──────────────── + + await T.test('dependencies', 'list-consumers', 'admin can list library consumers', async function () { + var r = await sw.api.admin.packages.consumers(libId); + T.assert(r && r.data, 'should return data'); + T.assert(r.data.length === 1, 'should have 1 consumer'); + T.assert(r.data[0].consumer_id === consumerId, 'consumer_id should match'); + }); + + // ── 6. Uninstall protection ──────────────── + + await T.test('dependencies', 'uninstall-protection', 'cannot uninstall library with active consumers', async function () { + try { + await sw.api.admin.packages.del(libId); + T.assert(false, 'should have been rejected'); + } catch (e) { + var msg = (e && e.message) || String(e); + T.assert( + msg.indexOf('409') >= 0 || msg.indexOf('active consumers') >= 0 || msg.indexOf('Conflict') >= 0, + 'expected 409 conflict, got: ' + msg + ); + } + }); + + // ── 7. Uninstall consumer, verify cleanup ──────────────── + + await T.test('dependencies', 'uninstall-consumer', 'uninstall consumer cleans up dependencies', async function () { + await sw.api.admin.packages.del(consumerId); + consumerId = null; // disable cleanup + + var r = await sw.api.admin.packages.consumers(libId); + T.assert(r && r.data, 'should return data'); + T.assert(r.data.length === 0, 'library should have 0 consumers after uninstall'); + }); + + // ── 8. Library uninstall succeeds ──────────────── + + await T.test('dependencies', 'uninstall-library', 'library can be uninstalled after consumers removed', async function () { + await sw.api.admin.packages.del(libId); + libId = null; // disable cleanup + + // Verify it's gone + try { + await sw.api.admin.packages.get(libId); + T.assert(false, 'should be deleted'); + } catch (e) { + T.assert(true, 'library deleted successfully'); + } + }); + + }); +})(); diff --git a/packages/sdk-test-runner/js/domains/packages.js b/packages/sdk-test-runner/js/domains/packages.js index 766cfbf..57107fd 100644 --- a/packages/sdk-test-runner/js/domains/packages.js +++ b/packages/sdk-test-runner/js/domains/packages.js @@ -233,5 +233,60 @@ ); } }); + + // ── v0.38.2: Library package validation ── + + await T.test('packages', 'library-no-exports', 'library without exports → 400', async function () { + var manifest = makeManifest({ type: 'library', tier: 'starlark' }); + delete manifest.tools; + var file = makePkgFile(manifest, [ + { name: 'script.star', content: 'x = 1\n' } + ]); + try { + await sw.api.admin.packages.install(file); + T.assert(false, 'should have rejected library without exports'); + } catch (e) { + var msg = (e && e.message) || String(e); + T.assert( + msg.indexOf('400') >= 0 || msg.indexOf('exports') >= 0 || msg.indexOf('error') >= 0, + 'expected 400 for missing exports' + ); + } + }); + + await T.test('packages', 'library-with-tools', 'library with tools → 400', async function () { + var manifest = makeManifest({ type: 'library', tier: 'starlark', exports: ['fn1'] }); + // manifest already has tools from makeManifest + var file = makePkgFile(manifest, [ + { name: 'script.star', content: 'def fn1():\n return 1\n' } + ]); + try { + await sw.api.admin.packages.install(file); + T.assert(false, 'should have rejected library with tools'); + } catch (e) { + var msg = (e && e.message) || String(e); + T.assert( + msg.indexOf('400') >= 0 || msg.indexOf('tools') >= 0 || msg.indexOf('error') >= 0, + 'expected 400 for library with tools' + ); + } + }); + + await T.test('packages', 'consumer-bad-dep', 'consumer with non-existent dependency → 400', async function () { + var manifest = makeManifest({ dependencies: { 'nonexistent-lib-xyz': '>=1.0.0' } }); + var file = makePkgFile(manifest, [ + { name: 'script.star', content: 'def on_tool_call(call):\n return {"ok": True}\n' } + ]); + try { + await sw.api.admin.packages.install(file); + T.assert(false, 'should have rejected non-existent dependency'); + } catch (e) { + var msg = (e && e.message) || String(e); + T.assert( + msg.indexOf('400') >= 0 || msg.indexOf('not found') >= 0 || msg.indexOf('error') >= 0, + 'expected 400 for non-existent dependency' + ); + } + }); }); })(); diff --git a/packages/sdk-test-runner/js/main.js b/packages/sdk-test-runner/js/main.js index 3fa1542..284f3b3 100644 --- a/packages/sdk-test-runner/js/main.js +++ b/packages/sdk-test-runner/js/main.js @@ -76,6 +76,7 @@ 'domains/admin.js', 'domains/packages.js', 'domains/connections.js', + 'domains/dependencies.js', // UI (must come last) 'ui.js' ]; diff --git a/server/database/migrations/023_ext_dependencies.sql b/server/database/migrations/023_ext_dependencies.sql new file mode 100644 index 0000000..0d8c2f0 --- /dev/null +++ b/server/database/migrations/023_ext_dependencies.sql @@ -0,0 +1,31 @@ +-- ========================================== +-- Chat Switchboard — 023 Extension Dependencies +-- ========================================== +-- v0.38.2: Library package dependency tracking. +-- Consumers declare which libraries they depend on. +-- Libraries with active consumers cannot be uninstalled. +-- ========================================== + +-- Add 'library' to the packages type constraint. +ALTER TABLE packages DROP CONSTRAINT IF EXISTS packages_type_check; +ALTER TABLE packages ADD CONSTRAINT packages_type_check + CHECK (type IN ('surface', 'extension', 'full', 'workflow', 'library')); + +CREATE TABLE IF NOT EXISTS ext_dependencies ( + consumer_id TEXT NOT NULL, + library_id TEXT NOT NULL, + version_spec TEXT NOT NULL DEFAULT '>=0.0.0', + resolved_ver TEXT NOT NULL DEFAULT '0.0.0', + PRIMARY KEY (consumer_id, library_id), + FOREIGN KEY (consumer_id) REFERENCES packages(id) ON DELETE CASCADE, + FOREIGN KEY (library_id) REFERENCES packages(id) ON DELETE RESTRICT +); + +CREATE INDEX IF NOT EXISTS idx_ext_deps_consumer ON ext_dependencies(consumer_id); +CREATE INDEX IF NOT EXISTS idx_ext_deps_library ON ext_dependencies(library_id); + +COMMENT ON TABLE ext_dependencies IS 'v0.38.2: Package dependency graph — consumers → libraries'; +COMMENT ON COLUMN ext_dependencies.consumer_id IS 'Package that depends on the library'; +COMMENT ON COLUMN ext_dependencies.library_id IS 'Library package being depended upon'; +COMMENT ON COLUMN ext_dependencies.version_spec IS 'Semver version spec declared by consumer (e.g. ">=1.0.0")'; +COMMENT ON COLUMN ext_dependencies.resolved_ver IS 'Actual library version at dependency creation time'; diff --git a/server/database/migrations/sqlite/023_ext_dependencies.sql b/server/database/migrations/sqlite/023_ext_dependencies.sql new file mode 100644 index 0000000..7621c4f --- /dev/null +++ b/server/database/migrations/sqlite/023_ext_dependencies.sql @@ -0,0 +1,62 @@ +-- ========================================== +-- Chat Switchboard — 023 Extension Dependencies +-- ========================================== +-- v0.38.2: Library package dependency tracking. +-- SQLite version. +-- +-- Also adds 'library' to the packages type CHECK constraint. +-- SQLite doesn't support ALTER CONSTRAINT, so we rebuild the table. +-- ========================================== + +-- Step 1: Rebuild packages table with updated CHECK constraint. +CREATE TABLE IF NOT EXISTS packages_new ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + type TEXT NOT NULL DEFAULT 'surface' + CHECK (type IN ('surface', 'extension', 'full', 'workflow', 'library')), + version TEXT NOT NULL DEFAULT '0.0.0', + description TEXT NOT NULL DEFAULT '', + author TEXT NOT NULL DEFAULT '', + tier TEXT NOT NULL DEFAULT 'browser' + CHECK (tier IN ('browser', 'starlark', 'sidecar')), + is_system INTEGER NOT NULL DEFAULT 0, + scope TEXT NOT NULL DEFAULT 'global' + CHECK (scope IN ('global', 'team', 'personal')), + team_id TEXT REFERENCES teams(id) ON DELETE CASCADE, + installed_by TEXT REFERENCES users(id) ON DELETE SET NULL, + manifest TEXT NOT NULL DEFAULT '{}', + enabled INTEGER NOT NULL DEFAULT 1, + status TEXT NOT NULL DEFAULT 'active' + CHECK (status IN ('active', 'pending_review', 'suspended')), + schema_version INTEGER NOT NULL DEFAULT 0, + package_settings TEXT NOT NULL DEFAULT '{}', + source TEXT NOT NULL DEFAULT 'core' + CHECK (source IN ('core', 'builtin', 'extension', 'registry')), + installed_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +INSERT INTO packages_new SELECT * FROM packages; +DROP TABLE packages; +ALTER TABLE packages_new RENAME TO packages; + +-- Recreate indexes (dropped with old table) +CREATE INDEX IF NOT EXISTS idx_packages_type ON packages(type); +CREATE INDEX IF NOT EXISTS idx_packages_enabled ON packages(enabled); +CREATE INDEX IF NOT EXISTS idx_packages_team ON packages(team_id); +CREATE INDEX IF NOT EXISTS idx_packages_source ON packages(source); +CREATE INDEX IF NOT EXISTS idx_packages_status ON packages(status); + +-- Step 2: Dependencies table. +CREATE TABLE IF NOT EXISTS ext_dependencies ( + consumer_id TEXT NOT NULL, + library_id TEXT NOT NULL, + version_spec TEXT NOT NULL DEFAULT '>=0.0.0', + resolved_ver TEXT NOT NULL DEFAULT '0.0.0', + PRIMARY KEY (consumer_id, library_id), + FOREIGN KEY (consumer_id) REFERENCES packages(id) ON DELETE CASCADE, + FOREIGN KEY (library_id) REFERENCES packages(id) +); + +CREATE INDEX IF NOT EXISTS idx_ext_deps_consumer ON ext_dependencies(consumer_id); +CREATE INDEX IF NOT EXISTS idx_ext_deps_library ON ext_dependencies(library_id); diff --git a/server/handlers/dependencies.go b/server/handlers/dependencies.go new file mode 100644 index 0000000..e333edf --- /dev/null +++ b/server/handlers/dependencies.go @@ -0,0 +1,69 @@ +package handlers + +import ( + "net/http" + + "github.com/gin-gonic/gin" + + "chat-switchboard/models" +) + +// ── Extension Dependencies (v0.38.2) ────────────────── +// Admin-only read endpoints for dependency graph visibility. +// Dependencies are created/deleted implicitly by InstallPackage/DeletePackage. + +// ListDependencies returns all libraries that a package depends on. +// GET /api/v1/admin/packages/:id/dependencies +func (h *PackageHandler) ListDependencies(c *gin.Context) { + id := c.Param("id") + if h.stores.Dependencies == nil { + c.JSON(http.StatusOK, gin.H{"data": []any{}}) + return + } + deps, err := h.stores.Dependencies.ListByConsumer(c.Request.Context(), id) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list dependencies"}) + return + } + if deps == nil { + deps = []models.ExtDependency{} + } + c.JSON(http.StatusOK, gin.H{"data": deps}) +} + +// ListConsumers returns all packages that depend on a library. +// GET /api/v1/admin/packages/:id/consumers +func (h *PackageHandler) ListConsumers(c *gin.Context) { + id := c.Param("id") + if h.stores.Dependencies == nil { + c.JSON(http.StatusOK, gin.H{"data": []any{}}) + return + } + deps, err := h.stores.Dependencies.ListByLibrary(c.Request.Context(), id) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list consumers"}) + return + } + if deps == nil { + deps = []models.ExtDependency{} + } + c.JSON(http.StatusOK, gin.H{"data": deps}) +} + +// ListAllDependencies returns the full dependency graph. +// GET /api/v1/admin/dependencies +func (h *PackageHandler) ListAllDependencies(c *gin.Context) { + if h.stores.Dependencies == nil { + c.JSON(http.StatusOK, gin.H{"data": []any{}}) + return + } + deps, err := h.stores.Dependencies.ListAll(c.Request.Context()) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list dependencies"}) + return + } + if deps == nil { + deps = []models.ExtDependency{} + } + c.JSON(http.StatusOK, gin.H{"data": deps}) +} diff --git a/server/handlers/packages.go b/server/handlers/packages.go index e30fbb1..ed5e7c1 100644 --- a/server/handlers/packages.go +++ b/server/handlers/packages.go @@ -13,8 +13,10 @@ import ( "strings" "github.com/gin-gonic/gin" + "go.starlark.net/starlark" "chat-switchboard/database" + "chat-switchboard/models" "chat-switchboard/sandbox" "chat-switchboard/store" ) @@ -29,6 +31,7 @@ type PackageHandler struct { stores store.Stores packagesDir string // e.g. /data/packages — where archives are extracted sandbox *sandbox.Sandbox // v0.30.0: for schema migration scripts + runner *sandbox.Runner // v0.38.2: for test-tool execution } func NewPackageHandler(s store.Stores, packagesDir ...string) *PackageHandler { @@ -44,6 +47,11 @@ func (h *PackageHandler) SetSandbox(sb *sandbox.Sandbox) { h.sandbox = sb } +// SetRunner attaches the Starlark runner for test-tool execution (v0.38.2). +func (h *PackageHandler) SetRunner(r *sandbox.Runner) { + h.runner = r +} + // ListPackages returns all registered packages. // GET /api/v1/admin/packages // GET /api/v1/admin/surfaces (alias) @@ -126,6 +134,20 @@ func (h *PackageHandler) DeletePackage(c *gin.Context) { return } + // v0.38.2: Libraries with active consumers cannot be uninstalled. + if pkg.Type == "library" && h.stores.Dependencies != nil { + hasConsumers, _ := h.stores.Dependencies.HasConsumers(c.Request.Context(), id) + if hasConsumers { + c.JSON(http.StatusConflict, gin.H{"error": "cannot uninstall: library has active consumers"}) + return + } + } + + // v0.38.2: Clean up dependency records when uninstalling a consumer. + if h.stores.Dependencies != nil { + h.stores.Dependencies.DeleteAllForConsumer(c.Request.Context(), id) + } + // v0.29.2: Drop namespaced DB tables before removing the package record. if err := DropExtTables(c.Request.Context(), database.DB, h.stores, id); err != nil { log.Printf("[ext-db] schema drop failed for %s: %v", id, err) @@ -281,8 +303,8 @@ func (h *PackageHandler) InstallPackage(c *gin.Context) { if pkgType == "" { pkgType = "surface" } - if pkgType != "surface" && pkgType != "extension" && pkgType != "full" && pkgType != "workflow" { - c.JSON(http.StatusBadRequest, gin.H{"error": "manifest type must be 'surface', 'extension', 'full', or 'workflow'"}) + if pkgType != "surface" && pkgType != "extension" && pkgType != "full" && pkgType != "workflow" && pkgType != "library" { + c.JSON(http.StatusBadRequest, gin.H{"error": "manifest type must be 'surface', 'extension', 'full', 'workflow', or 'library'"}) return } @@ -323,6 +345,21 @@ func (h *PackageHandler) InstallPackage(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": "workflow packages require a 'workflow_definition' in the manifest"}) return } + case "library": + // v0.38.2: Libraries must declare exports, cannot have tools/pipes/route. + exports, _ := manifest["exports"].([]any) + if len(exports) == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "library packages require an 'exports' array"}) + return + } + if hasTools || hasPipes { + c.JSON(http.StatusBadRequest, gin.H{"error": "library packages cannot have tools or pipes"}) + return + } + if hasRoute { + c.JSON(http.StatusBadRequest, gin.H{"error": "library packages cannot have a route"}) + return + } } // Check for conflicts with core packages @@ -470,6 +507,47 @@ func (h *PackageHandler) InstallPackage(c *gin.Context) { } } + // v0.38.2: Create dependency records from manifest "dependencies" map. + // Libraries must be installed first; consumers declare them. + if deps, ok := manifest["dependencies"].(map[string]any); ok && len(deps) > 0 { + // Clear stale dependency records from a previous install of the same consumer. + if h.stores.Dependencies != nil { + h.stores.Dependencies.DeleteAllForConsumer(c.Request.Context(), pkgID) + } + for libID, vSpec := range deps { + lib, err := h.stores.Packages.Get(c.Request.Context(), libID) + if err != nil || lib == nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "dependency not found: " + libID}) + return + } + if lib.Type != "library" { + c.JSON(http.StatusBadRequest, gin.H{"error": "dependency is not a library: " + libID}) + return + } + if lib.Status != "active" { + c.JSON(http.StatusBadRequest, gin.H{"error": "dependency is not active: " + libID}) + return + } + versionSpec, _ := vSpec.(string) + if versionSpec == "" { + versionSpec = ">=0.0.0" + } + if h.stores.Dependencies != nil { + if err := h.stores.Dependencies.Create(c.Request.Context(), &models.ExtDependency{ + ConsumerID: pkgID, + LibraryID: libID, + VersionSpec: versionSpec, + ResolvedVer: lib.Version, + }); err != nil { + log.Printf("[packages] dependency record failed for %s → %s: %v", pkgID, libID, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create dependency record"}) + return + } + } + } + log.Printf("[packages] %s: %d dependencies recorded", pkgID, len(deps)) + } + c.JSON(http.StatusOK, gin.H{ "id": pkgID, "title": title, @@ -650,3 +728,78 @@ func (h *PackageHandler) ListEnabledSurfaces(c *gin.Context) { } c.JSON(http.StatusOK, gin.H{"data": enabled}) } + +// ── Test Tool (v0.38.2) ───────────────────────── +// POST /admin/packages/:id/test-tool +// Invokes a starlark extension's on_tool_call entry point directly, +// without going through the AI chat loop. Admin-only test harness. + +// TestToolRequest is the JSON body for test-tool invocation. +type TestToolRequest struct { + ToolName string `json:"tool_name" binding:"required"` + Arguments map[string]any `json:"arguments"` +} + +// TestTool invokes a package's on_tool_call entry point for testing. +func (h *PackageHandler) TestTool(c *gin.Context) { + if h.runner == nil { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "starlark runner not configured"}) + return + } + + pkgID := c.Param("id") + pkg, err := h.stores.Packages.Get(c.Request.Context(), pkgID) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "package not found"}) + return + } + if pkg.Tier != "starlark" { + c.JSON(http.StatusBadRequest, gin.H{"error": "test-tool requires tier=starlark"}) + return + } + if !pkg.Enabled { + c.JSON(http.StatusBadRequest, gin.H{"error": "package is disabled"}) + return + } + + var req TestToolRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + // Build a call dict identical to what executeExtensionTool sends. + // Build the arguments as an interface{} map for jsonToStarlark. + var args interface{} + if req.Arguments != nil { + argsJSON, _ := json.Marshal(req.Arguments) + _ = json.Unmarshal(argsJSON, &args) + } + + callDict := starlark.NewDict(3) + _ = callDict.SetKey(starlark.String("tool_name"), starlark.String(req.ToolName)) + _ = callDict.SetKey(starlark.String("tool_call_id"), starlark.String("test-"+pkgID)) + _ = callDict.SetKey(starlark.String("arguments"), jsonToStarlark(args)) + + userID, _ := c.Get("user_id") + rc := &sandbox.RunContext{UserID: fmt.Sprintf("%v", userID)} + + val, output, err := h.runner.CallEntryPoint( + c.Request.Context(), pkg, "on_tool_call", + starlark.Tuple{callDict}, nil, rc, + ) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "error": err.Error(), + "output": output, + }) + return + } + + // Serialize the Starlark return value to Go types. + result := starlarkValueToGo(val) + c.JSON(http.StatusOK, gin.H{ + "result": result, + "output": output, + }) +} diff --git a/server/main.go b/server/main.go index 4b496f1..8676ba7 100644 --- a/server/main.go +++ b/server/main.go @@ -1357,6 +1357,7 @@ func main() { } pkgAdm := handlers.NewPackageHandler(stores, packagesDir) pkgAdm.SetSandbox(sandbox.New(sandbox.DefaultConfig())) // v0.30.0: schema migrations + pkgAdm.SetRunner(starlarkRunner) // v0.38.2: test-tool // Package registry — must be registered before /packages/:id (v0.30.0) registryH := handlers.NewRegistryHandler(stores, packagesDir, pkgAdm) @@ -1371,6 +1372,10 @@ func main() { admin.DELETE("/packages/:id", pkgAdm.DeletePackage) admin.GET("/packages/:id/settings", pkgAdm.GetPackageSettings) // v0.30.0 admin.PUT("/packages/:id/settings", pkgAdm.UpdatePackageSettings) // v0.30.0 + admin.GET("/packages/:id/dependencies", pkgAdm.ListDependencies) // v0.38.2 + admin.GET("/packages/:id/consumers", pkgAdm.ListConsumers) // v0.38.2 + admin.POST("/packages/:id/test-tool", pkgAdm.TestTool) // v0.38.2 + admin.GET("/dependencies", pkgAdm.ListAllDependencies) // v0.38.2 // Package export (v0.30.0) pkgExport := handlers.NewPackageExportHandler(stores, packagesDir) diff --git a/server/models/ext_dependency.go b/server/models/ext_dependency.go new file mode 100644 index 0000000..214b6bb --- /dev/null +++ b/server/models/ext_dependency.go @@ -0,0 +1,13 @@ +package models + +// ── Extension Dependencies (v0.38.2) ────────────────── + +// ExtDependency records that a consumer package depends on a library package. +// Created at consumer install time from the manifest's "dependencies" map. +// Immutable — update by uninstalling/reinstalling the consumer. +type ExtDependency struct { + ConsumerID string `json:"consumer_id" db:"consumer_id"` + LibraryID string `json:"library_id" db:"library_id"` + VersionSpec string `json:"version_spec" db:"version_spec"` + ResolvedVer string `json:"resolved_ver" db:"resolved_ver"` +} diff --git a/server/sandbox/lib_module.go b/server/sandbox/lib_module.go new file mode 100644 index 0000000..db9a07f --- /dev/null +++ b/server/sandbox/lib_module.go @@ -0,0 +1,155 @@ +// Package sandbox — lib_module.go +// +// v0.38.2: Library loading module for Starlark extensions. +// No permission required — any starlark package can use lib.require(). +// +// Starlark API: +// gitea = lib.require("gitea-client") → struct with library's exports +// +// The loaded library runs with its own permission context, not the +// consumer's. Results are cached per ExecPackage invocation. +package sandbox + +import ( + "context" + "fmt" + "log" + + "go.starlark.net/starlark" + "go.starlark.net/starlarkstruct" + + "chat-switchboard/models" +) + +// libContext carries per-invocation state for lib.require() calls. +// Created once per ExecPackage and threaded through buildModules. +type libContext struct { + cache map[string]starlark.Value // library ID → frozen exports struct + loading map[string]bool // cycle detection +} + +func newLibContext() *libContext { + return &libContext{ + cache: make(map[string]starlark.Value), + loading: make(map[string]bool), + } +} + +// BuildLibModule creates the "lib" module. +// consumerPkgID is the package calling lib.require() — used to validate +// the dependency record exists. +func BuildLibModule(ctx context.Context, r *Runner, consumerPkgID string, rc *RunContext, lc *libContext) *starlarkstruct.Module { + return MakeModule("lib", starlark.StringDict{ + "require": starlark.NewBuiltin("lib.require", func( + thread *starlark.Thread, b *starlark.Builtin, + args starlark.Tuple, kwargs []starlark.Tuple, + ) (starlark.Value, error) { + var libraryID string + if err := starlark.UnpackPositionalArgs(b.Name(), args, kwargs, 1, &libraryID); err != nil { + return nil, err + } + + // 1. Check cache + if cached, ok := lc.cache[libraryID]; ok { + return cached, nil + } + + // 2. Cycle detection + if lc.loading[libraryID] { + return nil, fmt.Errorf("lib.require: circular dependency: %q", libraryID) + } + lc.loading[libraryID] = true + defer func() { delete(lc.loading, libraryID) }() + + // 3. Verify dependency is declared + if r.stores.Dependencies == nil { + return nil, fmt.Errorf("lib.require: dependency store not available") + } + deps, err := r.stores.Dependencies.ListByConsumer(ctx, consumerPkgID) + if err != nil { + return nil, fmt.Errorf("lib.require: failed to check dependencies: %w", err) + } + declared := false + for _, dep := range deps { + if dep.LibraryID == libraryID { + declared = true + break + } + } + if !declared { + return nil, fmt.Errorf("lib.require: package %q does not declare dependency on %q", consumerPkgID, libraryID) + } + + // 4. Load library's PackageRegistration + libPkg, err := r.stores.Packages.Get(ctx, libraryID) + if err != nil { + return nil, fmt.Errorf("lib.require: library %q not found: %w", libraryID, err) + } + if libPkg.Type != "library" { + return nil, fmt.Errorf("lib.require: package %q is type %q, not library", libraryID, libPkg.Type) + } + if libPkg.Status != models.PackageStatusActive { + return nil, fmt.Errorf("lib.require: library %q is %s, not active", libraryID, libPkg.Status) + } + if libPkg.Tier != models.ExtTierStarlark { + return nil, fmt.Errorf("lib.require: library %q is tier %s, not starlark", libraryID, libPkg.Tier) + } + + // 5. Build library's module set (library's own permissions) + libModules, err := r.buildModulesWithLibCtx(ctx, libraryID, libPkg.Manifest, rc, lc) + if err != nil { + return nil, fmt.Errorf("lib.require: failed to build modules for library %q: %w", libraryID, err) + } + + // 6. Load library script from disk + libScript, err := r.loadScript(libPkg) + if err != nil { + return nil, fmt.Errorf("lib.require: %w", err) + } + + // 7. Execute with library-scoped loader + libLoader := r.packageLoader(libraryID, libModules) + result, err := r.sandbox.ExecWithLoader(ctx, libraryID+"/script.star", libScript, libModules, libLoader) + if err != nil { + return nil, fmt.Errorf("lib.require: error executing library %q: %w", libraryID, err) + } + + // 8. Extract exports from manifest + exports := extractExports(libPkg.Manifest) + if len(exports) == 0 { + return nil, fmt.Errorf("lib.require: library %q declares no exports", libraryID) + } + + members := make(starlark.StringDict, len(exports)) + for _, name := range exports { + val, ok := result.Globals[name] + if !ok { + return nil, fmt.Errorf("lib.require: library %q does not export %q", libraryID, name) + } + members[name] = val + } + + // 9. Freeze into struct, cache, return + frozen := starlarkstruct.FromStringDict(starlark.String(libraryID), members) + lc.cache[libraryID] = frozen + + log.Printf(" 📦 lib.require: loaded %s (%d exports) for %s", libraryID, len(exports), consumerPkgID) + return frozen, nil + }), + }) +} + +// extractExports reads the "exports" array from a package manifest. +func extractExports(manifest map[string]any) []string { + raw, ok := manifest["exports"].([]any) + if !ok { + return nil + } + exports := make([]string, 0, len(raw)) + for _, v := range raw { + if s, ok := v.(string); ok { + exports = append(exports, s) + } + } + return exports +} diff --git a/server/sandbox/runner.go b/server/sandbox/runner.go index 2e6567a..38c0076 100644 --- a/server/sandbox/runner.go +++ b/server/sandbox/runner.go @@ -13,6 +13,9 @@ // at startup. db.read grants query/view/list_tables; db.write adds // insert/update/delete. If both are granted, db.write wins (superset). // +// v0.38.2: Adds lib.load() for library packages. Libraries run with +// their own permission context. Per-invocation lib cache + cycle detection. +// // The runner is the bridge between the package/permission system // and the sandboxed Starlark interpreter. package sandbox @@ -119,8 +122,11 @@ func (r *Runner) ExecPackage(ctx context.Context, pkg *store.PackageRegistration return nil, err } + // v0.38.2: per-invocation lib context for lib.load() caching + cycle detection + lc := newLibContext() + // Build modules based on granted permissions - modules, err := r.buildModules(ctx, pkg.ID, pkg.Manifest, rc) + modules, err := r.buildModulesWithLibCtx(ctx, pkg.ID, pkg.Manifest, rc, lc) if err != nil { return nil, fmt.Errorf("failed to build modules for %q: %w", pkg.ID, err) } @@ -256,10 +262,17 @@ func (r *Runner) CallEntryPoint(ctx context.Context, pkg *store.PackageRegistrat } // buildModules assembles the module map based on granted permissions. +// Delegates to buildModulesWithLibCtx with a nil lib context (no lib.load support). +func (r *Runner) buildModules(ctx context.Context, packageID string, manifest map[string]any, rc *RunContext) (map[string]starlark.Value, error) { + return r.buildModulesWithLibCtx(ctx, packageID, manifest, rc, nil) +} + +// buildModulesWithLibCtx assembles the module map based on granted permissions. // The manifest is passed through so modules can read their config // (e.g., http module reads network_access, provider reads requires_provider). // The RunContext carries per-invocation state for user-scoped modules. -func (r *Runner) buildModules(ctx context.Context, packageID string, manifest map[string]any, rc *RunContext) (map[string]starlark.Value, error) { +// The libContext enables lib.load() caching and cycle detection (v0.38.2). +func (r *Runner) buildModulesWithLibCtx(ctx context.Context, packageID string, manifest map[string]any, rc *RunContext, lc *libContext) (map[string]starlark.Value, error) { if r.stores.ExtPermissions == nil { return nil, nil } @@ -332,5 +345,11 @@ func (r *Runner) buildModules(ctx context.Context, packageID string, manifest ma } modules["settings"] = BuildSettingsModule(ctx, r.stores, packageID, userID) + // v0.38.2: lib module — always injected, no permission required. + // Allows any starlark package to load declared library dependencies. + if lc != nil { + modules["lib"] = BuildLibModule(ctx, r, packageID, rc, lc) + } + return modules, nil } diff --git a/server/static/openapi.yaml b/server/static/openapi.yaml index 6f5f762..4828b87 100644 --- a/server/static/openapi.yaml +++ b/server/static/openapi.yaml @@ -8820,6 +8820,74 @@ paths: $ref: '#/components/responses/Unauthorized' '404': $ref: '#/components/responses/NotFound' + # ── Extension Dependencies (v0.38.2) ───────────────────────────────── + /api/v1/admin/packages/{id}/dependencies: + get: + tags: [Admin, Extensions] + summary: List libraries this package depends on + description: "v0.38.2: Returns dependency records where this package is the consumer." + security: + - bearerAuth: [] + parameters: + - $ref: '#/components/parameters/ResourceID' + responses: + '200': + description: Dependencies list + content: + application/json: + schema: + type: object + properties: + data: + type: array + items: + $ref: '#/components/schemas/ExtDependency' + '401': + $ref: '#/components/responses/Unauthorized' + /api/v1/admin/packages/{id}/consumers: + get: + tags: [Admin, Extensions] + summary: List packages that depend on this library + description: "v0.38.2: Returns dependency records where this package is the library." + security: + - bearerAuth: [] + parameters: + - $ref: '#/components/parameters/ResourceID' + responses: + '200': + description: Consumers list + content: + application/json: + schema: + type: object + properties: + data: + type: array + items: + $ref: '#/components/schemas/ExtDependency' + '401': + $ref: '#/components/responses/Unauthorized' + /api/v1/admin/dependencies: + get: + tags: [Admin, Extensions] + summary: List all package dependencies + description: "v0.38.2: Returns the full dependency graph." + security: + - bearerAuth: [] + responses: + '200': + description: Full dependency graph + content: + application/json: + schema: + type: object + properties: + data: + type: array + items: + $ref: '#/components/schemas/ExtDependency' + '401': + $ref: '#/components/responses/Unauthorized' # ── Extension Connections (v0.38.1) ───────────────────────────────── /api/v1/connections: get: @@ -12266,6 +12334,22 @@ components: updated_at: type: string format: date-time + ExtDependency: + type: object + description: "v0.38.2: Package dependency record (consumer → library)" + properties: + consumer_id: + type: string + description: Package ID of the consumer + library_id: + type: string + description: Package ID of the library + version_spec: + type: string + description: Semver version spec declared by consumer + resolved_ver: + type: string + description: Actual library version at dependency creation time ExtConnection: type: object description: "v0.38.1: Extension connection credential (secrets masked in list responses)" diff --git a/server/store/interfaces.go b/server/store/interfaces.go index de92d8a..845937b 100644 --- a/server/store/interfaces.go +++ b/server/store/interfaces.go @@ -66,6 +66,7 @@ type Stores struct { RateLimits RateLimitStore // v0.32.0: Distributed rate limiting Export ExportStore // v0.34.0: Data portability batch reads + GDPR ops Connections ConnectionStore // v0.38.1: Extension connection credentials + Dependencies DependencyStore // v0.38.2: Library package dependency graph } // ========================================= @@ -1396,6 +1397,35 @@ type ConnectionStore interface { DeleteByIDAndScope(ctx context.Context, id, scope, ownerID string) (int64, error) } +// ========================================= +// DEPENDENCY STORE (v0.38.2) +// ========================================= + +type DependencyStore interface { + // Create records that consumerID depends on libraryID. + Create(ctx context.Context, dep *models.ExtDependency) error + + // Delete removes a single dependency record. + Delete(ctx context.Context, consumerID, libraryID string) error + + // DeleteAllForConsumer removes all dependency records for a consumer. + // Called when uninstalling a consumer package. + DeleteAllForConsumer(ctx context.Context, consumerID string) error + + // ListByConsumer returns all libraries that consumerID depends on. + ListByConsumer(ctx context.Context, consumerID string) ([]models.ExtDependency, error) + + // ListByLibrary returns all consumers that depend on libraryID. + ListByLibrary(ctx context.Context, libraryID string) ([]models.ExtDependency, error) + + // ListAll returns the full dependency graph. + ListAll(ctx context.Context) ([]models.ExtDependency, error) + + // HasConsumers returns true if any package depends on libraryID. + // Used for uninstall protection. + HasConsumers(ctx context.Context, libraryID string) (bool, error) +} + // ========================================= // ListOptions provides standard pagination/sort for list queries. diff --git a/server/store/postgres/ext_dependency.go b/server/store/postgres/ext_dependency.go new file mode 100644 index 0000000..2d71852 --- /dev/null +++ b/server/store/postgres/ext_dependency.go @@ -0,0 +1,95 @@ +package postgres + +import ( + "context" + "database/sql" + + "chat-switchboard/models" +) + +// ── DependencyStore (v0.38.2) ──────────────────────── + +type DependencyStore struct{} + +func NewDependencyStore() *DependencyStore { return &DependencyStore{} } + +const depCols = `consumer_id, library_id, version_spec, resolved_ver` + +func (s *DependencyStore) Create(ctx context.Context, dep *models.ExtDependency) error { + _, err := DB.ExecContext(ctx, ` + INSERT INTO ext_dependencies (consumer_id, library_id, version_spec, resolved_ver) + VALUES ($1, $2, $3, $4) + ON CONFLICT (consumer_id, library_id) DO UPDATE + SET version_spec = EXCLUDED.version_spec, resolved_ver = EXCLUDED.resolved_ver`, + dep.ConsumerID, dep.LibraryID, dep.VersionSpec, dep.ResolvedVer, + ) + return err +} + +func (s *DependencyStore) Delete(ctx context.Context, consumerID, libraryID string) error { + _, err := DB.ExecContext(ctx, + `DELETE FROM ext_dependencies WHERE consumer_id = $1 AND library_id = $2`, + consumerID, libraryID) + return err +} + +func (s *DependencyStore) DeleteAllForConsumer(ctx context.Context, consumerID string) error { + _, err := DB.ExecContext(ctx, + `DELETE FROM ext_dependencies WHERE consumer_id = $1`, consumerID) + return err +} + +func (s *DependencyStore) ListByConsumer(ctx context.Context, consumerID string) ([]models.ExtDependency, error) { + rows, err := DB.QueryContext(ctx, + `SELECT `+depCols+` FROM ext_dependencies WHERE consumer_id = $1 ORDER BY library_id`, + consumerID) + if err != nil { + return nil, err + } + defer rows.Close() + return scanDependencies(rows) +} + +func (s *DependencyStore) ListByLibrary(ctx context.Context, libraryID string) ([]models.ExtDependency, error) { + rows, err := DB.QueryContext(ctx, + `SELECT `+depCols+` FROM ext_dependencies WHERE library_id = $1 ORDER BY consumer_id`, + libraryID) + if err != nil { + return nil, err + } + defer rows.Close() + return scanDependencies(rows) +} + +func (s *DependencyStore) ListAll(ctx context.Context) ([]models.ExtDependency, error) { + rows, err := DB.QueryContext(ctx, + `SELECT `+depCols+` FROM ext_dependencies ORDER BY consumer_id, library_id`) + if err != nil { + return nil, err + } + defer rows.Close() + return scanDependencies(rows) +} + +func (s *DependencyStore) HasConsumers(ctx context.Context, libraryID string) (bool, error) { + var exists bool + err := DB.QueryRowContext(ctx, + `SELECT EXISTS(SELECT 1 FROM ext_dependencies WHERE library_id = $1)`, + libraryID).Scan(&exists) + return exists, err +} + +// ── Internal ─────────────────────────────────────── + +func scanDependencies(rows *sql.Rows) ([]models.ExtDependency, error) { + var result []models.ExtDependency + for rows.Next() { + var d models.ExtDependency + err := rows.Scan(&d.ConsumerID, &d.LibraryID, &d.VersionSpec, &d.ResolvedVer) + if err != nil { + return nil, err + } + result = append(result, d) + } + return result, rows.Err() +} diff --git a/server/store/postgres/stores.go b/server/store/postgres/stores.go index 4cd5f4e..dbf62a0 100644 --- a/server/store/postgres/stores.go +++ b/server/store/postgres/stores.go @@ -52,5 +52,6 @@ func NewStores(db *sql.DB) store.Stores { RateLimits: NewRateLimitStore(), Export: NewExportStore(), Connections: NewConnectionStore(), + Dependencies: NewDependencyStore(), } } diff --git a/server/store/sqlite/ext_dependency.go b/server/store/sqlite/ext_dependency.go new file mode 100644 index 0000000..3e3e941 --- /dev/null +++ b/server/store/sqlite/ext_dependency.go @@ -0,0 +1,93 @@ +package sqlite + +import ( + "context" + "database/sql" + + "chat-switchboard/models" +) + +// ── DependencyStore (v0.38.2) ──────────────────────── + +type DependencyStore struct{} + +func NewDependencyStore() *DependencyStore { return &DependencyStore{} } + +const depCols = `consumer_id, library_id, version_spec, resolved_ver` + +func (s *DependencyStore) Create(ctx context.Context, dep *models.ExtDependency) error { + _, err := DB.ExecContext(ctx, ` + INSERT OR REPLACE INTO ext_dependencies (consumer_id, library_id, version_spec, resolved_ver) + VALUES (?, ?, ?, ?)`, + dep.ConsumerID, dep.LibraryID, dep.VersionSpec, dep.ResolvedVer, + ) + return err +} + +func (s *DependencyStore) Delete(ctx context.Context, consumerID, libraryID string) error { + _, err := DB.ExecContext(ctx, + `DELETE FROM ext_dependencies WHERE consumer_id = ? AND library_id = ?`, + consumerID, libraryID) + return err +} + +func (s *DependencyStore) DeleteAllForConsumer(ctx context.Context, consumerID string) error { + _, err := DB.ExecContext(ctx, + `DELETE FROM ext_dependencies WHERE consumer_id = ?`, consumerID) + return err +} + +func (s *DependencyStore) ListByConsumer(ctx context.Context, consumerID string) ([]models.ExtDependency, error) { + rows, err := DB.QueryContext(ctx, + `SELECT `+depCols+` FROM ext_dependencies WHERE consumer_id = ? ORDER BY library_id`, + consumerID) + if err != nil { + return nil, err + } + defer rows.Close() + return scanDependencies(rows) +} + +func (s *DependencyStore) ListByLibrary(ctx context.Context, libraryID string) ([]models.ExtDependency, error) { + rows, err := DB.QueryContext(ctx, + `SELECT `+depCols+` FROM ext_dependencies WHERE library_id = ? ORDER BY consumer_id`, + libraryID) + if err != nil { + return nil, err + } + defer rows.Close() + return scanDependencies(rows) +} + +func (s *DependencyStore) ListAll(ctx context.Context) ([]models.ExtDependency, error) { + rows, err := DB.QueryContext(ctx, + `SELECT `+depCols+` FROM ext_dependencies ORDER BY consumer_id, library_id`) + if err != nil { + return nil, err + } + defer rows.Close() + return scanDependencies(rows) +} + +func (s *DependencyStore) HasConsumers(ctx context.Context, libraryID string) (bool, error) { + var count int + err := DB.QueryRowContext(ctx, + `SELECT COUNT(*) FROM ext_dependencies WHERE library_id = ?`, + libraryID).Scan(&count) + return count > 0, err +} + +// ── Internal ─────────────────────────────────────── + +func scanDependencies(rows *sql.Rows) ([]models.ExtDependency, error) { + var result []models.ExtDependency + for rows.Next() { + var d models.ExtDependency + err := rows.Scan(&d.ConsumerID, &d.LibraryID, &d.VersionSpec, &d.ResolvedVer) + if err != nil { + return nil, err + } + result = append(result, d) + } + return result, rows.Err() +} diff --git a/server/store/sqlite/stores.go b/server/store/sqlite/stores.go index 9f7d611..0fd91f4 100644 --- a/server/store/sqlite/stores.go +++ b/server/store/sqlite/stores.go @@ -52,5 +52,6 @@ func NewStores(db *sql.DB) store.Stores { RateLimits: NewRateLimitStore(), Export: NewExportStore(), Connections: NewConnectionStore(), + Dependencies: NewDependencyStore(), } } diff --git a/src/js/sw/sdk/api-domains.js b/src/js/sw/sdk/api-domains.js index 0cea59f..136b26a 100644 --- a/src/js/sw/sdk/api-domains.js +++ b/src/js/sw/sdk/api-domains.js @@ -500,10 +500,17 @@ export function createDomains(restClient) { del: (id) => rc.del(`/api/v1/admin/packages/${id}`), settings: (id) => rc.get(`/api/v1/admin/packages/${id}/settings`), updateSettings: (id, data) => rc.put(`/api/v1/admin/packages/${id}/settings`, data), + dependencies: (id) => rc.get(`/api/v1/admin/packages/${id}/dependencies`), // v0.38.2 + consumers: (id) => rc.get(`/api/v1/admin/packages/${id}/consumers`), // v0.38.2 registry: () => rc.get('/api/v1/admin/packages/registry'), registryInstall: (url) => rc.post('/api/v1/admin/packages/registry/install', { url }), }, + // v0.38.2: Full dependency graph + dependencies: { + list: () => rc.get('/api/v1/admin/dependencies'), + }, + tasks: { list: () => rc.get('/api/v1/admin/tasks'), run: (id) => rc.post(`/api/v1/admin/tasks/${id}/run`, {}),