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;