Files
civitai__civitai/tests/preview-apps-git-access.spec.ts
T
Zachary Lowden 99a82aa8ca feat(app-blocks): git-push authoring on-ramp + self-service Forgejo credentials (Phase 3) (#2587)
* feat(app-blocks): approve push-originated requests from Forgejo (Phase 3 core)

The load-bearing core of the git-push authoring on-ramp. When a developer
git-pushes to civitai-apps/<slug>, the webhook parks a pending publish request
with EMPTY bundle pointers (bundleKey=''), but approveRequest called
fetchBundleBuffer(bundleKey) unconditionally → it crashed on approve, so a push
could be parked but never approved/deployed.

approveRequest's bundle source is now source-agnostic: bundleKey → MinIO ZIP
(unchanged); else forgejoCommitSha → reconstruct the bundle from the Forgejo
repo at the pushed sha; else throw. Everything downstream (platform-owned
filter, committed-manifest rewrite, screenshots, commit, sha stamp, build
trigger, Phase-2 deploy-state) is unchanged.

New `reconstructBundleFromForgejo(slug, ref)` builds a deterministic ZIP from
listRepoTreeAtRef + getBlobContent; backfillPublishRequest refactored onto it.
Added `listRepoTreeAtRef(slug, ref, org)` (resolves a commit sha via the git
trees endpoint; listRepoTree delegates to it, no caller-signature change).

No change to the no-trust-on-push deploy gate — pushes still never deploy
without mod approval. Tests: push-path approve (reconstructs from Forgejo, no
S3 GET, commits, stamps sha, triggers build, deploy_state=building) + ZIP-path
regression + reconstruct-helper determinism. Blocks suite 319/319 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(app-blocks): per-developer Forgejo identity + getMyAppRepo (Phase 3 self-service)

Lazily provisions each civitai user a scoped, restricted Forgejo identity the
first time they request git access to their app, and exposes the clone URL +
push credential to the app owner.

- Schema: new app_dev_forgejo_identity table (1:1 with userId) storing the
  Forgejo username + AES-256-GCM-encrypted PAT (keyed on NEXTAUTH_SECRET).
  Additive migration (manual apply, prod + dev clone).
- forgejo.service: createForgejoUser (POST /admin/users, restricted+private,
  idempotent), getForgejoUser, mintForgejoUserToken (HTTP-Basic as the user —
  gitea requires the user's own creds; scope write:repository), deleteForgejoUser.
- dev-git-access.service: ensureForgejoIdentity — read-or-provision, made
  CONCURRENCY-SAFE via a DB CLAIM (insert a placeholder row; the userId PK lets
  one caller own provisioning while the rest wait for the token) — pooler-safe
  (no advisory locks) and non-destructive. Claim is rolled back on failure.
  Fixes a race in the first cut where two concurrent first-provisions could
  purge the user mid-mint and persist a dead token.
- blocks.router: getMyAppRepo (protectedProcedure + flag, OWNER-gated) →
  provisions identity + addCollaborator(write) on this app's repo + returns the
  authed clone URL + push instructions. Not-approved apps return a "first
  version is ZIP-only" shape.

Isolation: restricted Forgejo user, write only on its own civitai-apps/<slug>
repo(s); a push parks a pending review request and CANNOT deploy without mod
approval (no-trust-on-push gate unchanged). Tests: forgejo APIs, the claim-first
provisioning incl. owner-wait + rollback + orphan edge, owner gate. Blocks
suite green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(app-blocks): "Author via git" UI + getMyAppRepo e2e (Phase 3)

Developer-facing surface for the git-push on-ramp + its preview e2e.

- AuthorViaGit.tsx: an "Author via git" panel under each approved submission the
  user owns on /apps/my-submissions. Mount-on-expand — getMyAppRepo is only
  called when the user clicks (it provisions a Forgejo identity as a side
  effect, so it must be user-initiated, never on page load; query is
  staleTime/gcTime 0). Clone URL is credential-MASKED by default
  (maskCloneUrlCredential, unit-tested) with a reveal toggle; copy buttons copy
  the real value. Note: "first version is ZIP; new versions via git push; pushes
  go to mod review, never auto-deploy." Non-owner FORBIDDEN / not-approved are
  shown as muted states, never a crash.
- preview-apps-git-access.spec.ts: e2e (mod fixture) asserting the SECURITY-
  critical owner-gate live — getMyAppRepo on a non-owned app → FORBIDDEN (throws
  before any Forgejo user/collaborator is created), unknown id → NOT_FOUND. The
  full provisioning happy-path (real Forgejo user + git push → park) is unit-
  covered + a manual preview check (shared-Forgejo state, no safe teardown),
  mirroring the publish spec's approve exclusion.

git-access unit 5/5; e2e discovered under preview-smoke.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(app-blocks): audit follow-ups — wedge recovery, token masking, ban gate (Phase 3)

Audit fixes for PR #2587. (The load-bearing approved-sha integrity / no-trust-on-
push property was verified SOUND — sha pinned end-to-end, content-addressed.)

HIGH — permanent provisioning wedge: a claim row is a standalone insert, so a
hard owner crash (pod kill) between the claim and the token write left an empty-
token row with no recovery → that user was locked out of git access forever.
ensureForgejoIdentity now treats an empty-token claim older than 60s as
ABANDONED and atomically reclaims it (optimistic-concurrency guarded on
createdAt), then provisions it — no permanent wedge. +test.

MEDIUM — the "Steps" block rendered the token-bearing clone URL in cleartext
regardless of the reveal toggle (the masker was anchored to a bare URL and
didn't touch the embedded URL in the instructions string). maskCloneUrlCredential
is now a global mask that handles an embedded credential in any text; the Steps
snippet is masked under the same reveal toggle. +test.

MEDIUM — getMyAppRepo now refuses to issue a push credential to a banned account
(ctx.user.bannedAt → FORBIDDEN). Full revoke-on-ban remains a follow-up.

Deferred (documented as follow-ups): orphan-recreate re-grant across the dev's
other repos; getMyAppRepo query→mutation; >1000-file tree pagination; Forgejo-
user cleanup on GDPR delete. MUST verify before launch: Forgejo restricted-user +
private-repo isolation (a dev PAT can't clone other apps / the starter / review
org) — config-dependent, not verifiable from the app code.

Blocks + git-access suites green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(app-blocks): reclaim guard must be range-based (timestamptz µs vs JS-Date ms)

The stale-claim reclaim guarded on exact createdAt equality, but Postgres
timestamptz(6) stores microseconds while Prisma reads a millisecond-precision JS
Date — the equality would systematically miss, so abandoned claims would never
be reclaimed (the wedge fix was inert). Switched to a range guard (createdAt <
now-60s), which also serializes concurrent reclaimers via the row lock.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(app-blocks): conditional token-fill/rollback + ban-gate test (re-audit)

Second-audit follow-ups (verdict was settled/mergeable; these close the last
narrow edge + a test nit).

LOW edge: a single owner that stalled >60s between createForgejoUser and the DB
write could, after a waiter reclaimed + filled the row, OVERWRITE the winner's
good token with its own (now minted against a deleted Forgejo user) → a dead
token persisted forever. The fill is now a CONDITIONAL updateMany guarded on the
still-empty token (count 0 ⇒ we were superseded ⇒ return the winner's stored
token, never persist a dead one). The failure rollback is likewise deleteMany
guarded on the empty token, so it can't nuke a reclaimer's filled row.

NIT: added a getMyAppRepo test asserting a banned owner → FORBIDDEN with nothing
provisioned.

dev-git-access + getMyAppRepo suites green (15/15 affected).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 08:28:35 -05:00

7.8 KiB