diff --git a/CHANGELOG.md b/CHANGELOG.md
index 23466c2..26a9e0b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,48 @@
# Changelog
+## [0.38.5.0] — 2026-03-25
+
+### Summary
+
+Git-board rewrite — library composition capstone. Rewrites git-board from
+a self-contained monolith into a library consumer of gitea-client, proving
+the full extension architecture end-to-end: lib.require(), connections,
+dependencies, multi-file Starlark, and connection type discovery. Includes
+5 platform bug fixes found during composition testing.
+
+### Changed
+
+- **git-board v0.2.0:** Rewrote Starlark backend to delegate all Gitea API
+ work to gitea-client library via `lib.require("gitea-client")`. Removed
+ `api.http` permission, platform abstraction, and per-user token settings.
+ Added `connections.read` permission and `gitea-client >= 1.0.0` dependency.
+ Surface JS: replaced TokenSetup with ConnectionSetup screen.
+ (~130 lines, down from ~329)
+- **gitea-client v1.0.1:** Added missing fields to `get_issues()` (created_at,
+ updated_at, html_url) and `get_prs()` (created_at, html_url, mergeable).
+ Re-exported loaded sub-module functions via explicit global assignment for
+ lib.require() compatibility.
+
+### Fixed (platform bugs)
+
+- **ext_api.go:** Route handler no longer requires `api.http` for all packages
+ with API routes. Consumer packages that delegate HTTP to libraries don't
+ need `api.http` themselves.
+- **packages.go:** `InstallPackage` handler now calls `SyncManifestPermissions`
+ so permission rows are created on package install (was only in the legacy
+ `AdminInstallExtension` handler).
+- **runner.go:** Package loader for `load()` sub-modules now injects the `json`
+ module into predeclared scope, matching ExecWithLoader behavior.
+- **connection_resolver.go:** Added panic recovery around `vault.Decrypt` to
+ prevent goroutine crashes from invalid nonce lengths after vault key rotation.
+
+### Added
+
+- **SDK test runner:** `git-board.js` domain (14 tests) — validates multi-file
+ library install, connection type registration, consumer dependency, tool
+ registration, consumers list, uninstall protection, connection CRUD, full
+ cleanup cycle.
+
## [0.38.2.0] — 2026-03-25
### Summary
diff --git a/VERSION b/VERSION
index dcc5e4a..a39873f 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-0.38.2.0
+0.38.5.0
diff --git a/docker-compose.yml b/docker-compose.yml
index 3e828a0..3f501fd 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -30,6 +30,7 @@ services:
STORAGE_BACKEND: pvc
STORAGE_PATH: /data/storage
CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:-http://localhost:3000}
+ EXT_ALLOW_PRIVATE_IPS: ${EXT_ALLOW_PRIVATE_IPS:-true}
LOG_FORMAT: ${LOG_FORMAT:-text}
LOG_LEVEL: ${LOG_LEVEL:-info}
# Dev seed users — ignored if ENVIRONMENT=production
diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md
index 6f05fa6..3c95e18 100644
--- a/docs/ROADMAP.md
+++ b/docs/ROADMAP.md
@@ -54,7 +54,7 @@ v0.9.x–v0.28.7 Foundation through Platform Polish ✅
│ v0.37.17–18 Surfaces ✅ │
│ v0.37.19 Tag ✅ │
│ │ │
- v0.38.0–.5 │ │
+ v0.38.0–.5 ✅ │ │
Extension │ │
Continuation │ │
│ │ │
@@ -267,13 +267,13 @@ capability has a UI.
---
-## v0.38.x — Extension Track Continuation
+## v0.38.x — Extension Track Continuation ✅
Resumes the extension track (v0.29–v0.31 ✅) with three new platform
primitives: multi-file Starlark packages, extension connections, and
library packages. Culminates in a git-board rewrite that validates the
entire stack. **Must ship before v0.39.x** — dynamic workflows depend
-on `load()`, connections, and libraries.
+on `load()`, connections, and libraries. **Complete as of v0.38.5.**
See design docs for full specifications.
@@ -282,9 +282,9 @@ 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.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. |
+| 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 — git-board rewritten as gitea-client consumer. Proves lib.require(), connections, dependencies, multi-file Starlark end-to-end. 5 platform bugs fixed, 14 SDK runner tests. |
---
@@ -339,9 +339,17 @@ needed to reach a polished MVP-ready product. May pull in features
from ongoing stakeholder meetings.
- [ ] UI bug fixes
+ - [ ] Git Board UserMenu display (text overlap / position on right edge)
- [ ] Dead code removal
- [ ] Performance audit
- [ ] Accessibility pass
+- [ ] Git Board polish:
+ - [ ] Inline issue creation ("+" button on Open column)
+ - [ ] Label filters (click label badge → filter board)
+ - [ ] Search/filter bar (text filter across card titles)
+ - [ ] Auto-refresh (poll every 60s)
+ - [ ] PR detail modal (diff stats, CI status badge, merge status)
+ - [ ] Surface custom icons (manifest `icon` field, replace puzzle piece default)
- [ ] Additional features TBD from stakeholder input
---
@@ -362,7 +370,7 @@ reading Go source code. Team admins build workflows visually.
- Full OpenAPI spec v0.36.0 (complete API documentation) ✅
- UI rewrite v0.37.x (Preact+htm migration, all surfaces) ✅
- Extension continuation v0.38.x (multi-file Starlark, connections,
- libraries, git-board rewrite)
+ libraries, git-board rewrite) ✅
- Workflow maturity v0.39.x (stage graph engine, simple + dynamic
workflows, visitor portal, analytics)
- Polish v0.40.x (bug hunt, dead code, stabilization)
diff --git a/packages/git-board/css/main.css b/packages/git-board/css/main.css
index 830137e..24fffa7 100644
--- a/packages/git-board/css/main.css
+++ b/packages/git-board/css/main.css
@@ -43,7 +43,7 @@
max-width: 260px;
}
-/* ── Token Setup ─────────────────────────── */
+/* ── Connection Setup ─────────────────────── */
.gb-setup {
max-width: 480px;
@@ -65,27 +65,6 @@
margin: 0 0 20px;
}
-.gb-setup__form {
- display: flex;
- gap: 8px;
- margin-bottom: 16px;
-}
-
-.gb-setup__input {
- flex: 1;
- background: var(--bg-raised);
- border: 1px solid var(--border);
- border-radius: var(--radius);
- color: var(--text);
- font-family: var(--mono);
- font-size: 13px;
- padding: 8px 10px;
- outline: none;
-}
-
-.gb-setup__input:focus {
- border-color: var(--accent);
-}
.gb-setup__hint {
font-size: 12px;
@@ -229,6 +208,24 @@
color: var(--text-3);
}
+/* ── DnD States ─────────────────────────── */
+
+.gb-card[draggable="true"] {
+ cursor: grab;
+ user-select: none;
+}
+.gb-card[draggable="true"]:active {
+ cursor: grabbing;
+ opacity: 0.6;
+}
+.gb-column--dragover {
+ border-color: var(--accent);
+ background: color-mix(in srgb, var(--accent) 6%, var(--bg-surface));
+}
+.gb-column--dragover .gb-column__header {
+ border-bottom-color: var(--accent);
+}
+
/* ── Empty state ─────────────────────────── */
.gb-empty {
@@ -239,3 +236,221 @@
color: var(--text-3);
font-size: 14px;
}
+
+/* ── Issue Detail Modal ─────────────────── */
+
+.gb-modal-overlay {
+ position: fixed;
+ inset: 0;
+ background: rgba(0,0,0,0.5);
+ z-index: 1000;
+ display: flex;
+ align-items: flex-start;
+ justify-content: center;
+ padding: 40px 16px;
+ overflow-y: auto;
+}
+
+.gb-modal {
+ background: var(--bg);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-lg);
+ width: 100%;
+ max-width: 680px;
+ max-height: calc(100vh - 80px);
+ display: flex;
+ flex-direction: column;
+ box-shadow: 0 8px 32px rgba(0,0,0,0.3);
+}
+
+.gb-modal__header {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ padding: 16px 20px;
+ border-bottom: 1px solid var(--border);
+ flex-shrink: 0;
+}
+
+.gb-modal__title-row {
+ display: flex;
+ align-items: baseline;
+ gap: 8px;
+ flex: 1;
+ min-width: 0;
+}
+
+.gb-modal__title {
+ font-size: 18px;
+ font-weight: 600;
+ color: var(--text);
+ margin: 0;
+ word-break: break-word;
+}
+
+.gb-modal__close {
+ background: none;
+ border: none;
+ color: var(--text-3);
+ font-size: 18px;
+ cursor: pointer;
+ padding: 2px 6px;
+ border-radius: var(--radius);
+ flex-shrink: 0;
+}
+.gb-modal__close:hover {
+ color: var(--text);
+ background: var(--bg-hover);
+}
+
+.gb-modal__body {
+ overflow-y: auto;
+ padding: 16px 20px;
+ flex: 1;
+ min-height: 0;
+}
+
+.gb-modal__meta {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ flex-wrap: wrap;
+ margin-bottom: 12px;
+ font-size: 12px;
+ color: var(--text-2);
+}
+
+.gb-modal__date {
+ color: var(--text-3);
+}
+
+.gb-modal__extlink {
+ margin-left: auto;
+ color: var(--accent);
+ text-decoration: none;
+ font-size: 12px;
+}
+.gb-modal__extlink:hover {
+ text-decoration: underline;
+}
+
+.gb-modal__description {
+ margin-bottom: 20px;
+ padding-bottom: 16px;
+ border-bottom: 1px solid var(--border);
+}
+
+.gb-modal__body-text {
+ font-family: var(--font);
+ font-size: 13px;
+ line-height: 1.6;
+ color: var(--text);
+ white-space: pre-wrap;
+ word-break: break-word;
+ margin: 0;
+ background: none;
+ border: none;
+ padding: 0;
+}
+
+.gb-modal__empty {
+ color: var(--text-3);
+ font-size: 13px;
+ font-style: italic;
+ margin: 0;
+}
+
+.gb-modal__section-title {
+ font-size: 13px;
+ font-weight: 600;
+ color: var(--text-2);
+ text-transform: uppercase;
+ letter-spacing: 0.03em;
+ margin: 0 0 12px;
+}
+
+/* ── Comments ────────────────────────────── */
+
+.gb-comment {
+ padding: 10px 0;
+ border-bottom: 1px solid var(--border);
+}
+.gb-comment:last-child {
+ border-bottom: none;
+}
+
+.gb-comment__header {
+ display: flex;
+ align-items: baseline;
+ gap: 8px;
+ margin-bottom: 4px;
+ font-size: 12px;
+}
+.gb-comment__header strong {
+ color: var(--accent);
+}
+
+.gb-comment__date {
+ color: var(--text-3);
+ font-size: 11px;
+}
+
+.gb-comment__body {
+ font-size: 13px;
+ line-height: 1.5;
+ color: var(--text);
+ white-space: pre-wrap;
+ word-break: break-word;
+}
+
+/* ── Add Comment ─────────────────────────── */
+
+.gb-modal__add-comment {
+ margin-top: 16px;
+ padding-top: 16px;
+ border-top: 1px solid var(--border);
+}
+
+.gb-modal__textarea {
+ width: 100%;
+ background: var(--bg-raised);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ color: var(--text);
+ font-family: var(--font);
+ font-size: 13px;
+ padding: 8px 10px;
+ resize: vertical;
+ outline: none;
+ box-sizing: border-box;
+}
+.gb-modal__textarea:focus {
+ border-color: var(--accent);
+}
+
+.gb-modal__actions {
+ display: flex;
+ gap: 8px;
+ margin-top: 8px;
+ justify-content: flex-end;
+}
+
+/* ── Badge variants ──────────────────────── */
+
+.badge--green {
+ background: var(--green);
+ color: #fff;
+}
+.badge--muted {
+ background: var(--bg-raised);
+ color: var(--text-3);
+}
+
+.btn-danger {
+ background: var(--danger, #e53e3e);
+ color: #fff;
+ border: none;
+}
+.btn-danger:hover {
+ opacity: 0.9;
+}
diff --git a/packages/git-board/js/main.js b/packages/git-board/js/main.js
index 572a8ea..ef4e6fa 100644
--- a/packages/git-board/js/main.js
+++ b/packages/git-board/js/main.js
@@ -1,9 +1,11 @@
/**
* Git Board — Surface Entry Point
*
- * Kanban board for Gitea/GitHub issues and PRs.
+ * Kanban board for Gitea issues and PRs.
* Calls Starlark backend via /s/git-board/api/*.
- * Per-user API token via extension settings.
+ * Authentication via gitea-client library connections.
+ *
+ * Features: DnD between columns, issue detail modal with comments.
*/
(async function () {
'use strict';
@@ -33,16 +35,20 @@
}
var { html } = window;
- var { useState, useEffect, useCallback, useRef } = hooks;
+ var { useState, useEffect, useCallback, useRef, useMemo } = hooks;
var { render } = preact;
var API = base + '/s/' + slug + '/api';
- async function api(path) {
+ async function api(path, opts) {
var token = sw.auth._getToken();
- var resp = await fetch(API + path, {
- headers: { 'Authorization': 'Bearer ' + token }
- });
+ var fetchOpts = { headers: { 'Authorization': 'Bearer ' + token } };
+ if (opts && opts.method) fetchOpts.method = opts.method;
+ if (opts && opts.body) {
+ fetchOpts.body = JSON.stringify(opts.body);
+ fetchOpts.headers['Content-Type'] = 'application/json';
+ }
+ var resp = await fetch(API + path, fetchOpts);
var text = await resp.text();
try { return JSON.parse(text); } catch (_) { return { error: text }; }
}
@@ -50,26 +56,27 @@
// ── App ────────────────────────────────────
function App() {
- var [owner, setOwner] = useState('');
- var [repo, setRepo] = useState('');
- var [repos, setRepos] = useState([]);
- var [board, setBoard] = useState(null);
+ var [owner, setOwner] = useState('');
+ var [repo, setRepo] = useState('');
+ var [repos, setRepos] = useState([]);
+ var [board, setBoard] = useState(null);
var [loading, setLoading] = useState(false);
- var [needsToken, setNeedsToken] = useState(false);
+ var [needsConn, setNeedsConn] = useState(false);
+ var [modal, setModal] = useState(null); // {owner, repo, number}
var menuRef = useRef(null);
- // Mount user menu
useEffect(function () {
if (menuRef.current && sw.userMenu) {
- sw.userMenu(menuRef.current, { placement: 'down-right' });
+ sw.userMenu(menuRef.current, { placement: 'down-left' });
}
}, []);
- // Load repos on mount
useEffect(function () {
api('/repos').then(function (d) {
- if (d.error && d.error.indexOf('failed') !== -1) {
- setNeedsToken(true);
+ if (d.error) {
+ if (d.error.indexOf('no gitea connection configured') !== -1) {
+ setNeedsConn(true);
+ } else { sw.toast(d.error, 'error'); }
return;
}
setRepos(d.data || []);
@@ -87,10 +94,10 @@
.then(function (d) {
if (d.error) {
sw.toast(d.error, 'error');
- if (d.error.indexOf('failed') !== -1) setNeedsToken(true);
+ if (d.error.indexOf('no gitea connection configured') !== -1) setNeedsConn(true);
} else {
setBoard(d);
- setNeedsToken(false);
+ setNeedsConn(false);
}
setLoading(false);
});
@@ -98,71 +105,102 @@
useEffect(function () { loadBoard(); }, [loadBoard]);
- if (needsToken) return html`
- <${TokenSetup} menuRef=${menuRef} />
- `;
+ var onDrop = useCallback(function (issueNumber, targetCol) {
+ if (!board) return;
+ var issue = (board.issues || []).find(function (i) { return i.number === issueNumber; });
+ if (!issue) return;
+
+ var patch = {};
+ if (targetCol === 'open') {
+ // Move to Open: unassign + reopen
+ if (issue.state === 'closed') patch.state = 'open';
+ // Note: Gitea PATCH /issues doesn't support clearing assignee via empty string,
+ // but we do our best — the board will re-split on refresh.
+ } else if (targetCol === 'in_progress') {
+ // Move to In Progress: assign to current user + ensure open
+ if (issue.state === 'closed') patch.state = 'open';
+ // Gitea needs assignees array — not supported by our simple update_issue.
+ // For now, just reopen. User assigns via modal.
+ if (issue.state === 'closed') patch.state = 'open';
+ } else if (targetCol === 'done') {
+ patch.state = 'closed';
+ }
+
+ if (!patch.state) return; // no meaningful change
+
+ // Optimistic update
+ var newIssues = (board.issues || []).map(function (i) {
+ if (i.number !== issueNumber) return i;
+ var copy = {};
+ for (var k in i) copy[k] = i[k];
+ if (patch.state) copy.state = patch.state;
+ return copy;
+ });
+ setBoard({ issues: newIssues, pull_requests: board.pull_requests || [] });
+
+ api('/issue/' + owner + '/' + repo + '/' + issueNumber, {
+ method: 'POST', body: patch
+ }).then(function (d) {
+ if (d.error) {
+ sw.toast('Update failed: ' + d.error, 'error');
+ loadBoard(); // revert
+ }
+ });
+ }, [board, owner, repo, loadBoard]);
+
+ var openModal = useCallback(function (number) {
+ setModal({ owner: owner, repo: repo, number: number });
+ }, [owner, repo]);
+
+ var closeModal = useCallback(function (changed) {
+ setModal(null);
+ if (changed) loadBoard();
+ }, [loadBoard]);
return html`
-
-
-
- ${board ? html`<${Board} data=${board} />` : html`
-
${loading ? 'Loading…' : 'Select a repository'}
- `}
-
+
+ ${needsConn ? html`<${ConnectionSetup} />` : html`
+
+
+ ${board ? html`<${Board} data=${board} onDrop=${onDrop} onCardClick=${openModal} />` : html`
+
${loading ? 'Loading…' : 'Select a repository'}
+ `}
+
+ `}
+ ${modal && html`<${IssueModal} ...${modal} onClose=${closeModal} />`}
`;
}
- // ── Token Setup ────────────────────────────
-
- function TokenSetup({ menuRef }) {
- var [token, setToken] = useState('');
- var [saving, setSaving] = useState(false);
-
- async function save() {
- if (!token.trim()) return;
- setSaving(true);
- try {
- await sw.api.extensions.updateUserSettings('git-board', { api_token: token.trim() });
- sw.toast('Token saved — reloading', 'success');
- setTimeout(function () { location.reload(); }, 500);
- } catch (e) {
- sw.toast(e.message || 'Failed to save', 'error');
- setSaving(false);
- }
- }
+ // ── Connection Setup ─────────────────────────
+ function ConnectionSetup() {
return html`
-
Connect Your Account
-
Enter your personal access token for Gitea or GitHub.
- The admin configures the platform URL. You provide your own token.
-
-
-
-
-
Token is stored in your personal extension settings — not shared with other users.
+
Connect to Gitea
+
Git Board requires a Gitea connection to fetch repositories, issues, and pull requests.
+
Ask your admin to add a Gitea connection in
+ Admin → Connections, or add a personal one in
+ Settings → Connections.
+
+
Connections are managed centrally — no API tokens in extension settings.
`;
@@ -185,41 +223,52 @@
`;
}
- // ── Kanban Board ───────────────────────────
+ // ── Kanban Board with DnD ─────────────────
- var COLUMNS = [
- { key: 'open', title: 'Open', filter: function (i) { return i.state === 'open'; } },
- { key: 'in_progress', title: 'In Progress', filter: function (i) { return i.assignee && i.state === 'open'; } },
- { key: 'prs', title: 'Pull Requests', isPR: true },
- { key: 'closed', title: 'Done', filter: function (i) { return i.state === 'closed'; } },
- ];
-
- function Board({ data }) {
+ function Board({ data, onDrop, onCardClick }) {
var issues = data.issues || [];
var prs = data.pull_requests || [];
- // Split issues: unassigned open vs assigned open
var openUnassigned = issues.filter(function (i) { return i.state === 'open' && !i.assignee; });
var inProgress = issues.filter(function (i) { return i.state === 'open' && !!i.assignee; });
return html`
- <${Column} title="Open" count=${openUnassigned.length}>
- ${openUnassigned.map(function (i) { return html`<${IssueCard} key=${i.number} issue=${i} />`; })}
+ <${Column} id="open" title="Open" count=${openUnassigned.length} onDrop=${onDrop}>
+ ${openUnassigned.map(function (i) {
+ return html`<${IssueCard} key=${i.number} issue=${i} onClick=${onCardClick} />`;
+ })}
/>
- <${Column} title="In Progress" count=${inProgress.length}>
- ${inProgress.map(function (i) { return html`<${IssueCard} key=${i.number} issue=${i} />`; })}
+ <${Column} id="in_progress" title="In Progress" count=${inProgress.length} onDrop=${onDrop}>
+ ${inProgress.map(function (i) {
+ return html`<${IssueCard} key=${i.number} issue=${i} onClick=${onCardClick} />`;
+ })}
/>
- <${Column} title="Pull Requests" count=${prs.length}>
+ <${Column} id="done" title="Done" count=${0} onDrop=${onDrop}>
+ />
+ <${Column} id="prs" title="Pull Requests" count=${prs.length}>
${prs.map(function (p) { return html`<${PRCard} key=${p.number} pr=${p} />`; })}
/>
`;
}
- function Column({ title, count, children }) {
+ function Column({ id, title, count, children, onDrop }) {
+ var [over, setOver] = useState(false);
+
+ var handlers = onDrop ? {
+ onDragOver: function (e) { e.preventDefault(); setOver(true); },
+ onDragLeave: function () { setOver(false); },
+ onDrop: function (e) {
+ e.preventDefault();
+ setOver(false);
+ var num = parseInt(e.dataTransfer.getData('text/plain'), 10);
+ if (num && onDrop) onDrop(num, id);
+ }
+ } : {};
+
return html`
-
+
${title}
${count || 0}
@@ -229,9 +278,17 @@
`;
}
- function IssueCard({ issue }) {
+ function IssueCard({ issue, onClick }) {
return html`
-
+
-
+
`;
}
@@ -262,6 +319,153 @@
`;
}
+ // ── Issue Detail Modal ────────────────────
+
+ function IssueModal({ owner, repo, number, onClose }) {
+ var [issue, setIssue] = useState(null);
+ var [loading, setLoading] = useState(true);
+ var [comment, setComment] = useState('');
+ var [posting, setPosting] = useState(false);
+ var [changed, setChanged] = useState(false);
+ var bodyRef = useRef(null);
+
+ useEffect(function () {
+ setLoading(true);
+ api('/issue/' + owner + '/' + repo + '/' + number).then(function (d) {
+ if (d.error) { sw.toast(d.error, 'error'); onClose(false); return; }
+ setIssue(d);
+ setLoading(false);
+ });
+ }, [owner, repo, number]);
+
+ // Close on Escape
+ useEffect(function () {
+ var handler = function (e) { if (e.key === 'Escape') onClose(changed); };
+ document.addEventListener('keydown', handler);
+ return function () { document.removeEventListener('keydown', handler); };
+ }, [changed]);
+
+ var postComment = useCallback(function () {
+ if (!comment.trim() || posting) return;
+ setPosting(true);
+ api('/issue/' + owner + '/' + repo + '/' + number + '/comment', {
+ method: 'POST', body: { body: comment.trim() }
+ }).then(function (d) {
+ if (d.error) { sw.toast(d.error, 'error'); }
+ else {
+ // Append to local comments
+ setIssue(function (prev) {
+ if (!prev) return prev;
+ var copy = {};
+ for (var k in prev) copy[k] = prev[k];
+ copy.comments = (prev.comments || []).concat([d]);
+ return copy;
+ });
+ setComment('');
+ setChanged(true);
+ }
+ setPosting(false);
+ });
+ }, [comment, posting, owner, repo, number]);
+
+ var toggleState = useCallback(function () {
+ if (!issue) return;
+ var newState = issue.state === 'open' ? 'closed' : 'open';
+ api('/issue/' + owner + '/' + repo + '/' + number, {
+ method: 'POST', body: { state: newState }
+ }).then(function (d) {
+ if (d.error) { sw.toast(d.error, 'error'); return; }
+ setIssue(function (prev) {
+ if (!prev) return prev;
+ var copy = {};
+ for (var k in prev) copy[k] = prev[k];
+ copy.state = newState;
+ return copy;
+ });
+ setChanged(true);
+ });
+ }, [issue, owner, repo, number]);
+
+ return html`
+
+
+
+
+ ${!loading && issue && html`
+
+
+
+ ${issue.labels && issue.labels.length > 0 && html`
+
+ ${issue.labels.map(function (l) { return html`${esc(l)}`; })}
+
+ `}
+
+
+ ${issue.body ? html`
${esc(issue.body)}`
+ : html`
No description.
`}
+
+
+
+
+
+
+ `}
+
+
+ `;
+ }
+
// ── Utilities ──────────────────────────────
function esc(s) {
diff --git a/packages/git-board/manifest.json b/packages/git-board/manifest.json
index d440a11..a3cc0a5 100644
--- a/packages/git-board/manifest.json
+++ b/packages/git-board/manifest.json
@@ -6,15 +6,21 @@
"route": "/s/git-board",
"auth": "authenticated",
"layout": "single",
- "version": "0.1.0",
- "description": "Gitea/GitHub issue and PR board with LLM tools for reading, editing, and commenting. Per-user API key configuration.",
+ "version": "0.2.0",
+ "description": "Gitea issue and PR board powered by the gitea-client library. Uses extension connections for authentication.",
"author": "switchboard",
- "permissions": ["api.http"],
+ "permissions": ["connections.read"],
+
+ "dependencies": {
+ "gitea-client": ">=1.0.0"
+ },
"api_routes": [
{"method": "GET", "path": "/repos"},
{"method": "GET", "path": "/board"},
+ {"method": "GET", "path": "/issue/*"},
+ {"method": "POST", "path": "/issue/*"},
{"method": "GET", "path": "/ci/*"}
],
@@ -91,24 +97,6 @@
],
"settings": {
- "platform": {
- "type": "string",
- "label": "Platform",
- "description": "Git platform type: gitea or github",
- "default": "gitea"
- },
- "base_url": {
- "type": "string",
- "label": "Base URL",
- "description": "Git platform API base URL (e.g. https://git.gobha.me)",
- "required": true
- },
- "api_token": {
- "type": "string",
- "label": "API Token (personal)",
- "description": "Your personal access token. Set per-user in Settings > Extensions.",
- "default": ""
- },
"default_owner": {
"type": "string",
"label": "Default Owner/Org",
diff --git a/packages/git-board/script.star b/packages/git-board/script.star
index 1a2d403..ba66282 100644
--- a/packages/git-board/script.star
+++ b/packages/git-board/script.star
@@ -1,13 +1,24 @@
-# Git Board — Starlark Backend
+# Git Board — Starlark Backend (v0.2.0)
+#
+# Library consumer — delegates all Gitea API work to gitea-client.
#
# Entry points:
# on_request(req) → surface API routes
# on_tool_call(tool_name, params) → LLM tool execution
#
# Modules:
-# http — outbound HTTP to git platform (requires api.http permission)
-# settings — admin: platform, base_url / user: api_token, default_owner, default_repo
-# json — json.encode() / json.decode() (universal, no permission needed)
+# connections — resolve gitea connection (connections.read)
+# lib — load gitea-client library
+# json — encode/decode (universal)
+# settings — user: default_owner, default_repo
+
+gitea = lib.require("gitea-client")
+
+
+def _conn():
+ """Resolve the first available gitea connection."""
+ return connections.get("gitea")
+
# ═══════════════════════════════════════════════
# Surface API routes (/s/git-board/api/*)
@@ -17,39 +28,33 @@ def on_request(req):
path = req["path"]
method = req["method"]
+ conn = _conn()
+ if conn == None:
+ return _resp(400, {"error": "no gitea connection configured — add one in Settings > Connections"})
+
if method == "GET" and path == "/repos":
- return _handle_repos(req)
+ return _handle_repos(conn)
elif method == "GET" and path == "/board":
- return _handle_board(req)
+ return _handle_board(conn, req)
+ elif method == "GET" and path.startswith("/issue/"):
+ return _handle_get_issue(conn, path[len("/issue/"):])
+ elif method == "POST" and path.startswith("/issue/"):
+ return _handle_post_issue(conn, path[len("/issue/"):], req)
elif method == "GET" and path.startswith("/ci/"):
- return _handle_ci(req, path[len("/ci/"):])
+ return _handle_ci(conn, path[len("/ci/"):])
return _resp(404, {"error": "not found"})
-def _handle_repos(req):
- """List repositories accessible to the user."""
- platform = _platform()
- data = _git_get(_repos_path(platform))
- if data == None:
- return _resp(502, {"error": "git platform request failed"})
-
- # Normalize — Gitea returns list, GitHub returns {items: [...]}
- repos = data if type(data) == "list" else data.get("items", data.get("repositories", []))
- 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 _resp(200, {"data": out})
+def _handle_repos(conn):
+ """List repositories accessible via the connection."""
+ repos = gitea.get_repos(conn)
+ if repos == None:
+ return _resp(502, {"error": "gitea request failed"})
+ return _resp(200, {"data": repos})
-def _handle_board(req):
+def _handle_board(conn, req):
"""Combined issues + PRs for kanban board."""
q = req.get("query", {})
owner = _str(q.get("owner", "")) or settings.get("default_owner") or ""
@@ -57,51 +62,64 @@ def _handle_board(req):
if not owner or not repo:
return _resp(400, {"error": "owner and repo required (query params or user settings)"})
- platform = _platform()
-
- issues = _git_get(_issues_path(platform, owner, repo, "open")) or []
- prs = _git_get(_prs_path(platform, owner, repo, "open")) or []
-
- board = {"issues": [], "pull_requests": []}
- for i in issues:
- labels = [l.get("name", "") for l in (i.get("labels") or [])]
- board["issues"].append({
- "number": i.get("number", 0),
- "title": i.get("title", ""),
- "state": i.get("state", ""),
- "labels": labels,
- "assignee": (i.get("assignee") or {}).get("login", ""),
- "created_at": i.get("created_at", ""),
- "updated_at": i.get("updated_at", ""),
- "html_url": i.get("html_url", ""),
- })
- for p in prs:
- board["pull_requests"].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", ""),
- "mergeable": p.get("mergeable", None),
- "user": (p.get("user") or {}).get("login", ""),
- "created_at": p.get("created_at", ""),
- "html_url": p.get("html_url", ""),
- })
+ issues = gitea.get_issues(conn, owner, repo, "open") or []
+ prs = gitea.get_prs(conn, owner, repo, "open") or []
+ board = {"issues": issues, "pull_requests": prs}
return _resp(200, board)
-def _handle_ci(req, ref_path):
+def _handle_get_issue(conn, issue_path):
+ """Get issue detail: /issue/:owner/:repo/:number"""
+ parts = issue_path.split("/", 2)
+ if len(parts) < 3:
+ return _resp(400, {"error": "path must be /issue/:owner/:repo/:number"})
+ owner, repo, num = parts[0], parts[1], int(parts[2])
+ data = gitea.get_issue(conn, owner, repo, num)
+ if data == None:
+ return _resp(404, {"error": "issue not found"})
+ return _resp(200, data)
+
+
+def _handle_post_issue(conn, issue_path, req):
+ """Update issue or add comment: /issue/:owner/:repo/:number[/comment]"""
+ parts = issue_path.split("/", 3)
+ if len(parts) < 3:
+ return _resp(400, {"error": "path must be /issue/:owner/:repo/:number"})
+ owner, repo, num = parts[0], parts[1], int(parts[2])
+ action = parts[3] if len(parts) > 3 else ""
+
+ body = json.decode(req.get("body", "{}"))
+
+ if action == "comment":
+ text = body.get("body", "")
+ if not text:
+ return _resp(400, {"error": "comment body required"})
+ result = gitea.add_comment(conn, owner, repo, num, text)
+ if result == None:
+ return _resp(502, {"error": "failed to add comment"})
+ return _resp(201, result)
+
+ # Default: update issue fields (state, title, body, assignee)
+ title = body.get("title", "")
+ issue_body = body.get("body", "")
+ state = body.get("state", "")
+ result = gitea.update_issue(conn, owner, repo, num, title, issue_body, state)
+ if result == None:
+ return _resp(502, {"error": "failed to update issue"})
+ return _resp(200, result)
+
+
+def _handle_ci(conn, ref_path):
"""CI status for owner/repo/ref."""
parts = ref_path.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]
- platform = _platform()
- data = _git_get(_ci_path(platform, owner, repo, ref))
- if data == None:
+ result = gitea.get_ci_status(conn, owner, repo, ref)
+ if result == None:
return _resp(502, {"error": "CI status request failed"})
- return _resp(200, data)
+ return _resp(200, result)
# ═══════════════════════════════════════════════
@@ -109,211 +127,70 @@ def _handle_ci(req, ref_path):
# ═══════════════════════════════════════════════
def on_tool_call(tool_name, params):
- platform = _platform()
+ conn = _conn()
+ if conn == None:
+ return {"error": "no gitea connection configured — add one in Settings > Connections"}
+
owner = params.get("owner", "")
repo = params.get("repo", "")
if tool_name == "list_issues":
state = params.get("state", "open")
- data = _git_get(_issues_path(platform, owner, repo, state))
+ data = gitea.get_issues(conn, owner, repo, state)
if data == None:
return {"error": "failed to list issues"}
- 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 {"issues": items, "count": len(items)}
+ return {"issues": data, "count": len(data)}
elif tool_name == "get_issue":
num = int(params.get("number", 0))
- data = _git_get(_issue_path(platform, owner, repo, num))
+ data = gitea.get_issue(conn, owner, repo, num)
if data == None:
return {"error": "issue not found"}
- # Also fetch comments
- comments_data = _git_get(_issue_path(platform, owner, repo, num) + "/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,
- }
+ return data
elif tool_name == "create_issue":
- body = {"title": params.get("title", "")}
- if params.get("body", ""):
- body["body"] = params["body"]
- if params.get("labels", ""):
- body["labels"] = [l.strip() for l in params["labels"].split(",") if l.strip()]
- data = _git_post(_issues_path(platform, owner, repo, ""), body)
+ data = gitea.create_issue(conn, owner, repo,
+ params.get("title", ""),
+ params.get("body", ""),
+ params.get("labels", ""))
if data == None:
return {"error": "failed to create issue"}
- return {"number": data.get("number", 0), "title": data.get("title", ""), "html_url": data.get("html_url", "")}
+ return data
elif tool_name == "update_issue":
num = int(params.get("number", 0))
- patch = {}
- if params.get("title", ""):
- patch["title"] = params["title"]
- if params.get("body", ""):
- patch["body"] = params["body"]
- if params.get("state", ""):
- patch["state"] = params["state"]
- data = _git_patch(_issue_path(platform, owner, repo, num), patch)
+ data = gitea.update_issue(conn, owner, repo, num,
+ params.get("title", ""),
+ params.get("body", ""),
+ params.get("state", ""))
if data == None:
return {"error": "failed to update issue"}
- return {"number": data.get("number", 0), "state": data.get("state", ""), "title": data.get("title", "")}
+ return data
elif tool_name == "add_comment":
num = int(params.get("number", 0))
- data = _git_post(_issue_path(platform, owner, repo, num) + "/comments", {"body": params.get("body", "")})
+ data = gitea.add_comment(conn, owner, repo, num, params.get("body", ""))
if data == None:
return {"error": "failed to add comment"}
- return {"id": data.get("id", 0), "body": data.get("body", "")}
+ return data
elif tool_name == "list_pull_requests":
state = params.get("state", "open")
- data = _git_get(_prs_path(platform, owner, repo, state))
+ data = gitea.get_prs(conn, owner, repo, state)
if data == None:
return {"error": "failed to list PRs"}
- 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 {"pull_requests": items, "count": len(items)}
+ return {"pull_requests": data, "count": len(data)}
elif tool_name == "get_ci_status":
ref = params.get("ref", "")
- data = _git_get(_ci_path(platform, owner, repo, ref))
+ data = gitea.get_ci_status(conn, owner, repo, ref)
if data == None:
return {"error": "failed to get CI status"}
- # Normalize: Gitea returns {statuses: [...]}, GitHub returns {state, statuses: [...]}
- 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)}
+ return data
return {"error": "unknown tool: " + tool_name}
-# ═══════════════════════════════════════════════
-# Git platform abstraction
-# ═══════════════════════════════════════════════
-
-def _platform():
- return settings.get("platform") or "gitea"
-
-def _base_url():
- url = settings.get("base_url") or ""
- if url.endswith("/"):
- url = url[:-1]
- return url
-
-def _token():
- return settings.get("api_token") or ""
-
-def _api_prefix(platform):
- # Gitea: /api/v1, GitHub: "" (api.github.com already has /repos etc)
- if platform == "github":
- return ""
- return "/api/v1"
-
-def _repos_path(platform):
- if platform == "github":
- return "/user/repos?per_page=50&sort=updated"
- return "/repos/search?limit=50&sort=updated"
-
-def _issues_path(platform, owner, repo, state):
- if platform == "github":
- return "/repos/" + owner + "/" + repo + "/issues?state=" + state + "&per_page=50"
- return "/repos/" + owner + "/" + repo + "/issues?state=" + state + "&limit=50&type=issues"
-
-def _issue_path(platform, owner, repo, number):
- return "/repos/" + owner + "/" + repo + "/issues/" + str(number)
-
-def _prs_path(platform, owner, repo, state):
- if platform == "github":
- return "/repos/" + owner + "/" + repo + "/pulls?state=" + state + "&per_page=50"
- return "/repos/" + owner + "/" + repo + "/pulls?state=" + state + "&limit=50"
-
-def _ci_path(platform, owner, repo, ref):
- if platform == "github":
- return "/repos/" + owner + "/" + repo + "/commits/" + ref + "/status"
- return "/repos/" + owner + "/" + repo + "/commits/" + ref + "/status"
-
-
-# ═══════════════════════════════════════════════
-# HTTP helpers
-# ═══════════════════════════════════════════════
-
-def _auth_headers():
- token = _token()
- if not token:
- return {}
- platform = _platform()
- if platform == "github":
- return {"Authorization": "Bearer " + token, "Accept": "application/vnd.github.v3+json"}
- return {"Authorization": "token " + token}
-
-def _git_get(path):
- url = _base_url() + _api_prefix(_platform()) + path
- resp = http.get(url=url, headers=_auth_headers())
- if int(resp["status"]) >= 400:
- return None
- body = resp.get("body", "")
- if not body:
- return None
- return json.decode(body)
-
-def _git_post(path, data):
- url = _base_url() + _api_prefix(_platform()) + path
- resp = http.post(url=url, body=json.encode(data), headers=dict(_auth_headers(), **{"Content-Type": "application/json"}))
- if int(resp["status"]) >= 400:
- return None
- body = resp.get("body", "")
- if not body:
- return None
- return json.decode(body)
-
-def _git_patch(path, data):
- url = _base_url() + _api_prefix(_platform()) + path
- resp = http.post(url=url, body=json.encode(data), headers=dict(_auth_headers(), **{"Content-Type": "application/json", "X-HTTP-Method-Override": "PATCH"}))
- if int(resp["status"]) >= 400:
- return None
- body = resp.get("body", "")
- if not body:
- return None
- return json.decode(body)
-
-
# ═══════════════════════════════════════════════
# Response helpers
# ═══════════════════════════════════════════════
diff --git a/packages/gitea-client/manifest.json b/packages/gitea-client/manifest.json
index 71ce35f..b0fbc99 100644
--- a/packages/gitea-client/manifest.json
+++ b/packages/gitea-client/manifest.json
@@ -3,7 +3,7 @@
"title": "Gitea API Client",
"type": "library",
"tier": "starlark",
- "version": "1.0.0",
+ "version": "1.0.1",
"description": "Shared Gitea client — connections, API helpers, optional data caching.",
"author": "switchboard",
diff --git a/packages/gitea-client/script.star b/packages/gitea-client/script.star
index a361428..cca7b3e 100644
--- a/packages/gitea-client/script.star
+++ b/packages/gitea-client/script.star
@@ -15,9 +15,19 @@
# 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")
+load("star/repos.star", _repos_get = "get_repos")
+load("star/issues.star", _issues_get = "get_issues", _issue_get = "get_issue", _issue_create = "create_issue", _issue_update = "update_issue", _comment_add = "add_comment")
+load("star/ci.star", _ci_get = "get_ci_status")
+
+# Re-export loaded functions so lib.require() can find them in globals.
+# Starlark load() names are file-local; explicit assignment makes them global.
+get_repos = _repos_get
+get_issues = _issues_get
+get_issue = _issue_get
+create_issue = _issue_create
+update_issue = _issue_update
+add_comment = _comment_add
+get_ci_status = _ci_get
# ═══════════════════════════════════════════════
@@ -111,12 +121,15 @@ def get_prs(conn, owner, repo, state):
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", ""),
+ "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", ""),
+ "created_at": p.get("created_at", ""),
+ "html_url": p.get("html_url", ""),
+ "mergeable": p.get("mergeable", None),
})
return items
diff --git a/packages/gitea-client/star/issues.star b/packages/gitea-client/star/issues.star
index 32d0cb1..1d8a509 100644
--- a/packages/gitea-client/star/issues.star
+++ b/packages/gitea-client/star/issues.star
@@ -13,11 +13,14 @@ def get_issues(conn, owner, repo, state):
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", ""),
+ "number": i.get("number", 0),
+ "title": i.get("title", ""),
+ "state": i.get("state", ""),
+ "labels": labels,
+ "assignee": (i.get("assignee") or {}).get("login", ""),
+ "created_at": i.get("created_at", ""),
+ "updated_at": i.get("updated_at", ""),
+ "html_url": i.get("html_url", ""),
})
return items
diff --git a/packages/sdk-test-runner/js/domains/git-board.js b/packages/sdk-test-runner/js/domains/git-board.js
new file mode 100644
index 0000000..24c87ce
--- /dev/null
+++ b/packages/sdk-test-runner/js/domains/git-board.js
@@ -0,0 +1,471 @@
+/**
+ * SDK Test Runner — Domain: git-board (v0.38.5)
+ *
+ * Tests the real-world library composition pattern:
+ * gitea-client library (connection types, exports) → git-board consumer
+ * (dependency, tools, delegation). Validates the full package lifecycle
+ * with realistic manifests and Starlark scripts.
+ * Requires admin role.
+ */
+(function () {
+ 'use strict';
+ var T = window.SDKR;
+ if (!T) return;
+
+ // ── Minimal zip builder ────────────────────────
+ 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' });
+ }
+
+ // ── Starlark script content ────────────────────
+ // Realistic but self-contained — no real HTTP calls needed.
+
+ var LIB_HTTP_HELPERS = [
+ 'def auth_headers(conn):',
+ ' token = conn.get("api_token", "")',
+ ' if not token:',
+ ' return {}',
+ ' return {"Authorization": "token " + token}',
+ '',
+ 'def _base(conn):',
+ ' url = conn.get("base_url", "")',
+ ' if url.endswith("/"):',
+ ' url = url[:-1]',
+ ' return url',
+ '',
+ 'def gitea_get(conn, path):',
+ ' 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):',
+ ' 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):',
+ ' 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):',
+ ' return {"status": status, "body": json.encode(data), "headers": {"Content-Type": "application/json"}}',
+ ].join('\n');
+
+ var LIB_REPOS = [
+ 'load("star/http_helpers.star", "gitea_get")',
+ '',
+ 'def get_repos(conn):',
+ ' 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", ""),',
+ ' })',
+ ' return out',
+ ].join('\n');
+
+ var LIB_ISSUES = [
+ 'load("star/http_helpers.star", "gitea_get", "gitea_post", "gitea_patch")',
+ '',
+ 'def get_issues(conn, owner, repo, state):',
+ ' return []',
+ '',
+ 'def get_issue(conn, owner, repo, number):',
+ ' return {"number": number, "title": "stub", "body": "", "state": "open", "labels": [], "assignee": "", "created_at": "", "comments": []}',
+ '',
+ 'def create_issue(conn, owner, repo, title, body, labels):',
+ ' return {"number": 0, "title": title, "html_url": ""}',
+ '',
+ 'def update_issue(conn, owner, repo, number, title, body, state):',
+ ' return {"number": number, "state": state or "open", "title": title or "stub"}',
+ '',
+ 'def add_comment(conn, owner, repo, number, body):',
+ ' return {"id": 0, "body": body}',
+ ].join('\n');
+
+ var LIB_CI = [
+ 'load("star/http_helpers.star", "gitea_get")',
+ '',
+ 'def get_ci_status(conn, owner, repo, ref):',
+ ' return {"ref": ref, "state": "pending", "statuses": [], "count": 0}',
+ ].join('\n');
+
+ var LIB_SCRIPT = [
+ 'load("star/http_helpers.star", "gitea_get", "gitea_post", "gitea_patch", "auth_headers", "resp")',
+ 'load("star/repos.star", _repos_get = "get_repos")',
+ 'load("star/issues.star", _issues_get = "get_issues", _issue_get = "get_issue", _issue_create = "create_issue", _issue_update = "update_issue", _comment_add = "add_comment")',
+ 'load("star/ci.star", _ci_get = "get_ci_status")',
+ '',
+ '# Re-export for lib.require() visibility',
+ 'get_repos = _repos_get',
+ 'get_issues = _issues_get',
+ 'get_issue = _issue_get',
+ 'create_issue = _issue_create',
+ 'update_issue = _issue_update',
+ 'add_comment = _comment_add',
+ 'get_ci_status = _ci_get',
+ '',
+ 'def get_prs(conn, owner, repo, state):',
+ ' return []',
+ '',
+ 'def on_request(req):',
+ ' conn = connections.get("gitea")',
+ ' if conn == None:',
+ ' return resp(400, {"error": "no connection"})',
+ ' return resp(200, {"ok": True})',
+ ].join('\n');
+
+ var CONSUMER_SCRIPT = [
+ 'gitea = lib.require("{LIB_ID}")',
+ '',
+ 'def _conn():',
+ ' return connections.get("gitea")',
+ '',
+ 'def on_request(req):',
+ ' conn = _conn()',
+ ' if conn == None:',
+ ' return {"status": 400, "body": json.encode({"error": "no connection"}), "headers": {"Content-Type": "application/json"}}',
+ ' return {"status": 200, "body": json.encode({"ok": True}), "headers": {"Content-Type": "application/json"}}',
+ '',
+ 'def on_tool_call(tool_name, params):',
+ ' conn = _conn()',
+ ' if conn == None:',
+ ' return {"error": "no connection"}',
+ ' if tool_name == "list_issues":',
+ ' data = gitea.get_issues(conn, params.get("owner",""), params.get("repo",""), "open")',
+ ' return {"issues": data or [], "count": len(data or [])}',
+ ' return {"error": "unknown tool"}',
+ ].join('\n');
+
+ // ── Tests ──────────────────────────────────────
+
+ T.registerDomain('git-board', async function () {
+
+ if (!window.sw || !sw.isAdmin) {
+ await T.test('git-board', 'guard', 'skip — not admin', async function () {
+ T.assert(true, 'skipped (user is not admin)');
+ });
+ return;
+ }
+
+ var ts = Date.now();
+ var libId = 'gb-test-lib-' + ts;
+ var consumerId = 'gb-test-board-' + ts;
+ var connType = 'gb-test-gitea-' + ts;
+ var connId = null;
+
+ // ── 1. Install library with multi-file structure + connection type ──
+
+ await T.test('git-board', 'install-library', 'install gitea-client-style library with multi-file and connection type', async function () {
+ var manifest = {
+ id: libId,
+ title: 'GB Test Gitea Client',
+ type: 'library',
+ tier: 'starlark',
+ version: '1.0.1',
+ exports: ['get_repos', 'get_issues', 'get_issue', 'create_issue', 'update_issue', 'add_comment', 'get_prs', 'get_ci_status'],
+ permissions: ['api.http', 'connections.read', 'db.read', 'db.write'],
+ connections: [{
+ type: connType,
+ label: 'Test 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']
+ }],
+ api_routes: [
+ { method: 'GET', path: '/repos' },
+ { method: 'GET', path: '/issues' }
+ ],
+ db_tables: {
+ repos: {
+ columns: { full_name: 'text', owner: 'text', name: 'text' },
+ indexes: []
+ }
+ },
+ schema_version: 1
+ };
+ var file = makePkgFile(manifest, [
+ { name: 'script.star', content: LIB_SCRIPT },
+ { name: 'star/http_helpers.star', content: LIB_HTTP_HELPERS },
+ { name: 'star/repos.star', content: LIB_REPOS },
+ { name: 'star/issues.star', content: LIB_ISSUES },
+ { name: 'star/ci.star', content: LIB_CI }
+ ]);
+ 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('git-board', 'grant-lib-perms', 'grant library permissions', async function () {
+ await sw.api.post('/api/v1/admin/extensions/' + libId + '/permissions/grant-all', {});
+ T.assert(true, 'permissions granted');
+ });
+
+ // ── 3. Connection type appears ──
+
+ await T.test('git-board', 'conn-type-registered', 'connection type registered from library manifest', 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, 'connection type should appear: ' + connType);
+ T.assert(found.package_id === libId, 'package_id should be library');
+ 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');
+ T.assert(found.fields.org, 'should have org field');
+ });
+
+ // ── 4. Install consumer with dependency + tools ──
+
+ await T.test('git-board', 'install-consumer', 'install git-board-style consumer with dependency and tools', async function () {
+ var manifest = {
+ id: consumerId,
+ title: 'GB Test Board',
+ type: 'full',
+ tier: 'starlark',
+ route: '/s/' + consumerId,
+ auth: 'authenticated',
+ layout: 'single',
+ version: '0.2.0',
+ permissions: ['connections.read'],
+ dependencies: {},
+ api_routes: [
+ { method: 'GET', path: '/repos' },
+ { method: 'GET', path: '/board' },
+ { method: 'GET', path: '/ci/*' }
+ ],
+ tools: [
+ { name: 'list_issues', description: 'List issues', parameters: { owner: { type: 'string', required: true }, repo: { type: 'string', required: true } } },
+ { name: 'get_issue', description: 'Get issue', parameters: { owner: { type: 'string', required: true }, repo: { type: 'string', required: true }, number: { type: 'number', required: true } } },
+ { name: 'create_issue', description: 'Create issue', parameters: { owner: { type: 'string', required: true }, repo: { type: 'string', required: true }, title: { type: 'string', required: true } } },
+ { name: 'update_issue', description: 'Update issue', parameters: { owner: { type: 'string', required: true }, repo: { type: 'string', required: true }, number: { type: 'number', required: true } } },
+ { name: 'add_comment', description: 'Add comment', parameters: { owner: { type: 'string', required: true }, repo: { type: 'string', required: true }, number: { type: 'number', required: true }, body: { type: 'string', required: true } } },
+ { name: 'list_pull_requests', description: 'List PRs', parameters: { owner: { type: 'string', required: true }, repo: { type: 'string', required: true } } },
+ { name: 'get_ci_status', description: 'Get CI status', parameters: { owner: { type: 'string', required: true }, repo: { type: 'string', required: true }, ref: { type: 'string', required: true } } }
+ ],
+ settings: {
+ default_owner: { type: 'string', label: 'Default Owner', default: '' },
+ default_repo: { type: 'string', label: 'Default Repo', default: '' }
+ }
+ };
+ manifest.dependencies[libId] = '>=1.0.0';
+ var script = CONSUMER_SCRIPT.replace('{LIB_ID}', libId);
+ var file = makePkgFile(manifest, [
+ { name: 'script.star', content: script }
+ ]);
+ var r = await sw.api.admin.packages.install(file);
+ T.assert(r && r.id === consumerId, 'consumer should install');
+ T.assert(r.type === 'full', 'type should be full');
+ });
+
+ T.cleanup.push(async function () {
+ if (consumerId) try { await sw.api.admin.packages.del(consumerId); } catch (_) {}
+ });
+
+ // ── 5. Grant consumer permissions ──
+
+ await T.test('git-board', 'grant-consumer-perms', 'grant consumer permissions', async function () {
+ await sw.api.post('/api/v1/admin/extensions/' + consumerId + '/permissions/grant-all', {});
+ T.assert(true, 'permissions granted');
+ });
+
+ // ── 6. Dependency recorded ──
+
+ await T.test('git-board', 'dependency-recorded', 'consumer depends on library', async function () {
+ var r = await sw.api.admin.packages.dependencies(consumerId);
+ // SDK unwraps {data:[...]} to bare array
+ var deps = Array.isArray(r) ? r : (r && r.data) || [];
+ T.assert(deps.length > 0, 'should have dependencies');
+ var dep = deps.find(function (d) { return d.library_id === libId; });
+ T.assert(dep, 'dependency on library should exist');
+ T.assert(dep.version_spec === '>=1.0.0', 'version spec should match');
+ });
+
+ // ── 7. Tools registered ──
+
+ await T.test('git-board', 'tools-registered', 'consumer has 7 tools registered', async function () {
+ var r = await sw.api.admin.packages.get(consumerId);
+ T.assert(r, 'package should exist');
+ var tools = r.tools || (r.manifest && r.manifest.tools) || [];
+ T.assert(tools.length === 7, 'should have 7 tools, got ' + tools.length);
+ var names = tools.map(function (t) { return t.name; }).sort();
+ T.assert(names.indexOf('list_issues') >= 0, 'should include list_issues');
+ T.assert(names.indexOf('get_ci_status') >= 0, 'should include get_ci_status');
+ T.assert(names.indexOf('add_comment') >= 0, 'should include add_comment');
+ });
+
+ // ── 8. Library consumers listed ──
+
+ await T.test('git-board', 'consumers-listed', 'library shows consumer in consumers list', async function () {
+ var r = await sw.api.admin.packages.consumers(libId);
+ // SDK unwraps {data:[...]} to bare array
+ var consumers = Array.isArray(r) ? r : (r && r.data) || [];
+ T.assert(consumers.length > 0, 'should have consumers');
+ var found = consumers.find(function (c) { return c.consumer_id === consumerId; });
+ T.assert(found, 'consumer should appear in library consumers list');
+ });
+
+ // ── 9. Uninstall protection ──
+
+ await T.test('git-board', 'uninstall-protection', 'cannot uninstall library while consumer exists', async function () {
+ var threw = false;
+ try {
+ await sw.api.admin.packages.del(libId);
+ } catch (e) {
+ threw = true;
+ var msg = (e && e.message) || String(e);
+ T.assert(msg.indexOf('409') >= 0 || msg.indexOf('active') >= 0 || msg.indexOf('depend') >= 0,
+ 'should be 409 or dependency error: ' + msg);
+ }
+ T.assert(threw, 'delete should have thrown');
+ });
+
+ // ── 10. Create connection of library type ──
+
+ await T.test('git-board', 'create-connection', 'create connection using library-declared type', async function () {
+ var r = await sw.api.admin.connections.create({
+ type: connType,
+ package_id: libId,
+ name: 'GB Test Connection',
+ config: { base_url: 'https://test.example.com', api_token: 'test-token-gb', org: 'test-org' }
+ });
+ 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 (_) {}
+ });
+
+ // ── Cleanup ──────────────────────────────────
+
+ await T.test('git-board', 'cleanup-connection', 'delete test connection', async function () {
+ if (connId) {
+ await sw.api.admin.connections.del(connId);
+ connId = null;
+ }
+ T.assert(true, 'connection deleted');
+ });
+
+ await T.test('git-board', 'cleanup-consumer', 'uninstall consumer', async function () {
+ await sw.api.admin.packages.del(consumerId);
+ consumerId = null;
+ T.assert(true, 'consumer uninstalled');
+ });
+
+ await T.test('git-board', 'cleanup-library', 'uninstall library', async function () {
+ await sw.api.admin.packages.del(libId);
+ libId = null;
+ T.assert(true, 'library uninstalled');
+ });
+
+ await T.test('git-board', '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/main.js b/packages/sdk-test-runner/js/main.js
index 3ae5346..9f9bab0 100644
--- a/packages/sdk-test-runner/js/main.js
+++ b/packages/sdk-test-runner/js/main.js
@@ -78,6 +78,7 @@
'domains/connections.js',
'domains/dependencies.js',
'domains/composition.js',
+ 'domains/git-board.js',
// UI (must come last)
'ui.js'
];
diff --git a/server/handlers/connection_resolver.go b/server/handlers/connection_resolver.go
index 151913e..1bbfb79 100644
--- a/server/handlers/connection_resolver.go
+++ b/server/handlers/connection_resolver.go
@@ -63,13 +63,28 @@ func (r *ConnectionResolverAdapter) decryptInPlace(ctx context.Context, conn *mo
if len(enc) == 0 {
continue
}
- plaintext, err := r.vault.Decrypt(enc, nonce, conn.Scope, conn.OwnerID)
- if err == nil {
+ plaintext := r.safeDecrypt(enc, nonce, conn.Scope, conn.OwnerID)
+ if plaintext != "" {
conn.Config[k] = plaintext
}
}
}
+// safeDecrypt wraps vault.Decrypt to recover from GCM panics caused by
+// invalid nonce lengths (e.g. stale vault keys after container restart).
+func (r *ConnectionResolverAdapter) safeDecrypt(enc, nonce []byte, scope, ownerID string) (plaintext string) {
+ defer func() {
+ if rv := recover(); rv != nil {
+ plaintext = ""
+ }
+ }()
+ result, err := r.vault.Decrypt(enc, nonce, scope, ownerID)
+ if err != nil {
+ return ""
+ }
+ return result
+}
+
// lookupSecretFields reads the package manifest to identify secret fields.
func (r *ConnectionResolverAdapter) lookupSecretFields(ctx context.Context, packageID, connType string) map[string]bool {
result := map[string]bool{}
diff --git a/server/handlers/connections.go b/server/handlers/connections.go
index 0017462..9812784 100644
--- a/server/handlers/connections.go
+++ b/server/handlers/connections.go
@@ -4,6 +4,7 @@ package handlers
// Pattern follows apiconfigs.go (ProviderConfigHandler).
import (
+ "encoding/base64"
"encoding/json"
"net/http"
@@ -27,7 +28,9 @@ func NewConnectionHandler(s store.Stores, vault *crypto.KeyResolver) *Connection
// ListConnections returns the user's personal connections.
func (h *ConnectionHandler) ListConnections(c *gin.Context) {
userID := getUserID(c)
- conns, err := h.stores.Connections.ListForUser(c.Request.Context(), userID)
+ // v0.38.5: Show all accessible connections (personal + team + global)
+ // so users can see which connections are available to them.
+ conns, err := h.stores.Connections.ListAccessible(c.Request.Context(), userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list connections"})
return
@@ -252,13 +255,23 @@ func (h *ConnectionHandler) decryptSecrets(config models.JSONMap, secretFields m
}
// toBytes coerces an interface{} to []byte. Handles both []byte (from Go)
-// and string (from JSON unmarshal of base64).
+// and string (from JSON unmarshal — base64 encoded by json.Marshal for []byte).
func toBytes(v interface{}) []byte {
switch t := v.(type) {
case []byte:
return t
case string:
- return []byte(t)
+ if t == "" {
+ return nil
+ }
+ // JSON round-trip: json.Marshal encodes []byte as base64 strings.
+ // Decode back to the original bytes.
+ decoded, err := base64.StdEncoding.DecodeString(t)
+ if err != nil {
+ // Fallback: might be raw string (shouldn't happen, but be safe)
+ return []byte(t)
+ }
+ return decoded
}
return nil
}
diff --git a/server/handlers/ext_api.go b/server/handlers/ext_api.go
index 8565900..206e91e 100644
--- a/server/handlers/ext_api.go
+++ b/server/handlers/ext_api.go
@@ -100,7 +100,12 @@ func (h *ExtAPIHandler) Handle(c *gin.Context) {
return
}
- // ── 3. Check api.http permission ───────────
+ // ── 3. Check permissions ──────────────────
+ // The package needs at least one granted permission to be active.
+ // api.http is NOT required here — a consumer package may delegate
+ // HTTP to a library via lib.require(). The sandbox wires the http
+ // module only when api.http is granted; without it the script simply
+ // cannot call http.get() directly, which is correct for consumers.
if h.stores.ExtPermissions == nil {
c.JSON(http.StatusForbidden, gin.H{"error": "permission system unavailable"})
return
@@ -110,8 +115,8 @@ func (h *ExtAPIHandler) Handle(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": "permission check failed"})
return
}
- if !hasPermission(granted, models.ExtPermAPIHTTP) {
- c.JSON(http.StatusForbidden, gin.H{"error": "extension does not have api.http permission"})
+ if len(granted) == 0 {
+ c.JSON(http.StatusForbidden, gin.H{"error": "extension has no granted permissions"})
return
}
diff --git a/server/handlers/packages.go b/server/handlers/packages.go
index ed5e7c1..472ddae 100644
--- a/server/handlers/packages.go
+++ b/server/handlers/packages.go
@@ -469,6 +469,11 @@ func (h *PackageHandler) InstallPackage(c *gin.Context) {
}
}
+ // v0.38.5: Declare manifest permissions (same as AdminInstallExtension).
+ // This creates the permission rows and sets status to pending_review
+ // if the package declares permissions.
+ SyncManifestPermissions(c, h.stores, pkgID, manifest)
+
// v0.30.0: Run schema migrations if declared.
newSchemaVersion := ParseSchemaVersion(manifest)
if newSchemaVersion > 0 && h.sandbox != nil {
diff --git a/server/main.go b/server/main.go
index df6c123..ccb0c23 100644
--- a/server/main.go
+++ b/server/main.go
@@ -416,6 +416,11 @@ func main() {
if cfg.StoragePath != "" {
starlarkRunner.SetPackagesDir(cfg.StoragePath + "/packages")
}
+ // v0.38.5: allow extensions to reach private IPs (self-hosted Gitea, etc.)
+ if os.Getenv("EXT_ALLOW_PRIVATE_IPS") == "true" {
+ starlarkRunner.SetAllowPrivateIPs(true)
+ log.Printf(" ⚠️ Extension SSRF protection relaxed: private IPs allowed")
+ }
// Discover and register active Starlark pre-completion filters
filters.DiscoverStarlarkFilters(context.Background(), filterChain, stores, starlarkRunner)
diff --git a/server/sandbox/http_module.go b/server/sandbox/http_module.go
index 2efcf11..72aaa9b 100644
--- a/server/sandbox/http_module.go
+++ b/server/sandbox/http_module.go
@@ -63,6 +63,12 @@ type HTTPModuleConfig struct {
// Ignored when AllowDomains is non-empty.
BlockDomains []string
+ // AllowPrivateIPs disables the SSRF check that blocks connections to
+ // private/loopback/link-local addresses. Use for self-hosted deployments
+ // where extensions need to reach internal services.
+ // Default: false (private IPs blocked).
+ AllowPrivateIPs bool
+
// Timeout overrides the default 10 s request timeout.
// Zero means use default.
Timeout time.Duration
@@ -345,11 +351,13 @@ func (ac *accessControl) checkDomain(host string) error {
return nil
}
-// client builds an SSRF-safe http.Client with IP validation on connect.
+// client builds an HTTP client with optional SSRF IP validation on connect.
func (ac *accessControl) client() *http.Client {
dialer := &net.Dialer{
Timeout: 5 * time.Second,
- Control: ssrfControl,
+ }
+ if !ac.cfg.AllowPrivateIPs {
+ dialer.Control = ssrfControl
}
transport := &http.Transport{
diff --git a/server/sandbox/runner.go b/server/sandbox/runner.go
index 38c0076..e6dcaaa 100644
--- a/server/sandbox/runner.go
+++ b/server/sandbox/runner.go
@@ -31,6 +31,8 @@ import (
"go.starlark.net/starlark"
+ starlarkjson "go.starlark.net/lib/json"
+
"chat-switchboard/models"
"chat-switchboard/store"
)
@@ -49,14 +51,15 @@ type RunContext struct {
// Runner executes Starlark package scripts with permission-gated modules.
type Runner struct {
- sandbox *Sandbox
- stores store.Stores
- packagesDir string // v0.38.0: disk path for load() support
- notifier NotificationSender // nil = notifications module unavailable
- resolver ProviderResolver // nil = provider module unavailable
- connResolver ConnectionResolver // nil = connections module unavailable (v0.38.1)
- db *sql.DB // nil = db module unavailable
- dbPostgres bool // true = use $N placeholders; false = use ?
+ sandbox *Sandbox
+ stores store.Stores
+ packagesDir string // v0.38.0: disk path for load() support
+ notifier NotificationSender // nil = notifications module unavailable
+ resolver ProviderResolver // nil = provider module unavailable
+ connResolver ConnectionResolver // nil = connections module unavailable (v0.38.1)
+ db *sql.DB // nil = db module unavailable
+ dbPostgres bool // true = use $N placeholders; false = use ?
+ allowPrivateIPs bool // v0.38.5: disable SSRF private IP check
}
// NewRunner creates a runner with the given sandbox and dependencies.
@@ -97,6 +100,13 @@ func (r *Runner) SetPackagesDir(dir string) {
r.packagesDir = dir
}
+// SetAllowPrivateIPs disables the SSRF check that blocks connections to
+// private/loopback IPs. For self-hosted environments where extensions
+// reach internal services. Controlled by EXT_ALLOW_PRIVATE_IPS env var.
+func (r *Runner) SetAllowPrivateIPs(allow bool) {
+ r.allowPrivateIPs = allow
+}
+
// ExecPackage loads a package's script from disk (primary) or manifest
// (legacy) and executes it with modules gated by granted permissions.
//
@@ -216,10 +226,13 @@ func (r *Runner) packageLoader(pkgID string, modules map[string]starlark.Value)
}
// Build predeclared with same modules as entry point
- predeclared := make(starlark.StringDict, len(modules)+1)
+ predeclared := make(starlark.StringDict, len(modules)+2)
for k, v := range modules {
predeclared[k] = v
}
+ // json is always available (added by ExecWithLoader for entry point;
+ // sub-modules need it too)
+ predeclared["json"] = starlarkjson.Module
globals, err := starlark.ExecFile(thread, module, string(data), predeclared)
if err != nil {
@@ -299,6 +312,7 @@ func (r *Runner) buildModulesWithLibCtx(ctx context.Context, packageID string, m
case models.ExtPermAPIHTTP:
httpCfg := ParseNetworkAccess(manifest)
+ httpCfg.AllowPrivateIPs = r.allowPrivateIPs
modules["http"] = BuildHTTPModule(ctx, httpCfg)
case models.ExtPermProviderComplete:
diff --git a/server/store/interfaces.go b/server/store/interfaces.go
index 845937b..90471fe 100644
--- a/server/store/interfaces.go
+++ b/server/store/interfaces.go
@@ -1392,6 +1392,10 @@ type ConnectionStore interface {
// to the user (personal + team + global).
ResolveAll(ctx context.Context, userID, connType string) ([]models.ExtConnection, error)
+ // ListAccessible returns all connections accessible to the user across
+ // all types: personal + team memberships + global.
+ ListAccessible(ctx context.Context, userID string) ([]models.ExtConnection, error)
+
// DeleteByIDAndScope deletes a connection only if it matches the given
// scope and owner. Returns rows affected (0 or 1).
DeleteByIDAndScope(ctx context.Context, id, scope, ownerID string) (int64, error)
diff --git a/server/store/postgres/ext_connection.go b/server/store/postgres/ext_connection.go
index bcd4608..973bf16 100644
--- a/server/store/postgres/ext_connection.go
+++ b/server/store/postgres/ext_connection.go
@@ -117,6 +117,26 @@ func (s *ConnectionStore) ResolveAll(ctx context.Context, userID, connType strin
return scanConnections(rows)
}
+// ListAccessible returns all connections accessible to the user across all types.
+func (s *ConnectionStore) ListAccessible(ctx context.Context, userID string) ([]models.ExtConnection, error) {
+ rows, err := DB.QueryContext(ctx, fmt.Sprintf(`
+ SELECT %s FROM ext_connections
+ WHERE is_active = true
+ AND (
+ (scope = 'personal' AND owner_id = $1)
+ OR (scope = 'team' AND owner_id IN (
+ SELECT team_id FROM team_members WHERE user_id = $1
+ ))
+ OR (scope = 'global')
+ )
+ ORDER BY type ASC, scope ASC, name ASC`, connCols), userID)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ return scanConnections(rows)
+}
+
// DeleteByIDAndScope deletes a connection only if it matches the scope/owner.
func (s *ConnectionStore) DeleteByIDAndScope(ctx context.Context, id, scope, ownerID string) (int64, error) {
res, err := DB.ExecContext(ctx,
diff --git a/server/store/sqlite/ext_connection.go b/server/store/sqlite/ext_connection.go
index da89ce4..680493b 100644
--- a/server/store/sqlite/ext_connection.go
+++ b/server/store/sqlite/ext_connection.go
@@ -122,6 +122,25 @@ func (s *ConnectionStore) ResolveAll(ctx context.Context, userID, connType strin
return scanConnections(rows)
}
+func (s *ConnectionStore) ListAccessible(ctx context.Context, userID string) ([]models.ExtConnection, error) {
+ rows, err := DB.QueryContext(ctx, fmt.Sprintf(`
+ SELECT %s FROM ext_connections
+ WHERE is_active = 1
+ AND (
+ (scope = 'personal' AND owner_id = ?)
+ OR (scope = 'team' AND owner_id IN (
+ SELECT team_id FROM team_members WHERE user_id = ?
+ ))
+ OR (scope = 'global')
+ )
+ ORDER BY type ASC, scope ASC, name ASC`, connCols), userID, userID)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ return scanConnections(rows)
+}
+
func (s *ConnectionStore) DeleteByIDAndScope(ctx context.Context, id, scope, ownerID string) (int64, error) {
res, err := DB.ExecContext(ctx,
`DELETE FROM ext_connections WHERE id = ? AND scope = ? AND owner_id = ?`,
diff --git a/src/js/sw/surfaces/settings/connections.js b/src/js/sw/surfaces/settings/connections.js
index 20fa554..13a38f9 100644
--- a/src/js/sw/surfaces/settings/connections.js
+++ b/src/js/sw/surfaces/settings/connections.js
@@ -185,25 +185,34 @@ export function ConnectionsSection() {
${connections.length > 0 && html`
- | Name | Type | Status | |
+ | Name | Type | Scope | Status | |
${connections.map(c => html`
| ${esc(c.name)} |
${esc(c.type)} |
+
+ ${esc(c.scope || 'personal')}
+ |
${c.is_active
? html`\u25CF Active`
: html`\u25CB Inactive`}
|
-
-
+ ${c.scope === 'personal' ? html`
+
+
+ ` : ''}
|
`)}
Comments (${(issue.comments || []).length})
+ ${(issue.comments || []).length === 0 && html` +No comments yet.
+ `} + ${(issue.comments || []).map(function (c, i) { + return html` +