Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/self-hosted-remote-sync.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@inkeep/open-knowledge": patch
---

Auto-sync is no longer silently disabled for projects whose `origin` is a self-hosted Git forge (Gitea, Forgejo, …). The push-permission probe presumes any host that is not a known non-GitHub forge is GitHub or GitHub Enterprise Server, since GHES hostnames are arbitrary. When no GitHub token could be resolved for such a host, the probe previously returned `denied` without any network call, which paused sync with "You don't have permission to push to this repo" — even though the user pushes fine over SSH. The probe now only treats an anonymous (no-token) result as `denied` for `github.com`; for any other host it returns `unknown`, so sync stays enabled and the real push attempt surfaces a genuine error only if it actually fails. Behavior for `github.com` and for GHES hosts with a resolvable token is unchanged.
9 changes: 8 additions & 1 deletion packages/core/src/schemas/api/sync-seed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,14 @@ export const PushPermissionSchema = z.discriminatedUnion('checkStatus', [
.object({
checkStatus: z.literal('unknown'),
unknownError: z
.enum(['network', 'timeout', 'rate-limit', 'token-invalid', 'malformed-response'])
.enum([
'network',
'timeout',
'rate-limit',
'token-invalid',
'malformed-response',
'host-unverified',
])
.optional(),
})
.loose(),
Expand Down
21 changes: 21 additions & 0 deletions packages/server/src/github-permissions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,27 @@ describe('checkPushPermission — token resolution', () => {
expect(calls).toHaveLength(0);
});

test('anonymous on a non-github.com host → unknown/host-unverified with NO HTTP call', async () => {
// Self-hosted Gitea/Forgejo (arbitrary hostname, indistinguishable from a
// GHES host) with no resolvable gh/stored token. We cannot assert the user
// can't push — they may well push over SSH with a key that never involves
// an HTTP token. Denying here would hard-pause auto-sync for every
// self-hosted-forge user. `unknown` keeps callers lenient and lets the real
// push attempt surface any real error.
const { fetch, calls } = mockFetch(() => jsonResponse(200, {}));
const { store } = fakeStore(null);
const result = await checkPushPermission({
owner: 'example-org',
repo: 'notes',
host: 'git.example.com',
detectGh: ghUnavailable(),
tokenStore: store,
_fetchFn: fetch,
});
expect(result).toEqual({ kind: 'unknown', error: 'host-unverified' });
expect(calls).toHaveLength(0); // still no network call — the short-circuit holds
});

test('gh detection is scoped to the requested host', async () => {
const seenHosts: Array<string | undefined> = [];
const detectGh: DetectGhFn = (host) => {
Expand Down
42 changes: 32 additions & 10 deletions packages/server/src/github-permissions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,11 @@ type PushPermissionUnknownError =
| 'timeout'
| 'rate-limit'
| 'token-invalid'
| 'malformed-response';
| 'malformed-response'
// No credential resolved for a non-github.com host we cannot verify is
// GitHub (a GHES host or a self-hosted forge like Gitea/Forgejo). Push
// permission is genuinely unknown — see the anonymous branch in `runProbe`.
| 'host-unverified';

/**
* Outcome of a single push-permission probe.
Expand Down Expand Up @@ -211,15 +215,33 @@ async function runProbe(opts: CheckPushPermissionOptions): Promise<PushPermissio
tokenStore,
);

// No credential at all → no push, definitionally. Short-circuit to the
// denied posture WITHOUT a network call: an anonymous `GET /repos` returns
// 200 with no `permissions` field, which classifies as `unknown` and makes
// callers fall through to the doomed sync-onboarding + 403-push path. An
// anonymous receiver opening a public shared repo (read-only by nature) must
// instead land directly in the suppressed-onboarding, no-push UX.
// No credential resolved. What that means depends on the host.
//
// github.com: no push, definitionally. Short-circuit to `denied` WITHOUT a
// network call — an anonymous `GET /repos` returns 200 with no `permissions`
// field, which classifies as `unknown` and makes callers fall through to the
// doomed sync-onboarding + 403-push path. An anonymous receiver opening a
// public shared repo (read-only by nature) must instead land directly in the
// suppressed-onboarding, no-push UX.
//
// Any other host: it may be a GHES host OR a self-hosted forge (Gitea,
// Forgejo, …) whose arbitrary hostname is indistinguishable from GHES and so
// is presumed-GitHub upstream. Without a token we CANNOT assert the user
// can't push — a self-hosted forge is routinely pushed over SSH with a key
// that never involves an HTTP token. Denying here would hard-pause auto-sync
// for every self-hosted-forge user (regression from #597, which stopped
// ignoring non-github.com hosts). Return `unknown` so callers stay lenient
// and the real push attempt surfaces a real error only if it actually fails.
if (tokenSource === 'anonymous') {
log.info({ host }, '[permissions] no credential resolved — denying push (read-only)');
return { kind: 'denied', reason: 'no-collaborator' };
if (host === 'github.com') {
log.info({ host }, '[permissions] no credential resolved — denying push (read-only)');
return { kind: 'denied', reason: 'no-collaborator' };
}
log.info(
{ host },
'[permissions] no credential for unverified non-github.com host — deferring to push (unknown)',
);
return { kind: 'unknown', error: 'host-unverified' };
}

const url = `${githubApiBase(host)}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`;
Expand Down Expand Up @@ -309,7 +331,7 @@ let _outcomeCounter: Counter | null = null;
function outcomeCounter(): Counter {
_outcomeCounter ||= getMeter().createCounter('ok.permissions.probe.outcome_total', {
description:
'Push-permission probe outcomes. Bounded labels: outcome ∈ {allowed,denied,unknown}; denied_reason ∈ {no-collaborator,private-no-access,repo-not-found,none}; error_class ∈ {network,timeout,rate-limit,token-invalid,malformed-response,none}.',
'Push-permission probe outcomes. Bounded labels: outcome ∈ {allowed,denied,unknown}; denied_reason ∈ {no-collaborator,private-no-access,repo-not-found,none}; error_class ∈ {network,timeout,rate-limit,token-invalid,malformed-response,host-unverified,none}.',
});
return _outcomeCounter;
}
Expand Down
8 changes: 7 additions & 1 deletion packages/server/src/sync-engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,13 @@ export type PushPermissionStatus =
}
| {
checkStatus: 'unknown';
unknownError?: 'network' | 'timeout' | 'rate-limit' | 'token-invalid' | 'malformed-response';
unknownError?:
| 'network'
| 'timeout'
| 'rate-limit'
| 'token-invalid'
| 'malformed-response'
| 'host-unverified';
};

/** Flatten the tagged `PushPermission` from `github-permissions.ts` to wire shape. */
Expand Down