feat(forge): per-user forge connections — keyring, host-keyed resolution, project pin (#2778)
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / TypeScript typecheck (push) Successful in 41s
CI & Build / integration (push) Successful in 37s
CI & Build / Python tests (push) Successful in 1m4s
CI & Build / Build & push image (push) Successful in 40s

A forge token is a user's credential, not an instance's. The single
admin-settings config is replaced by per-user keyring rows (one per forge
host), and every server-side forge read runs on the PROJECT OWNER's keyring:

- forge_connections table + projects.forge_connection_id pin (migration 0078,
  which also carries the existing admin config into the first admin's row and
  deletes the old setting keys — no legacy dual-read)
- get_forge() replaced by get_forges(owner_id, project_id) -> ForgeSelector;
  resolve(repo) picks the connection whose host serves the repo. A pinned
  project uses ONLY its pinned connection; a stale pin (ownership moved) is
  ignored, never honored across users
- env FORGE_* config survives as an implicit entry for admin owners only;
  a stored row for the same host beats it
- consumers threaded: pull-time freshness (owner of the note), coverage
  (owner of the project), coverage routes' configured flag
- routes: /api/settings/forge-connections CRUD + per-connection test
  (own-rows only, tokens never returned); /api/admin/forge shrinks to
  /api/admin/forge-webhook (secret only); PUT /api/projects/<id>/forge pins,
  owner-or-admin asking, owner's connections only
- UI: Git Forges card moves to Settings -> Integrations as a connection
  list; webhook secret stays in the admin Config tab; owner-only forge
  select on the project coverage card
- backups exclude forge_connections (credentials, api_keys precedent) and
  the pin, so restores fall back to keyring resolution

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-19 11:23:22 -04:00
co-authored by Claude Fable 5
parent 7a5e2b18d9
commit 1faf8f3ece
19 changed files with 1252 additions and 310 deletions
+77 -1
View File
@@ -1,7 +1,8 @@
<script setup lang="ts">
import { ref, computed, onMounted, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { apiGet, apiPatch, apiDelete, apiPost } from "@/api/client";
import { apiGet, apiPatch, apiDelete, apiPost, apiPut } from "@/api/client";
import { useAuthStore } from "@/stores/auth";
import { useToastStore } from "@/stores/toast";
import { useTasksStore } from "@/stores/tasks";
import { relativeTime } from "@/composables/useRelativeTime";
@@ -48,6 +49,7 @@ interface Project {
status: "active" | "paused" | "completed" | "archived";
color: string | null;
design_system_id: number | null;
forge_connection_id: number | null;
permission?: string;
created_at: string;
updated_at: string;
@@ -472,12 +474,56 @@ async function refreshCoverage() {
}
}
/* ── Forge pin (#2778) — which of the OWNER's connections serves this
project. Owner-only UI: the select lists the viewer's own keyring, which
is only the eligible set when the viewer IS the owner. ── */
const authStore = useAuthStore();
const isProjectOwner = computed(
() => !!project.value && project.value.user_id === authStore.user?.id
);
interface ForgeConnectionOption { id: number; host: string; kind: string }
const forgeOptions = ref<ForgeConnectionOption[]>([]);
const forgePin = ref<number | null>(null);
const savingForgePin = ref(false);
async function loadForgeOptions() {
if (!isProjectOwner.value) return;
try {
const res = await apiGet<{ connections: ForgeConnectionOption[] }>(
"/api/settings/forge-connections"
);
forgeOptions.value = res.connections;
forgePin.value = project.value?.forge_connection_id ?? null;
} catch {
forgeOptions.value = [];
}
}
async function saveForgePin() {
savingForgePin.value = true;
try {
await apiPut(`/api/projects/${projectId.value}/forge`, {
connection_id: forgePin.value,
});
if (project.value) project.value.forge_connection_id = forgePin.value;
await loadCoverage();
} catch (e) {
const body = (e as { body?: { error?: string } }).body;
toast.show(body?.error || "Failed to change the project's forge", "error");
forgePin.value = project.value?.forge_connection_id ?? null;
} finally {
savingForgePin.value = false;
}
}
onMounted(async () => {
await loadProject();
loadTasks();
loadNotes();
loadDesignSystems();
loadCoverage();
loadForgeOptions();
});
/** Populate the design-system picker. Swallows failure on purpose: with no
@@ -496,6 +542,7 @@ watch(projectId, async () => {
loadTasks();
loadNotes();
loadCoverage();
loadForgeOptions();
});
watch(
@@ -693,6 +740,23 @@ async function confirmDelete() {
against recorded snippets.
</p>
<p v-if="coverageError" class="coverage-error">{{ coverageError }}</p>
<!-- Forge pin (#2778): owner-only, because the eligible set is the
owner's own keyring. Automatic = resolve by repo host. -->
<div v-if="isProjectOwner && forgeOptions.length" class="coverage-forge-row">
<label for="forge-pin" class="coverage-gaps-label">Forge</label>
<select
id="forge-pin"
v-model="forgePin"
class="input"
:disabled="savingForgePin"
@change="saveForgePin"
>
<option :value="null">Automatic (by repo host)</option>
<option v-for="c in forgeOptions" :key="c.id" :value="c.id">
{{ c.host }} ({{ c.kind }})
</option>
</select>
</div>
</div>
<div class="project-body">
@@ -1202,6 +1266,18 @@ async function confirmDelete() {
flex-wrap: wrap;
font-size: 0.78rem;
}
.coverage-forge-row {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 0.6rem;
font-size: 0.78rem;
}
.coverage-forge-row select {
max-width: 20rem;
}
.coverage-gaps-label { color: var(--fs-text-tertiary); }
.coverage-gap-chip {
padding: 0.1rem 0.5rem;
+167 -88
View File
@@ -420,15 +420,25 @@ const baseUrl = ref("");
const savingBaseUrl = ref(false);
const baseUrlSaved = ref(false);
// Git forge integration (admin only, #2689). The token round-trips masked;
// the server treats the mask as "unchanged".
const forge = ref({ kind: "", base_url: "", token: "", webhook_secret: "" });
const forgeKinds = ref<string[]>(["gitea"]);
const forgeConfigured = ref(false);
const savingForge = ref(false);
const forgeSaved = ref(false);
const testingForge = ref(false);
const forgeTestResult = ref<{ ok: boolean; message: string } | null>(null);
// Git forge connections (#2778) — the user's keyring: one read-only
// credential per forge host, used server-side for every forge read on
// projects this user owns. The token round-trips masked; the server treats
// the mask as "unchanged".
interface ForgeConnectionEntry {
id: number; kind: string; base_url: string; host: string;
}
const forgeConnections = ref<ForgeConnectionEntry[]>([]);
const forgeKinds = ref<string[]>(["gitea", "github"]);
const connForm = ref({ id: 0, kind: "gitea", base_url: "", token: "" });
const connFormOpen = ref(false);
const savingConn = ref(false);
const testingConnId = ref(0);
const connTestResult = ref<{ id: number; ok: boolean; message: string } | null>(null);
// The webhook secret stays admin: the push endpoint is one URL per instance
// and authenticates deliveries, not users.
const forgeWebhookSecret = ref("");
const savingForgeWebhook = ref(false);
const forgeWebhookSaved = ref(false);
// Search test (SearXNG)
@@ -576,25 +586,30 @@ onMounted(async () => {
// base URL not configured yet
}
try {
await loadForgeSettings();
await loadForgeWebhook();
} catch {
// forge not configured yet
// webhook secret not configured yet
}
}
try {
await loadForgeConnections();
} catch {
// no keyring yet — the ordinary state
}
_loadTabContent(activeTab.value);
});
async function loadForgeSettings() {
const cfg = await apiGet<{
kind: string; base_url: string; token: string; webhook_secret: string;
configured: boolean; kinds: string[];
}>("/api/admin/forge");
forge.value = {
kind: cfg.kind, base_url: cfg.base_url, token: cfg.token,
webhook_secret: cfg.webhook_secret,
};
forgeConfigured.value = cfg.configured;
if (cfg.kinds?.length) forgeKinds.value = cfg.kinds;
async function loadForgeConnections() {
const res = await apiGet<{
connections: ForgeConnectionEntry[]; kinds: string[];
}>("/api/settings/forge-connections");
forgeConnections.value = res.connections;
if (res.kinds?.length) forgeKinds.value = res.kinds;
}
async function loadForgeWebhook() {
const cfg = await apiGet<{ webhook_secret: string }>("/api/admin/forge-webhook");
forgeWebhookSecret.value = cfg.webhook_secret;
}
async function changeEmail() {
@@ -762,42 +777,77 @@ async function sendTestEmail() {
}
}
async function saveForge() {
savingForge.value = true;
forgeSaved.value = false;
forgeTestResult.value = null;
function editConnection(c: ForgeConnectionEntry | null) {
connTestResult.value = null;
connFormOpen.value = true;
connForm.value = c
? { id: c.id, kind: c.kind, base_url: c.base_url, token: "********" }
: { id: 0, kind: forgeKinds.value[0] || "gitea", base_url: "", token: "" };
}
async function saveConnection() {
savingConn.value = true;
try {
await apiPut("/api/admin/forge", forge.value);
await loadForgeSettings();
forgeSaved.value = true;
setTimeout(() => (forgeSaved.value = false), 2000);
const { id, ...values } = connForm.value;
if (id) await apiPut(`/api/settings/forge-connections/${id}`, values);
else await apiPost("/api/settings/forge-connections", values);
connFormOpen.value = false;
await loadForgeConnections();
} catch (e) {
const body = (e as { body?: { error?: string } }).body;
toastStore.show(body?.error || "Failed to save forge settings", "error");
toastStore.show(body?.error || "Failed to save forge connection", "error");
} finally {
savingForge.value = false;
savingConn.value = false;
}
}
async function testForge() {
testingForge.value = true;
forgeTestResult.value = null;
async function removeConnection(id: number) {
try {
await apiDelete(`/api/settings/forge-connections/${id}`);
connTestResult.value = null;
await loadForgeConnections();
} catch (e) {
const body = (e as { body?: { error?: string } }).body;
toastStore.show(body?.error || "Failed to delete forge connection", "error");
}
}
async function testConnection(id: number) {
testingConnId.value = id;
connTestResult.value = null;
try {
const res = await apiPost<{ version: string; username: string }>(
"/api/admin/forge/test", {},
`/api/settings/forge-connections/${id}/test`, {},
);
forgeTestResult.value = {
ok: true,
message: `Connected — Gitea ${res.version}, authenticated as ${res.username}`,
connTestResult.value = {
id, ok: true,
message: `Connected — ${res.version}, authenticated as ${res.username}`,
};
} catch (e) {
const body = (e as { body?: { error?: string } }).body;
forgeTestResult.value = {
ok: false,
connTestResult.value = {
id, ok: false,
message: body?.error || "Connection test failed",
};
} finally {
testingForge.value = false;
testingConnId.value = 0;
}
}
async function saveForgeWebhook() {
savingForgeWebhook.value = true;
try {
await apiPut("/api/admin/forge-webhook", {
webhook_secret: forgeWebhookSecret.value,
});
await loadForgeWebhook();
forgeWebhookSaved.value = true;
setTimeout(() => (forgeWebhookSaved.value = false), 2000);
} catch (e) {
const body = (e as { body?: { error?: string } }).body;
toastStore.show(body?.error || "Failed to save webhook secret", "error");
} finally {
savingForgeWebhook.value = false;
}
}
@@ -1710,6 +1760,70 @@ function formatUserDate(iso: string): string {
</template>
</section>
<section class="settings-section full-width">
<h2>Git Forges</h2>
<p class="section-desc">
Read-only connections to your git forges, one per host. Projects you
own use them server-side — resolved by repo host — to fetch and
drift-check recorded snippets and measure pattern coverage. A
read-scope token is enough. Gitea: an access token with read scope
on repositories. GitHub: a fine-grained PAT with Contents:
Read-only, base URL <code>https://github.com</code> (or your GitHub
Enterprise URL).
</p>
<table v-if="forgeConnections.length" class="api-keys-table">
<thead>
<tr><th>Host</th><th>Kind</th><th>Base URL</th><th></th></tr>
</thead>
<tbody>
<tr v-for="c in forgeConnections" :key="c.id">
<td>{{ c.host }}</td>
<td>{{ c.kind }}</td>
<td><code>{{ c.base_url }}</code></td>
<td>
<button class="btn btn-secondary btn-sm" :disabled="testingConnId === c.id" @click="testConnection(c.id)">
{{ testingConnId === c.id ? "Testing…" : "Test" }}
</button>
<button class="btn btn-secondary btn-sm" @click="editConnection(c)">Edit</button>
<button class="btn btn-danger btn-sm" @click="removeConnection(c.id)">Delete</button>
</td>
</tr>
</tbody>
</table>
<p v-else class="section-desc not-configured">No forge connections yet.</p>
<p
v-if="connTestResult"
:class="connTestResult.ok ? 'text-success' : 'text-error'"
>
{{ connTestResult.message }}
</p>
<div v-if="connFormOpen" class="smtp-grid">
<div class="field">
<label for="conn-kind">Forge</label>
<select id="conn-kind" v-model="connForm.kind" class="input">
<option v-for="k in forgeKinds" :key="k" :value="k">{{ k }}</option>
</select>
</div>
<div class="field">
<label for="conn-base-url">Base URL</label>
<input id="conn-base-url" v-model="connForm.base_url" type="text" placeholder="https://git.example.com" class="input" />
</div>
<div class="field">
<label for="conn-token">API Token (read scope)</label>
<input id="conn-token" v-model="connForm.token" type="password" class="input" />
</div>
</div>
<div class="actions">
<template v-if="connFormOpen">
<button class="btn-primary" @click="saveConnection" :disabled="savingConn">
{{ savingConn ? "Saving..." : connForm.id ? "Save Connection" : "Add Connection" }}
</button>
<button class="btn-ghost" @click="connFormOpen = false">Cancel</button>
</template>
<button v-else class="btn-primary" @click="editConnection(null)">Add Connection</button>
</div>
</section>
</div>
<!-- ── Data ── -->
@@ -2161,61 +2275,26 @@ function formatUserDate(iso: string): string {
</section>
<section class="settings-section full-width">
<h2>Git Forge</h2>
<h2>Forge Webhook</h2>
<p class="section-desc">
Optional read-only connection to your git forge (Gitea or GitHub) so
snippet code can be fetched, drift-checked, and coverage-measured
server-side. A read-scope token is enough. Leave the kind unset to
keep the integration off.
Forge connections are per-user (Settings Integrations Git
Forges). What stays instance-wide is the push webhook: create one on
the forge pointing at <code>/api/webhooks/forge</code> with this
secret, and snippets whose recorded files change get flagged for
re-verification.
</p>
<div class="smtp-grid">
<div class="field">
<label for="forge-kind">Forge</label>
<select id="forge-kind" v-model="forge.kind" class="input">
<option value="">Off</option>
<option v-for="k in forgeKinds" :key="k" :value="k">{{ k }}</option>
</select>
</div>
<div class="field">
<label for="forge-base-url">Base URL</label>
<input id="forge-base-url" v-model="forge.base_url" type="text" placeholder="https://git.example.com" class="input" />
</div>
<div class="field">
<label for="forge-token">API Token (read scope)</label>
<input id="forge-token" v-model="forge.token" type="password" class="input" />
<p class="field-hint">
Gitea: an access token with read scope on repositories. GitHub:
a fine-grained PAT with Contents: Read-only (or a classic token
with repo read). For GitHub, use
<code>https://github.com</code> as the base URL — or your
GitHub Enterprise instance's URL.
</p>
</div>
<div class="field">
<label for="forge-webhook-secret">Webhook Secret</label>
<input id="forge-webhook-secret" v-model="forge.webhook_secret" type="password" class="input" />
<p class="field-hint">
Optional: create a push webhook on the forge pointing at
<code>/api/webhooks/forge</code> with this secret, and snippets
whose recorded files change get flagged for re-verification.
</p>
<input id="forge-webhook-secret" v-model="forgeWebhookSecret" type="password" class="input" />
</div>
</div>
<div class="actions" style="margin-bottom: 1.25rem;">
<button class="btn-primary" @click="saveForge" :disabled="savingForge">
{{ savingForge ? "Saving..." : "Save Forge Settings" }}
<div class="actions">
<button class="btn-primary" @click="saveForgeWebhook" :disabled="savingForgeWebhook">
{{ savingForgeWebhook ? "Saving..." : "Save Webhook Secret" }}
</button>
<button class="btn-ghost" @click="testForge" :disabled="testingForge || !forgeConfigured">
{{ testingForge ? "Testing..." : "Test Connection" }}
</button>
<span v-if="forgeSaved" class="saved-msg">Saved!</span>
<span v-if="forgeWebhookSaved" class="saved-msg">Saved!</span>
</div>
<p
v-if="forgeTestResult"
:class="forgeTestResult.ok ? 'text-success' : 'text-error'"
>
{{ forgeTestResult.message }}
</p>
</section>
</div>