diff --git a/src/hooks/useGitHubSync.ts b/src/hooks/useGitHubSync.ts
index 6a1fd90..68851e8 100644
--- a/src/hooks/useGitHubSync.ts
+++ b/src/hooks/useGitHubSync.ts
@@ -11,6 +11,7 @@ import { isChunkLoadError, showChunkReloadToast, CHUNK_RELOAD_MESSAGE } from '@/
// prefetch). pullFromZipball still lives in githubSync.ts for callers/tests.
import { syncToGitHub, pullFromGitHub } from '@/utils/githubSync'
import type { PullClassification, SyncResult, GitPathUpdate } from '@/utils/githubSync'
+import { makeGitHostProvider } from '@/utils/gitHost'
import { getValidGitHubToken, withTokenRefresh, ReconnectRequiredError } from '@/utils/tokenRefresh'
import { applyNonConflicts, applyAttachmentClassifications } from '@/utils/syncApply'
import { fillShellsInBackground } from '@/utils/backgroundFill'
@@ -186,8 +187,9 @@ async function runPull(
// clone runs on the user's own authenticated GitHub API quota instead of
// Noteser's Vercel bandwidth. pullFromZipball + fetchZipball + the
// /api/github/zipball route are kept in the tree but no longer on this path.
+ const { host, baseUrl } = useGitHubStore.getState()
const { classifications, latestCommitSha } = await pullFromGitHub({
- token, repo,
+ provider: makeGitHostProvider({ host, token, baseUrl }), repo,
notes: localNotes, folders: localFolders,
excludedFolderPaths,
vaultSettingsPath,
@@ -242,8 +244,9 @@ async function runPush(
}
}
+ const { host, baseUrl } = useGitHubStore.getState()
const outcome = await syncToGitHub({
- token, repo, notes, folders, commitMessage,
+ provider: makeGitHostProvider({ host, token, baseUrl }), repo, notes, folders, commitMessage,
vaultSettings: vaultSettingsInput,
// gi9n: thread the editor's draft through. Null = no pending edit;
// syncToGitHub will leave the remote `.gitignore` alone.
diff --git a/src/middleware.ts b/src/middleware.ts
index f9f74b4..02c4280 100644
--- a/src/middleware.ts
+++ b/src/middleware.ts
@@ -1,6 +1,6 @@
import { NextResponse, type NextRequest } from 'next/server'
-import { buildCsp, deriveCollabWsOrigin } from '@/utils/csp'
+import { buildCsp, deriveCollabWsOrigin, deriveGitHostOrigin } from '@/utils/csp'
/**
* Per-request nonce-based CSP (Finding 6 of the 2026-05-21 security audit).
@@ -22,6 +22,7 @@ export function middleware(request: NextRequest) {
const csp = buildCsp(nonce, {
isDev: process.env.NODE_ENV !== 'production',
wsOrigin: deriveCollabWsOrigin(process.env.NEXT_PUBLIC_YJS_WS_URL),
+ gitHostOrigin: deriveGitHostOrigin(process.env.NEXT_PUBLIC_FORGEJO_BASE_URL),
// /share renders arbitrary shared content to arbitrary visitors — no
// remote-image tracking pixels there. See BuildCspOptions.restrictImages.
restrictImages: request.nextUrl.pathname.startsWith('/share'),
diff --git a/src/stores/githubStore.ts b/src/stores/githubStore.ts
index e5afe6f..d5e7466 100644
--- a/src/stores/githubStore.ts
+++ b/src/stores/githubStore.ts
@@ -1,6 +1,7 @@
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
import type { GitHubUser, SyncRepo } from '@/types'
+import type { HostKind } from '@/utils/gitHost/types'
import { STORAGE_KEYS } from '@/utils/storageKeys'
import { localStorageJSON } from '@/utils/persistStorage'
import { trackEventOncePerSession } from '@/utils/analytics'
@@ -46,6 +47,13 @@ interface GitHubState {
token: string | null
user: GitHubUser | null
connectedAt: number | null
+ // Which git host this connection targets. `'github'` is the default for
+ // every existing user (a persisted blob with no `host` key merges over this
+ // initial value). The sync pipeline selects the provider from it.
+ host: HostKind
+ // Base URL for the active host. Null means "use the provider's own default"
+ // (GitHub ignores it entirely; Forgejo falls back to codeberg.org).
+ baseUrl: string | null
syncRepo: SyncRepo | null
lastSyncedAt: number | null
lastCommitSha: string | null
@@ -83,6 +91,7 @@ interface GitHubState {
// the refresh token on every use, so the new one is persisted here too.
applyRefreshedTokens: (tokens: GitHubTokenSet) => void
setTokenScopes: (scopes: string[] | null) => void
+ setHost: (host: HostKind, baseUrl: string | null) => void
setSyncRepo: (repo: SyncRepo | null) => void
recordSync: (commitSha: string) => void
setIsSyncing: (value: boolean) => void
@@ -100,6 +109,8 @@ export const useGitHubStore = create
()(
token: null,
user: null,
connectedAt: null,
+ host: 'github',
+ baseUrl: null,
syncRepo: null,
lastSyncedAt: null,
lastCommitSha: null,
@@ -129,6 +140,7 @@ export const useGitHubStore = create()(
refreshTokenExpiresAt: tokens.refreshTokenExpiresAt,
}),
setTokenScopes: (scopes) => set({ tokenScopes: scopes }),
+ setHost: (host, baseUrl) => set({ host, baseUrl }),
setIsSyncing: (value) => set({ isSyncing: value }),
setSyncRepo: (repo) => set(state => {
const currentKey = repoKey(state.syncRepo)
@@ -162,6 +174,7 @@ export const useGitHubStore = create()(
}),
disconnect: () => set({
token: null, user: null, connectedAt: null,
+ host: 'github', baseUrl: null,
syncRepo: null, lastSyncedAt: null, lastCommitSha: null,
repoSyncStates: {}, tokenScopes: null,
accessTokenExpiresAt: null, refreshToken: null, refreshTokenExpiresAt: null,
@@ -177,6 +190,8 @@ export const useGitHubStore = create()(
token: state.token,
user: state.user,
connectedAt: state.connectedAt,
+ host: state.host,
+ baseUrl: state.baseUrl,
syncRepo: state.syncRepo,
lastSyncedAt: state.lastSyncedAt,
lastCommitSha: state.lastCommitSha,
diff --git a/src/utils/csp.ts b/src/utils/csp.ts
index 35815ef..3807834 100644
--- a/src/utils/csp.ts
+++ b/src/utils/csp.ts
@@ -41,11 +41,33 @@ export function deriveCollabWsOrigin(raw: string | undefined): string | null {
}
}
+/**
+ * Derive a single http(s) origin from a raw NEXT_PUBLIC_FORGEJO_BASE_URL
+ * value so the CSP only allows the self-hosted Forgejo/Gitea instance the
+ * operator opted into at deploy time. Same posture as deriveCollabWsOrigin:
+ * an allow-listed origin, never a scheme wildcard — a runtime-controllable
+ * connect-src would let an XSS payload exfiltrate localStorage (which holds
+ * the git token) to an arbitrary host. Codeberg itself is a static entry in
+ * connect-src and needs no env var.
+ */
+export function deriveGitHostOrigin(raw: string | undefined): string | null {
+ if (!raw) return null
+ try {
+ const url = new URL(raw)
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') return null
+ return `${url.protocol}//${url.host}`
+ } catch {
+ return null
+ }
+}
+
export interface BuildCspOptions {
/** Non-production (dev / test): adds 'unsafe-eval' to script-src. */
isDev: boolean
/** Already-derived ws(s):// origin to add to connect-src, or null. */
wsOrigin: string | null
+ /** Already-derived self-hosted Forgejo origin for connect-src, or null. */
+ gitHostOrigin?: string | null
/**
* Drop the `https:` wildcard from img-src. The editor's own notes
* legitimately embed arbitrary HTTPS images (`![]()`), but the public
@@ -63,7 +85,7 @@ export interface BuildCspOptions {
* @param nonce base64 per-request nonce (already generated by middleware).
*/
export function buildCsp(nonce: string, options: BuildCspOptions): string {
- const { isDev, wsOrigin, restrictImages = false } = options
+ const { isDev, wsOrigin, gitHostOrigin = null, restrictImages = false } = options
const scriptSrc = [
"'self'",
@@ -76,9 +98,15 @@ export function buildCsp(nonce: string, options: BuildCspOptions): string {
"'self'",
'https://api.github.com',
'https://github.com',
+ // Codeberg is the built-in Forgejo host preset in the vault connect flow;
+ // its Gitea API is called browser-direct just like api.github.com.
+ 'https://codeberg.org',
'https://api.anthropic.com',
'https://api.openai.com',
...(wsOrigin ? [wsOrigin] : []),
+ // Self-hosted Forgejo/Gitea: only the single origin the operator
+ // allow-listed via NEXT_PUBLIC_FORGEJO_BASE_URL (see deriveGitHostOrigin).
+ ...(gitHostOrigin ? [gitHostOrigin] : []),
].join(' ')
return [
diff --git a/src/utils/gitHost/forgejoProvider.ts b/src/utils/gitHost/forgejoProvider.ts
new file mode 100644
index 0000000..d5d1e1f
--- /dev/null
+++ b/src/utils/gitHost/forgejoProvider.ts
@@ -0,0 +1,322 @@
+// ForgejoProvider: the Forgejo/Gitea implementation of the GitHostProvider
+// seam. Codeberg is just a base-URL preset (https://codeberg.org); any
+// self-hosted Forgejo/Gitea instance works via a configurable baseUrl.
+//
+// Unlike GitHubProvider (a thin wrap of github.ts), this provider talks to
+// the Gitea API directly at `{baseUrl}/api/v1`. The big divergence from
+// GitHub is the write path: Forgejo's git-data endpoints are read-only, so
+// commitChanges goes through `POST /repos/{owner}/{repo}/contents` (the
+// ChangeFiles batch API) — one request writes N files as a single commit.
+// See docs/multi-host-sync-plan.md.
+
+import type { SyncRepo } from '@/types'
+import { base64ToBytes } from '../github'
+import type {
+ GitHostProvider,
+ HostKind,
+ HostRepo,
+ HostUser,
+ CommitRequest,
+ CommitResult
+} from './types'
+
+const CODEBERG_BASE = 'https://codeberg.org'
+
+// Gitea caps repo listings; 50 keeps each page small enough to stay snappy.
+const REPOS_PER_PAGE = 50
+
+// Typed error thrown at the network boundary for any non-ok Gitea response.
+// Carries the HTTP status and the API's error message (when the body parses
+// as JSON) so the UI can show a precise message instead of a bare code.
+export class ForgejoAPIError extends Error {
+ constructor(
+ public readonly status: number,
+ public readonly operation: string,
+ public readonly serverMessage: string | null
+ ) {
+ const tail = serverMessage ? ` — ${serverMessage}` : ''
+ super(`${operation} failed (${status})${tail}`)
+ this.name = 'ForgejoAPIError'
+ }
+
+ static async fromResponse(
+ res: Response,
+ operation: string
+ ): Promise {
+ let serverMessage: string | null = null
+ try {
+ const body = (await res.clone().json()) as { message?: string }
+ if (typeof body.message === 'string') serverMessage = body.message
+ } catch {
+ // Body wasn't JSON, leave message null.
+ }
+ return new ForgejoAPIError(res.status, operation, serverMessage)
+ }
+}
+
+// Base64-encode a UTF-8 string for the ChangeFiles `content` field. TextEncoder
+// + btoa (btoa alone only handles Latin-1).
+function utf8ToBase64(content: string): string {
+ const bytes = new TextEncoder().encode(content)
+ let bin = ''
+ for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i])
+ return btoa(bin)
+}
+
+function bytesToBase64(bytes: Uint8Array): string {
+ let bin = ''
+ for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i])
+ return btoa(bin)
+}
+
+interface GiteaRepo {
+ name: string
+ owner: { login: string }
+ default_branch: string
+ private: boolean
+}
+
+function toHostRepo(repo: GiteaRepo): HostRepo {
+ return {
+ owner: repo.owner.login,
+ name: repo.name,
+ defaultBranch: repo.default_branch,
+ isPrivate: repo.private
+ }
+}
+
+export class ForgejoProvider implements GitHostProvider {
+ readonly kind: HostKind = 'forgejo'
+ readonly baseUrl: string
+
+ constructor(
+ private readonly token: string,
+ baseUrl: string = CODEBERG_BASE
+ ) {
+ this.baseUrl = baseUrl.replace(/\/+$/, '')
+ }
+
+ private get apiBase(): string {
+ return `${this.baseUrl}/api/v1`
+ }
+
+ private headers(extra: Record = {}): Record {
+ return {
+ Authorization: `token ${this.token}`,
+ Accept: 'application/json',
+ ...extra
+ }
+ }
+
+ private async get(path: string, operation: string): Promise {
+ const res = await fetch(`${this.apiBase}${path}`, {
+ headers: this.headers()
+ })
+ if (!res.ok) throw await ForgejoAPIError.fromResponse(res, operation)
+ return res.json()
+ }
+
+ // --- repo ops ---
+ async listRepos(): Promise {
+ const out: HostRepo[] = []
+ for (let page = 1; ; page++) {
+ const batch = (await this.get(
+ `/user/repos?limit=${REPOS_PER_PAGE}&page=${page}`,
+ 'List repos'
+ )) as GiteaRepo[]
+ out.push(...batch.map(toHostRepo))
+ if (batch.length < REPOS_PER_PAGE) break
+ }
+ return out
+ }
+
+ async getRepo(owner: string, name: string): Promise {
+ const repo = (await this.get(
+ `/repos/${owner}/${name}`,
+ 'Fetch repo'
+ )) as GiteaRepo
+ return toHostRepo(repo)
+ }
+
+ async listBranches(owner: string, name: string): Promise {
+ const branches = (await this.get(
+ `/repos/${owner}/${name}/branches`,
+ 'List branches'
+ )) as { name: string }[]
+ return branches.map(b => b.name)
+ }
+
+ async createRepo(name: string, isPrivate: boolean): Promise {
+ const res = await fetch(`${this.apiBase}/user/repos`, {
+ method: 'POST',
+ headers: this.headers({ 'Content-Type': 'application/json' }),
+ body: JSON.stringify({ name, private: isPrivate, auto_init: true })
+ })
+ if (!res.ok) throw await ForgejoAPIError.fromResponse(res, 'Create repo')
+ return toHostRepo((await res.json()) as GiteaRepo)
+ }
+
+ async getAuthenticatedUser(): Promise {
+ const data = (await this.get('/user', 'Read user')) as {
+ id: number
+ login: string
+ full_name?: string
+ avatar_url?: string
+ }
+ return {
+ id: data.id,
+ login: data.login,
+ name: data.full_name ? data.full_name : null,
+ avatarUrl: data.avatar_url
+ }
+ }
+
+ // --- git-data READ ---
+ async getBranchHeadSha(repo: SyncRepo): Promise {
+ // The refs endpoint returns an array when the path is a prefix match for
+ // multiple refs, or a single object for an exact match.
+ const data = (await this.get(
+ `/repos/${repo.owner}/${repo.name}/git/refs/heads/${repo.branch}`,
+ 'Read ref'
+ )) as { object: { sha: string } } | { object: { sha: string } }[]
+ return Array.isArray(data) ? data[0].object.sha : data.object.sha
+ }
+
+ async getCommitTreeSha(repo: SyncRepo, commitSha: string): Promise {
+ // Forgejo nests the tree sha under `.commit.tree.sha` (GitHub puts it at
+ // the top-level `.tree.sha`).
+ const data = (await this.get(
+ `/repos/${repo.owner}/${repo.name}/git/commits/${commitSha}`,
+ 'Read commit'
+ )) as { commit: { tree: { sha: string } } }
+ return data.commit.tree.sha
+ }
+
+ async getTreeMap(
+ repo: SyncRepo,
+ treeSha: string
+ ): Promise