Files
gitea-mirror/docs/API.md
T
ARUNAVO RAYandGitHub 83dd74dd52 feat(auth): API keys for programmatic access (#380)
* feat(auth): API keys for programmatic access

Adds the @better-auth/api-key plugin so scripts and CI pipelines can call
the existing endpoints with an x-api-key header instead of a session
cookie. Keys are owned by a user, hashed at rest, prefixed gm_, never
expire unless an expiry is chosen, and are not rate limited. A small
guard plugin refuses key management calls that arrive with a key, so a
leaked key cannot mint or revoke keys.

New API Keys section on the Authentication tab with create, show once,
copy and revoke. Migration 0017 adds the api_keys table with the
validator fixture. docs/API.md documents the header and the calls
automation needs. An e2e spec covers create, use, refuse and revoke over
HTTP. bun.nix regenerated for the new package.

Closes #314

Claude-Session: https://claude.ai/code/session_01Tp9pmi65a8k5jLMQFLf4JX

* fix(e2e): send Origin on cookie-authenticated key management calls

Better Auth rejects a cookie-authenticated POST without an Origin header (403 MISSING_OR_NULL_ORIGIN). Browsers always send one, the Playwright request context does not, so the spec sets it on the create and delete calls. Also asserts the guard's 403 code and documents the Origin requirement for scripts that manage keys with a session.

Claude-Session: https://claude.ai/code/session_01Tp9pmi65a8k5jLMQFLf4JX
2026-09-02 14:57:58 +05:30

5.8 KiB

API access with API keys

Gitea Mirror's web UI talks to a set of JSON endpoints under /api. With an API key you can call the same endpoints from scripts, CI pipelines and workflow tools such as n8n, without a browser session.

Create a key

  1. Sign in and open Settings → Authentication.
  2. In the API Keys section click Create key, give it a name and pick an expiry (never, 30 days, 90 days or a year).
  3. Copy the key. It is shown once; afterwards only its first characters are visible in the list.

Keys start with gm_, belong to the user who created them and carry the same rights as that user's session. Revoke a key from the same list at any time. Keys are stored hashed. They are not rate limited, so a runaway script is stopped by revoking its key.

Send the key

Put the key in the x-api-key header. Every endpoint that accepts a session cookie accepts the header instead.

curl -sS "https://mirror.example.com/api/github/repositories" \
  -H "x-api-key: gm_..."

A missing, malformed, expired or revoked key gets 401 Unauthorized:

{ "success": false, "error": "Unauthorized" }

If the app is served under a base path (BASE_PATH=/mirror), prefix every URL with it: /mirror/api/....

Endpoints

The calls below cover the automation cases from issue #314. Response bodies show the fields you are most likely to use; the app returns more.

List tracked repositories

GET /api/github/repositories

Despite the name it lists every repository the account tracks, whatever the configured source. Requires a saved configuration, otherwise 404.

{
  "success": true,
  "message": "Repositories fetched successfully",
  "repositories": [
    {
      "id": "9b2f...",
      "name": "hello-world",
      "fullName": "octocat/hello-world",
      "owner": "octocat",
      "organization": null,
      "status": "mirrored",
      "mirroredLocation": "github-mirrors/hello-world",
      "lastMirrored": "2026-09-02T08:00:00.000Z",
      "errorMessage": null,
      "destinationOrg": null,
      "sourceProvider": "github",
      "isPrivate": false
    }
  ]
}

status is one of imported, mirroring, mirrored, failed, syncing, synced, skipped, deleted, archived.

Add a repository

POST /api/sync/repository

{ "owner": "octocat", "repo": "hello-world" }

Optional fields: destinationOrg to override the Gitea organization for this repository, force: true to refresh the metadata of a repository that is already tracked.

The repository is looked up on the configured source and added with status imported. It is not mirrored until you start a mirror (next call) or the scheduler picks it up.

Status Meaning
200 Added. repository.id is what the mirror call needs.
400 owner or repo missing.
404 No configuration for this account, or the repository does not exist on the source.
409 Already tracked. Send force: true to refresh it instead.
{
  "success": true,
  "message": "Repository added successfully",
  "repository": { "id": "9b2f...", "fullName": "octocat/hello-world", "status": "imported" }
}

Start a mirror

POST /api/job/mirror-repo

{ "repositoryIds": ["9b2f..."] }

Starts mirroring in the background and returns straight away. Poll the list endpoint to watch status move from mirroring to mirrored or failed.

Status Meaning
200 Job started. repositories lists what was queued.
400 repositoryIds missing or empty, or no source token saved.
404 None of the ids belong to this account.

POST /api/job/sync-repo takes the same body and re-syncs repositories that are already mirrored.

Organizations

POST /api/sync/organization adds an organization the same way:

{ "org": "my-org", "role": "member" }

role is the account's role in that organization (member, admin, owner or billing_manager). 409 means it is already tracked; force: true refreshes it. Then POST /api/job/mirror-org with { "organizationIds": ["..."] } mirrors its repositories.

A complete example

Add a repository and mirror it in one go:

#!/usr/bin/env bash
set -euo pipefail

BASE="https://mirror.example.com"
KEY="gm_..."

added=$(curl -sS -X POST "$BASE/api/sync/repository" \
  -H "x-api-key: $KEY" \
  -H "Content-Type: application/json" \
  -d '{"owner":"octocat","repo":"hello-world"}')

id=$(echo "$added" | jq -r '.repository.id')

curl -sS -X POST "$BASE/api/job/mirror-repo" \
  -H "x-api-key: $KEY" \
  -H "Content-Type: application/json" \
  -d "{\"repositoryIds\":[\"$id\"]}"

Managing keys from the API

Manage keys through the settings UI or with a browser session. The key endpoints need a session cookie, not a key, so a leaked key cannot mint more keys, and like every cookie-authenticated POST to /api/auth/* they also need an Origin header (browsers send it on their own; a script must add Origin: https://mirror.example.com). The key itself is only for the app routes above. The endpoints live under /api/auth/api-key/:

Call Body Result
POST /api/auth/api-key/create { "name": "ci", "expiresIn": 2592000 } (expiresIn in seconds, omit for no expiry) The new key, including key, shown only here.
GET /api/auth/api-key/list { "apiKeys": [...], "total": n }. Each entry has id, name, start, createdAt, lastRequest and expiresAt, never the secret.
POST /api/auth/api-key/delete { "keyId": "..." } Revokes the key.

Notes

  • A key can only act on the account that created it. Each account keeps its own source, destination and repositories.
  • Keys are not rate limited by the app. Source hosts still apply their own limits, and the app tracks GitHub's.
  • Deleting a user deletes their keys.