diff --git a/docs/ICD/extensions.md b/docs/ICD/extensions.md index 14d6a77..487dcda 100644 --- a/docs/ICD/extensions.md +++ b/docs/ICD/extensions.md @@ -412,6 +412,65 @@ all = connections.list("gitea") # all accessible Each returned dict contains `id`, `type`, `name`, `scope` plus all config fields with secrets decrypted. +### Connection Type Discovery (v0.38.4) + +**Auth:** Authenticated user (no specific permission required) + +``` +GET /connection-types → {"data": [...connection type entries]} +``` + +Returns the merged set of connection types declared by all active packages. +When multiple packages declare the same type name, library declarations +take precedence over non-library declarations. + +**Response entry:** +```json +{ + "type": "gitea", + "label": "Gitea Instance", + "package_id": "gitea-client", + "package_title": "Gitea API Client", + "fields": { + "base_url": {"type": "url", "required": "true", "label": "Server URL"}, + "api_token": {"type": "secret", "required": "true", "label": "API Token"}, + "org": {"type": "string", "required": "false", "label": "Default Org"} + }, + "scopes": ["global", "team", "personal"] +} +``` + +Used by all three Connections management UIs (Settings, Admin, Team Admin) +to populate the connection type dropdown. Replaces client-side manifest +scanning which required admin permissions. + +--- + +### Library Composition (v0.38.4) + +Libraries declare connection types in their manifest `connections[]` field. +Consumers depend on the library and use its exported functions, passing +connection dicts obtained from `connections.get()`. + +**End-to-end pattern:** + +1. Library declares `connections: [{ "type": "gitea", ... }]` in manifest +2. Library exports functions that accept a `conn` parameter +3. Admin installs library and grants permissions (`api.http`, `connections.read`, etc.) +4. User creates a connection of the library's type via Settings > Connections +5. Consumer declares `dependencies: { "gitea-client": ">=1.0.0" }` in manifest +6. Consumer loads library: `gitea = lib.load("gitea-client")` +7. Consumer resolves connection: `conn = connections.get("gitea")` +8. Consumer calls library: `gitea.get_repos(conn)` + +The library function executes with the **library's** permission context. +The consumer does not need `api.http` or `db.*` permissions — those are +the library's concern. + +**Browser-side consumers** can also call the library's REST endpoints +directly (`GET /s/gitea-client/api/repos`) without Starlark, enabling +pure `type: "surface"` packages. + --- ### Library Packages (v0.38.2) diff --git a/packages/gitea-client/manifest.json b/packages/gitea-client/manifest.json new file mode 100644 index 0000000..71ce35f --- /dev/null +++ b/packages/gitea-client/manifest.json @@ -0,0 +1,72 @@ +{ + "id": "gitea-client", + "title": "Gitea API Client", + "type": "library", + "tier": "starlark", + "version": "1.0.0", + "description": "Shared Gitea client — connections, API helpers, optional data caching.", + "author": "switchboard", + + "permissions": ["api.http", "connections.read", "db.read", "db.write"], + + "exports": [ + "get_repos", "get_issues", "get_issue", "create_issue", + "update_issue", "add_comment", "get_prs", "get_ci_status" + ], + + "api_routes": [ + {"method": "GET", "path": "/repos"}, + {"method": "GET", "path": "/issues"}, + {"method": "GET", "path": "/issues/*"}, + {"method": "POST", "path": "/issues"}, + {"method": "GET", "path": "/prs"}, + {"method": "GET", "path": "/ci/*"}, + {"method": "POST", "path": "/sync"} + ], + + "connections": [ + { + "type": "gitea", + "label": "Gitea Instance", + "fields": { + "base_url": {"type": "url", "required": "true", "label": "Server URL"}, + "api_token": {"type": "secret", "required": "true", "label": "API Token"}, + "org": {"type": "string", "required": "false", "label": "Default Org"} + }, + "scopes": ["global", "team", "personal"] + } + ], + + "db_tables": { + "repos": { + "columns": { + "connection_id": "text", + "full_name": "text", + "owner": "text", + "name": "text", + "description": "text", + "html_url": "text", + "open_issues": "integer", + "synced_at": "timestamp" + }, + "indexes": [["connection_id"]] + }, + "issues": { + "columns": { + "connection_id": "text", + "repo_full_name": "text", + "number": "integer", + "title": "text", + "state": "text", + "labels": "text", + "assignee": "text", + "body": "text", + "created_at": "text", + "synced_at": "timestamp" + }, + "indexes": [["connection_id", "repo_full_name"]] + } + }, + + "schema_version": 1 +} diff --git a/packages/gitea-client/script.star b/packages/gitea-client/script.star new file mode 100644 index 0000000..a361428 --- /dev/null +++ b/packages/gitea-client/script.star @@ -0,0 +1,173 @@ +# Gitea API Client — Library Package +# +# Shared Gitea client providing connection-backed API helpers. +# Consumers call lib.require("gitea-client") and pass a conn dict +# obtained from connections.get("gitea"). +# +# Entry points: +# on_request(req) — REST API routes for browser-side consumers +# Exported functions — Starlark consumers via lib.require() +# +# Modules available (via library permissions): +# http — outbound HTTP (api.http) +# connections — credential resolution (connections.read) +# db — ext_gitea_client_* tables (db.read, db.write) +# json — encode/decode (universal) + +load("star/http_helpers.star", "gitea_get", "gitea_post", "gitea_patch", "auth_headers", "resp") +load("star/repos.star", "get_repos") +load("star/issues.star", "get_issues", "get_issue", "create_issue", "update_issue", "add_comment") +load("star/ci.star", "get_ci_status") + + +# ═══════════════════════════════════════════════ +# REST API routes (/s/gitea-client/api/*) +# ═══════════════════════════════════════════════ + +def on_request(req): + path = req["path"] + method = req["method"] + + conn = _resolve_conn() + if conn == None: + return resp(400, {"error": "no gitea connection configured"}) + + if method == "GET" and path == "/repos": + repos = get_repos(conn) + if repos == None: + return resp(502, {"error": "gitea request failed"}) + return resp(200, {"data": repos}) + + elif method == "GET" and path == "/issues": + q = req.get("query", {}) + owner = _str(q.get("owner", "")) + repo = _str(q.get("repo", "")) + state = _str(q.get("state", "")) or "open" + if not owner or not repo: + return resp(400, {"error": "owner and repo query params required"}) + issues = get_issues(conn, owner, repo, state) + if issues == None: + return resp(502, {"error": "gitea request failed"}) + return resp(200, {"data": issues, "count": len(issues)}) + + elif method == "GET" and path.startswith("/issues/"): + parts = path[len("/issues/"):].split("/", 2) + if len(parts) < 3: + return resp(400, {"error": "path must be /issues/:owner/:repo/:number"}) + owner, repo, num = parts[0], parts[1], int(parts[2]) + issue = get_issue(conn, owner, repo, num) + if issue == None: + return resp(404, {"error": "issue not found"}) + return resp(200, issue) + + elif method == "POST" and path == "/issues": + body = json.decode(req.get("body", "{}")) + result = create_issue(conn, body.get("owner", ""), body.get("repo", ""), + body.get("title", ""), body.get("body", ""), + body.get("labels", "")) + if result == None: + return resp(502, {"error": "failed to create issue"}) + return resp(201, result) + + elif method == "GET" and path == "/prs": + q = req.get("query", {}) + owner = _str(q.get("owner", "")) + repo = _str(q.get("repo", "")) + state = _str(q.get("state", "")) or "open" + if not owner or not repo: + return resp(400, {"error": "owner and repo query params required"}) + prs = get_prs(conn, owner, repo, state) + if prs == None: + return resp(502, {"error": "gitea request failed"}) + return resp(200, {"data": prs, "count": len(prs)}) + + elif method == "GET" and path.startswith("/ci/"): + parts = path[len("/ci/"):].split("/", 2) + if len(parts) < 3: + return resp(400, {"error": "path must be /ci/:owner/:repo/:ref"}) + owner, repo, ref = parts[0], parts[1], parts[2] + result = get_ci_status(conn, owner, repo, ref) + if result == None: + return resp(502, {"error": "CI status request failed"}) + return resp(200, result) + + elif method == "POST" and path == "/sync": + return _handle_sync(conn, req) + + return resp(404, {"error": "not found"}) + + +# ═══════════════════════════════════════════════ +# PR helper (not large enough for its own file) +# ═══════════════════════════════════════════════ + +def get_prs(conn, owner, repo, state): + """List pull requests for a repository.""" + state = state or "open" + path = "/repos/" + owner + "/" + repo + "/pulls?state=" + state + "&limit=50" + data = gitea_get(conn, path) + if data == None: + return None + items = [] + for p in data: + items.append({ + "number": p.get("number", 0), + "title": p.get("title", ""), + "state": p.get("state", ""), + "head": p.get("head", {}).get("ref", ""), + "base": p.get("base", {}).get("ref", ""), + "user": (p.get("user") or {}).get("login", ""), + }) + return items + + +# ═══════════════════════════════════════════════ +# Sync handler — cache repos/issues to db tables +# ═══════════════════════════════════════════════ + +def _handle_sync(conn, req): + """Sync repos and issues into local cache tables.""" + repos = get_repos(conn) + if repos == None: + return resp(502, {"error": "failed to fetch repos"}) + + conn_id = conn.get("id", "default") + + # Clear and re-insert repos + db.exec("DELETE FROM repos WHERE connection_id = ?", [conn_id]) + for r in repos: + db.exec( + "INSERT INTO repos (connection_id, full_name, owner, name, description, html_url, open_issues, synced_at) VALUES (?, ?, ?, ?, ?, ?, ?, datetime('now'))", + [conn_id, r["full_name"], r["owner"], r["name"], r.get("description", ""), r.get("html_url", ""), r.get("open_issues", 0)] + ) + + # Sync open issues for each repo + issue_count = 0 + db.exec("DELETE FROM issues WHERE connection_id = ?", [conn_id]) + for r in repos: + issues = get_issues(conn, r["owner"], r["name"], "open") + if issues == None: + continue + for i in issues: + labels_str = ",".join(i.get("labels", [])) + db.exec( + "INSERT INTO issues (connection_id, repo_full_name, number, title, state, labels, assignee, body, created_at, synced_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))", + [conn_id, r["full_name"], i["number"], i["title"], i["state"], labels_str, i.get("assignee", ""), "", i.get("created_at", "")] + ) + issue_count += 1 + + return resp(200, {"repos": len(repos), "issues": issue_count}) + + +# ═══════════════════════════════════════════════ +# Helpers +# ═══════════════════════════════════════════════ + +def _resolve_conn(): + """Resolve the first available gitea connection.""" + return connections.get("gitea") + +def _str(v): + if v == None: + return "" + return str(v) diff --git a/packages/gitea-client/star/ci.star b/packages/gitea-client/star/ci.star new file mode 100644 index 0000000..6a3f110 --- /dev/null +++ b/packages/gitea-client/star/ci.star @@ -0,0 +1,25 @@ +# gitea-client — CI status operations + +load("star/http_helpers.star", "gitea_get") + +def get_ci_status(conn, owner, repo, ref): + """Get CI/CD job status for a commit or branch reference.""" + path = "/repos/" + owner + "/" + repo + "/commits/" + ref + "/status" + data = gitea_get(conn, path) + if data == None: + return None + statuses = data.get("statuses", []) + items = [] + for s in statuses: + items.append({ + "context": s.get("context", s.get("name", "")), + "state": s.get("state", s.get("status", "")), + "description": s.get("description", ""), + "target_url": s.get("target_url", ""), + }) + return { + "ref": ref, + "state": data.get("state", ""), + "statuses": items, + "count": len(items), + } diff --git a/packages/gitea-client/star/http_helpers.star b/packages/gitea-client/star/http_helpers.star new file mode 100644 index 0000000..09f3cf5 --- /dev/null +++ b/packages/gitea-client/star/http_helpers.star @@ -0,0 +1,60 @@ +# gitea-client — HTTP helpers +# +# Shared HTTP utilities for Gitea API calls. +# All functions take a `conn` dict from connections.get("gitea"). + +def auth_headers(conn): + """Build authorization headers from a connection dict.""" + token = conn.get("api_token", "") + if not token: + return {} + return {"Authorization": "token " + token} + +def _base(conn): + """Return base URL with trailing slash stripped.""" + url = conn.get("base_url", "") + if url.endswith("/"): + url = url[:-1] + return url + +def gitea_get(conn, path): + """GET request to Gitea API. Returns decoded body or None.""" + url = _base(conn) + "/api/v1" + path + resp = http.get(url=url, headers=auth_headers(conn)) + if int(resp["status"]) >= 400: + return None + body = resp.get("body", "") + if not body: + return None + return json.decode(body) + +def gitea_post(conn, path, data): + """POST request to Gitea API. Returns decoded body or None.""" + url = _base(conn) + "/api/v1" + path + hdrs = dict(auth_headers(conn), **{"Content-Type": "application/json"}) + resp = http.post(url=url, body=json.encode(data), headers=hdrs) + if int(resp["status"]) >= 400: + return None + body = resp.get("body", "") + if not body: + return None + return json.decode(body) + +def gitea_patch(conn, path, data): + """PATCH request to Gitea API (via X-HTTP-Method-Override). Returns decoded body or None.""" + url = _base(conn) + "/api/v1" + path + hdrs = dict(auth_headers(conn), **{ + "Content-Type": "application/json", + "X-HTTP-Method-Override": "PATCH", + }) + resp = http.post(url=url, body=json.encode(data), headers=hdrs) + if int(resp["status"]) >= 400: + return None + body = resp.get("body", "") + if not body: + return None + return json.decode(body) + +def resp(status, data): + """Build a standard JSON response dict.""" + return {"status": status, "body": json.encode(data), "headers": {"Content-Type": "application/json"}} diff --git a/packages/gitea-client/star/issues.star b/packages/gitea-client/star/issues.star new file mode 100644 index 0000000..32d0cb1 --- /dev/null +++ b/packages/gitea-client/star/issues.star @@ -0,0 +1,91 @@ +# gitea-client — Issue operations + +load("star/http_helpers.star", "gitea_get", "gitea_post", "gitea_patch") + +def get_issues(conn, owner, repo, state): + """List issues for a repository.""" + state = state or "open" + path = "/repos/" + owner + "/" + repo + "/issues?state=" + state + "&limit=50&type=issues" + data = gitea_get(conn, path) + if data == None: + return None + items = [] + for i in data: + labels = [l.get("name", "") for l in (i.get("labels") or [])] + items.append({ + "number": i.get("number", 0), + "title": i.get("title", ""), + "state": i.get("state", ""), + "labels": labels, + "assignee": (i.get("assignee") or {}).get("login", ""), + }) + return items + +def get_issue(conn, owner, repo, number): + """Get full issue details including comments.""" + path = "/repos/" + owner + "/" + repo + "/issues/" + str(number) + data = gitea_get(conn, path) + if data == None: + return None + comments_data = gitea_get(conn, path + "/comments") or [] + comments = [] + for c in comments_data: + comments.append({ + "user": (c.get("user") or {}).get("login", ""), + "body": c.get("body", ""), + "created_at": c.get("created_at", ""), + }) + return { + "number": data.get("number", 0), + "title": data.get("title", ""), + "body": data.get("body", ""), + "state": data.get("state", ""), + "labels": [l.get("name", "") for l in (data.get("labels") or [])], + "assignee": (data.get("assignee") or {}).get("login", ""), + "created_at": data.get("created_at", ""), + "comments": comments, + } + +def create_issue(conn, owner, repo, title, body, labels): + """Create a new issue.""" + path = "/repos/" + owner + "/" + repo + "/issues" + payload = {"title": title} + if body: + payload["body"] = body + if labels: + payload["labels"] = [l.strip() for l in labels.split(",") if l.strip()] + data = gitea_post(conn, path, payload) + if data == None: + return None + return { + "number": data.get("number", 0), + "title": data.get("title", ""), + "html_url": data.get("html_url", ""), + } + +def update_issue(conn, owner, repo, number, title, body, state): + """Update an existing issue.""" + path = "/repos/" + owner + "/" + repo + "/issues/" + str(number) + patch = {} + if title: + patch["title"] = title + if body: + patch["body"] = body + if state: + patch["state"] = state + data = gitea_patch(conn, path, patch) + if data == None: + return None + return { + "number": data.get("number", 0), + "state": data.get("state", ""), + "title": data.get("title", ""), + } + +def add_comment(conn, owner, repo, number, body): + """Add a comment to an issue.""" + path = "/repos/" + owner + "/" + repo + "/issues/" + str(number) + "/comments" + data = gitea_post(conn, path, {"body": body}) + if data == None: + return None + return {"id": data.get("id", 0), "body": data.get("body", "")} diff --git a/packages/gitea-client/star/repos.star b/packages/gitea-client/star/repos.star new file mode 100644 index 0000000..0ed4ede --- /dev/null +++ b/packages/gitea-client/star/repos.star @@ -0,0 +1,21 @@ +# gitea-client — Repository operations + +load("star/http_helpers.star", "gitea_get") + +def get_repos(conn): + """List repositories accessible via the connection.""" + data = gitea_get(conn, "/repos/search?limit=50&sort=updated") + if data == None: + return None + repos = data if type(data) == "list" else data.get("data", []) + out = [] + for r in repos: + out.append({ + "full_name": r.get("full_name", ""), + "name": r.get("name", ""), + "owner": r.get("owner", {}).get("login", ""), + "description": r.get("description", ""), + "html_url": r.get("html_url", ""), + "open_issues": r.get("open_issues_count", 0), + }) + return out diff --git a/packages/sdk-test-runner/js/domains/composition.js b/packages/sdk-test-runner/js/domains/composition.js new file mode 100644 index 0000000..3aab52e --- /dev/null +++ b/packages/sdk-test-runner/js/domains/composition.js @@ -0,0 +1,240 @@ +/** + * SDK Test Runner — Domain: composition (v0.38.4) + * + * Tests the full library composition pattern: + * library declares connection types → consumer depends on library → + * connection created → types endpoint reflects it → cleanup. + * Requires admin role. + */ +(function () { + 'use strict'; + var T = window.SDKR; + if (!T) return; + + // ── Minimal zip builder (same as dependencies.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('composition', async function () { + + if (!window.sw || !sw.isAdmin) { + await T.test('composition', 'guard', 'skip — not admin', async function () { + T.assert(true, 'skipped (user is not admin)'); + }); + return; + } + + var ts = Date.now(); + var libId = 'comp-test-lib-' + ts; + var consumerId = 'comp-test-consumer-' + ts; + var connType = 'comp-test-svc-' + ts; + var connId = null; + + // ── 1. Install library with connection type ──────────── + + await T.test('composition', 'install-library', 'install library with connection type declaration', async function () { + var manifest = { + id: libId, + title: 'Composition Test Library', + type: 'library', + tier: 'starlark', + version: '1.0.0', + exports: ['get_data'], + permissions: ['api.http', 'connections.read'], + connections: [{ + type: connType, + label: 'Test Service', + fields: { + base_url: { type: 'url', required: 'true', label: 'Server URL' }, + api_token: { type: 'secret', required: 'true', label: 'API Token' } + }, + scopes: ['global', 'team', 'personal'] + }] + }; + var file = makePkgFile(manifest, [ + { name: 'script.star', content: 'def get_data():\n return {"ok": True}\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 () { + if (libId) try { await sw.api.admin.packages.del(libId); } catch (_) {} + }); + + // ── 2. Grant library permissions ──────────── + + await T.test('composition', 'grant-permissions', 'grant library declared permissions', async function () { + var r = await sw.api.post('/api/v1/admin/extensions/' + libId + '/permissions/grant-all', {}); + T.assert(true, 'permissions granted (or already granted)'); + }); + + // ── 3. Connection type appears in discovery endpoint ──── + + await T.test('composition', 'types-include-library', 'connection-types endpoint includes library type', async function () { + var types = await sw.api.connectionTypes.list(); + T.assert(Array.isArray(types), 'should be array'); + var found = types.find(function (t) { return t.type === connType; }); + T.assert(found, 'library connection type should appear: ' + connType); + T.assert(found.package_id === libId, 'package_id should be library'); + T.assert(found.label === 'Test Service', 'label should match'); + T.assert(found.fields && found.fields.api_token, 'should have api_token field'); + T.assert(found.fields.api_token.type === 'secret', 'api_token should be secret type'); + }); + + // ── 4. Create connection of library's type ──────────── + + await T.test('composition', 'create-connection', 'create connection of library connection type', async function () { + var r = await sw.api.admin.connections.create({ + type: connType, + package_id: libId, + name: 'Comp Test Connection', + config: { base_url: 'https://comp-test.example.com', api_token: 'test-token-12345' } + }); + T.assert(r && r.id, 'should create connection'); + T.assert(r.type === connType, 'type should match'); + connId = r.id; + }); + + T.cleanup.push(async function () { + if (connId) try { await sw.api.admin.connections.del(connId); } catch (_) {} + }); + + // ── 5. Install consumer with dependency on library ──── + + await T.test('composition', 'install-consumer', 'install consumer depending on library', async function () { + var manifest = { + id: consumerId, + title: 'Composition Test Consumer', + type: 'extension', + tier: 'starlark', + version: '0.1.0', + permissions: ['connections.read'], + tools: [{ name: 'comp_noop', description: 'test tool', parameters: {} }], + dependencies: {} + }; + manifest.dependencies[libId] = '>=1.0.0'; + var file = makePkgFile(manifest, [ + { name: 'script.star', content: 'lib = lib.require("' + libId + '")\n\ndef on_tool_call(tool_name, params):\n return lib.get_data()\n' } + ]); + var r = await sw.api.admin.packages.install(file); + T.assert(r && r.id === consumerId, 'consumer should install'); + }); + + T.cleanup.push(async function () { + if (consumerId) try { await sw.api.admin.packages.del(consumerId); } catch (_) {} + }); + + // ── 6. Verify dependency recorded ──────────── + + await T.test('composition', 'dependency-recorded', 'consumer depends on library', async function () { + var r = await sw.api.admin.packages.dependencies(consumerId); + T.assert(r && r.data, 'should return data'); + var dep = r.data.find(function (d) { return d.library_id === libId; }); + T.assert(dep, 'dependency on library should exist'); + }); + + // ── 7. Cleanup: consumer, connection, library ──────────── + + await T.test('composition', 'cleanup-consumer', 'uninstall consumer', async function () { + await sw.api.admin.packages.del(consumerId); + consumerId = null; + T.assert(true, 'consumer uninstalled'); + }); + + await T.test('composition', 'cleanup-connection', 'delete connection', async function () { + await sw.api.admin.connections.del(connId); + connId = null; + T.assert(true, 'connection deleted'); + }); + + await T.test('composition', 'cleanup-library', 'uninstall library', async function () { + await sw.api.admin.packages.del(libId); + libId = null; + T.assert(true, 'library uninstalled'); + }); + + // ── 8. Verify type removed after uninstall ──────────── + + await T.test('composition', 'types-removed', 'connection type removed after library uninstall', async function () { + var types = await sw.api.connectionTypes.list(); + var found = types.find(function (t) { return t.type === connType; }); + T.assert(!found, 'type should not appear after library uninstall'); + }); + + }); +})(); diff --git a/packages/sdk-test-runner/js/domains/connections.js b/packages/sdk-test-runner/js/domains/connections.js index f6c29f7..dd2f14a 100644 --- a/packages/sdk-test-runner/js/domains/connections.js +++ b/packages/sdk-test-runner/js/domains/connections.js @@ -95,6 +95,22 @@ T.assert(r.id === globalId, 'should match global connection by name'); }); + // ── Connection Type Discovery (v0.38.4) ── + + await T.test('connections', 'types-list', 'GET /connection-types returns array', async function () { + var r = await sw.api.connectionTypes.list(); + T.assert(Array.isArray(r), 'should be array'); + }); + + await T.test('connections', 'types-include-sdk-test', 'connection types include sdk-test-conn from global conn', async function () { + // The global connection we created above references package_id sdk-test-pkg + // but that package may not exist. The endpoint scans active package manifests, + // so this test verifies the endpoint returns without error. Actual type matching + // is tested in the composition domain with a real library. + var r = await sw.api.connectionTypes.list(); + T.assert(Array.isArray(r), 'should be array (may be empty if no packages declare connections)'); + }); + // ── Cleanup ────────────────────────────── await T.test('connections', 'personal-delete', 'delete personal connection', async function () { diff --git a/packages/sdk-test-runner/js/main.js b/packages/sdk-test-runner/js/main.js index 284f3b3..3ae5346 100644 --- a/packages/sdk-test-runner/js/main.js +++ b/packages/sdk-test-runner/js/main.js @@ -77,6 +77,7 @@ 'domains/packages.js', 'domains/connections.js', 'domains/dependencies.js', + 'domains/composition.js', // UI (must come last) 'ui.js' ]; diff --git a/server/handlers/connection_types.go b/server/handlers/connection_types.go new file mode 100644 index 0000000..2ed1cf8 --- /dev/null +++ b/server/handlers/connection_types.go @@ -0,0 +1,103 @@ +package handlers + +// v0.38.4: Connection type discovery endpoint. +// Returns merged connection types from all active packages. +// Library declarations take precedence over non-library declarations. + +import ( + "encoding/json" + "net/http" + + "github.com/gin-gonic/gin" + + "chat-switchboard/store" +) + +// ConnectionTypeEntry is a single connection type in the API response. +type ConnectionTypeEntry struct { + Type string `json:"type"` + Label string `json:"label"` + PackageID string `json:"package_id"` + PackageTitle string `json:"package_title"` + Fields map[string]map[string]string `json:"fields"` + Scopes []string `json:"scopes,omitempty"` +} + +// ConnectionTypeHandler serves GET /api/v1/connection-types. +type ConnectionTypeHandler struct { + stores store.Stores +} + +func NewConnectionTypeHandler(s store.Stores) *ConnectionTypeHandler { + return &ConnectionTypeHandler{stores: s} +} + +// ListConnectionTypes returns all connection types declared by active packages. +// Library packages take precedence when multiple packages declare the same type. +func (h *ConnectionTypeHandler) ListConnectionTypes(c *gin.Context) { + ctx := c.Request.Context() + pkgs, err := h.stores.Packages.List(ctx) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list packages"}) + return + } + + type entry struct { + ct ConnectionTypeEntry + isLibrary bool + } + seen := map[string]*entry{} + + for _, pkg := range pkgs { + if pkg.Status != "active" || !pkg.Enabled { + continue + } + connsRaw, ok := pkg.Manifest["connections"] + if !ok { + continue + } + connsJSON, _ := json.Marshal(connsRaw) + var conns []struct { + Type string `json:"type"` + Label string `json:"label"` + Fields map[string]map[string]string `json:"fields"` + Scopes []string `json:"scopes"` + } + if err := json.Unmarshal(connsJSON, &conns); err != nil { + continue + } + + isLib := pkg.Type == "library" + for _, cd := range conns { + if cd.Type == "" { + continue + } + existing, exists := seen[cd.Type] + // Library declarations take precedence + if exists && !isLib && existing.isLibrary { + continue + } + label := cd.Label + if label == "" { + label = cd.Type + } + seen[cd.Type] = &entry{ + ct: ConnectionTypeEntry{ + Type: cd.Type, + Label: label, + PackageID: pkg.ID, + PackageTitle: pkg.Title, + Fields: cd.Fields, + Scopes: cd.Scopes, + }, + isLibrary: isLib, + } + } + } + + result := make([]ConnectionTypeEntry, 0, len(seen)) + for _, e := range seen { + result = append(result, e.ct) + } + c.JSON(http.StatusOK, gin.H{"data": result}) +} diff --git a/server/main.go b/server/main.go index 8676ba7..df6c123 100644 --- a/server/main.go +++ b/server/main.go @@ -814,6 +814,10 @@ func main() { protected.GET("/api-configs/:id/models", provCfg.ListModels) protected.POST("/api-configs/:id/models/fetch", provCfg.FetchModels) + // Connection Type Discovery (v0.38.4) + connTypeH := handlers.NewConnectionTypeHandler(stores) + protected.GET("/connection-types", connTypeH.ListConnectionTypes) + // Extension Connections (personal scope — v0.38.1) connH := handlers.NewConnectionHandler(stores, keyResolver) protected.GET("/connections", connH.ListConnections) diff --git a/server/static/openapi.yaml b/server/static/openapi.yaml index 9973e0f..aa3c0cc 100644 --- a/server/static/openapi.yaml +++ b/server/static/openapi.yaml @@ -8888,6 +8888,31 @@ paths: $ref: '#/components/schemas/ExtDependency' '401': $ref: '#/components/responses/Unauthorized' + # ── Connection Type Discovery (v0.38.4) ───────────────────────────── + /api/v1/connection-types: + get: + tags: [Extensions] + summary: List available connection types + description: | + Returns merged connection types from all active packages. + Library declarations take precedence over non-library declarations. + Any authenticated user can call this endpoint. + security: + - bearerAuth: [] + responses: + '200': + description: Connection type list + content: + application/json: + schema: + type: object + properties: + data: + type: array + items: + $ref: '#/components/schemas/ConnectionType' + '401': + $ref: '#/components/responses/Unauthorized' # ── Extension Connections (v0.38.1) ───────────────────────────────── /api/v1/connections: get: @@ -12334,6 +12359,39 @@ components: updated_at: type: string format: date-time + ConnectionType: + type: object + description: "v0.38.4: Available connection type from an installed package" + properties: + type: + type: string + description: Connection type identifier (e.g. gitea, slack) + label: + type: string + description: Human-readable label + package_id: + type: string + description: Package that declares this connection type + package_title: + type: string + description: Title of the declaring package + fields: + type: object + description: Field definitions (name → {type, required, label}) + additionalProperties: + type: object + properties: + type: + type: string + required: + type: string + label: + type: string + scopes: + type: array + items: + type: string + description: Allowed scopes (global, team, personal) ExtDependency: type: object description: "v0.38.2: Package dependency record (consumer → library)" diff --git a/src/js/sw/sdk/api-domains.js b/src/js/sw/sdk/api-domains.js index 136b26a..01547a2 100644 --- a/src/js/sw/sdk/api-domains.js +++ b/src/js/sw/sdk/api-domains.js @@ -194,6 +194,11 @@ export function createDomains(restClient) { resolve: (type, name) => rc.get('/api/v1/connections/resolve' + _qs({ type, name })), }, + // ── 10c. Connection Types (v0.38.4) ── + connectionTypes: { + list: () => rc.get('/api/v1/connection-types'), + }, + // ── 11. Notifications ────────────────── notifications: { list: (opts) => rc.get('/api/v1/notifications' + _qs(opts)), diff --git a/src/js/sw/surfaces/admin/connections.js b/src/js/sw/surfaces/admin/connections.js index 96bf5c6..80f847c 100644 --- a/src/js/sw/surfaces/admin/connections.js +++ b/src/js/sw/surfaces/admin/connections.js @@ -26,19 +26,11 @@ export default function ConnectionsSection() { const loadTypes = useCallback(async () => { try { - const pkgs = await sw.api.admin.packages.list(); - const types = []; - const seen = {}; - for (const pkg of (pkgs || [])) { - const conns = pkg.manifest?.connections || []; - for (const cd of conns) { - if (!seen[cd.type]) { - seen[cd.type] = true; - types.push({ type: cd.type, label: cd.label || cd.type, packageId: pkg.id, fields: cd.fields || {} }); - } - } - } - setConnTypes(types); + const types = await sw.api.connectionTypes.list(); + setConnTypes((types || []).map(ct => ({ + type: ct.type, label: ct.label || ct.type, + packageId: ct.package_id, fields: ct.fields || {} + }))); } catch (_) {} }, []); diff --git a/src/js/sw/surfaces/settings/connections.js b/src/js/sw/surfaces/settings/connections.js index 8ea3319..20fa554 100644 --- a/src/js/sw/surfaces/settings/connections.js +++ b/src/js/sw/surfaces/settings/connections.js @@ -26,19 +26,11 @@ export function ConnectionsSection() { const loadTypes = useCallback(async () => { try { - const pkgs = await sw.api.admin.packages.list(); - const types = []; - const seen = {}; - for (const pkg of (pkgs || [])) { - const conns = pkg.manifest?.connections || []; - for (const cd of conns) { - if (!seen[cd.type]) { - seen[cd.type] = true; - types.push({ type: cd.type, label: cd.label || cd.type, packageId: pkg.id, fields: cd.fields || {} }); - } - } - } - setConnTypes(types); + const types = await sw.api.connectionTypes.list(); + setConnTypes((types || []).map(ct => ({ + type: ct.type, label: ct.label || ct.type, + packageId: ct.package_id, fields: ct.fields || {} + }))); } catch (_) {} }, []); diff --git a/src/js/sw/surfaces/team-admin/connections.js b/src/js/sw/surfaces/team-admin/connections.js index 1eb1dff..5d9a6b9 100644 --- a/src/js/sw/surfaces/team-admin/connections.js +++ b/src/js/sw/surfaces/team-admin/connections.js @@ -26,19 +26,11 @@ export default function ConnectionsSection({ teamId }) { const loadTypes = useCallback(async () => { try { - const pkgs = await sw.api.admin.packages.list(); - const types = []; - const seen = {}; - for (const pkg of (pkgs || [])) { - const conns = pkg.manifest?.connections || []; - for (const cd of conns) { - if (!seen[cd.type]) { - seen[cd.type] = true; - types.push({ type: cd.type, label: cd.label || cd.type, packageId: pkg.id, fields: cd.fields || {} }); - } - } - } - setConnTypes(types); + const types = await sw.api.connectionTypes.list(); + setConnTypes((types || []).map(ct => ({ + type: ct.type, label: ct.label || ct.type, + packageId: ct.package_id, fields: ct.fields || {} + }))); } catch (_) {} }, []);