feat(app-blocks): record client-claimed source provenance on publish requests (#4059) (#4061)

* feat(app-blocks): record client-claimed source provenance on publish requests (#4059)

`appBlocks.submitVersion` accepted a bundle and stored nothing about where it
came from, so deploy-vs-source drift could be observed but never diagnosed —
five first-party apps were found behind their live version and recovering the
mapping took a session of archaeology. `submitVersionSchema` is a plain
`z.object`, so a client sending provenance today is silently stripped: the
inert-feature shape. This is the server half; the CLI stamping half (civitai/cli#411)
is deliberately blocked on it.

Two NULLABLE columns on `app_block_publish_requests`, accepted at submit and
returned on read:

  source_commit  the 40-hex git sha the CLIENT says the bundle was built from
  source_dirty   whether the CLIENT says that work tree had uncommitted changes

Decisions worth stating:

- CLIENT-CLAIMED, NEVER SERVER-VERIFIED. The server validates the shape and
  stores a claim; it cannot confirm the bundle was built from that commit.
  Nothing downstream may imply it did.
- NOT `forgejo_commit_sha`, which the same row already carries. That is a
  SERVER-side sha written on approve after the platform's own Forgejo commit
  succeeds — a different commit in a different repository. Never aliased,
  defaulted, or fallen back between; `recordPendingFromPush` (the Forgejo push
  path) deliberately leaves both new columns NULL.
- BOTH OPTIONAL. A client that sends neither submits exactly as before. This is
  never a submit gate.
- A MALFORMED `sourceCommit` REJECTS with 400 rather than being dropped —
  silently dropping is the same inert-feature failure the issue exists to close.
  Lowercase 40-hex only.
- NULL != false. `source_dirty` is tri-state: NULL = unknown, false = the client
  asserted CLEAN, true = the client asserted DIRTY. No backfill and no DEFAULT,
  because either would turn "nobody looked" into "someone looked and it was
  clean" for every pre-feature row.

Both submit front doors are wired, not just one: `/api/blocks/submit-version`
(mod browser) and `/api/v1/blocks/submit-version` (the route the CLI posts to).
Wiring only the first would have left the actual client's provenance stripped.

The 400 body no longer answers every parse failure with the flat
'Invalid bundle payload'. A failure confined to the provenance fields now names
the field; a genuine bundle failure still reads exactly the legacy string.

🔴 THE MIGRATION IS AUTHORED BUT NOT APPLIED TO ANY DATABASE. It must be applied
BEFORE deploying the app that writes these columns — a new pod against a table
without them fails the INSERT, and Prisma's no-`select` queries raise P2022.
Both columns are nullable with no default, so an old pod writing a row without
them stays valid, and the columns must never be dropped on a rollback.

* fix(app-blocks): JSON null is UNKNOWN, not a 400; carry provenance through reset-to-pending (#4059)

Three findings from an adversarial audit of the #4059 server half.

1. EXPLICIT JSON `null` REJECTED THE WHOLE SUBMIT WITH 400.

`submitVersionSchema` used `.optional()`, which accepts `undefined` only. JSON
has no `undefined`, so `null` is the natural encoding of the UNKNOWN state this
feature is built on — and any client holding these as nullable in its own model
sent exactly that. Measured against the repo's zod (4.0.17): `{"sourceDirty":
null}` REJECTED, and `{"sourceCommit": null}` REJECTED with "sourceCommit must
be a 40-character lowercase hex git commit sha", which is wrong about what
happened. That directly contradicted this feature's own rule that provenance
must never become a submit gate.

Both fields are now `.nullish()` with a `?? undefined` normalisation, and the
WIRE CONTRACT — previously stated nowhere and tested nowhere — is written into
the schema: omit the key OR send `null` = UNKNOWN; `false` = client asserted
CLEAN; `true` = client asserted DIRTY. The tri-state is unchanged and pinned.

The normalisation is load-bearing rather than cosmetic. Prisma OMITS an
`undefined` field from the INSERT but emits an explicit NULL for a `null` one:
the resulting ROW is identical, the SQL is not. Letting `null` through would
have made every provenance-less submit name the new columns, widening the
migration-ordering hazard below from conditional to unconditional.

Deliberately NOT added: a `.max()` on `sourceCommit`. It was measured to buy
nothing — zod runs the regex anyway (2 issues, not 1, for an over-long value),
and the anchored fixed-length regex costs ~0.02ms on a 70 MB string either way.
An earlier 13ms reading was V8 warm-up, not regex work.

2. `resetToPending` SILENTLY DROPPED THE PROVENANCE IT COULD CARRY.

The onsite reset-to-pending clone re-submits a BYTE-IDENTICAL bundle (same
bundleKey, same bundleSha256) and already carried manifest, fileSummary,
manifestDiffSummary and forgejoCommitSha forward — but never fetched
sourceCommit/sourceDirty, so any app through a suspend -> reset-to-pending cycle
permanently lost the answer to "which tree did these bytes come from?", which is
the archaeology #4059 exists to remove. Both are now selected and carried,
copied RAW including NULL, with no `??` fallback of any kind.

This is NOT the `recordPendingFromPush` case, which correctly leaves both NULL
(no client, no author work tree) and is untouched.

3. THE MIGRATION HEADER MIS-STATED WHICH PATH BREAKS FIRST.

It named the INSERT as the failure mode. That is the narrower, CONDITIONAL path:
Prisma omits `undefined` fields, so a client sending no provenance produces
today's INSERT exactly. The real first casualty is the READ —
`/api/v1/blocks/submissions` adds both columns to a SELECT serving both the
findFirst and the findMany, unconditionally, for every caller regardless of what
any client sends. Against a table without the columns that is a 100% failure of
GET /api/v1/blocks/submissions with P2022. The ordering table now states the
read blast radius and keeps the INSERT note as the secondary, conditional case.
Comment only; the SQL is unchanged.

Also: the recordPendingFromPush provenance block in the orchestration test was
measured to be an INVARIANT GUARD, not regression coverage (deleting both fields
from the submitVersion INSERT left that file green; only
publish-request.service.test.ts went red). It is now labelled as one, and its
vacuous `not.toBe(pushArgs.sha)` — strictly implied by the toBeUndefined() above
it, so it could never fail on its own — is replaced by a key-ABSENCE assertion
that can: an explicit `sourceCommit: undefined` satisfies toBeUndefined() and
fails the new check. Absence is also the load-bearing property, since it is what
keeps that path's SQL byte-for-byte what it was before #4059.

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

* docs(migration): the WRITE fails unconditionally too — RETURNING, not the INSERT column list

The ordering comment said the write failure was "SECONDARY and CONDITIONAL —
only a submit that actually CARRIES provenance names a column the table lacks".
That is wrong, and wrong in the reassuring direction: it reads as "day one is
safe for everyone who sends nothing", which is exactly the belief that gets a
deploy scheduled ahead of an apply.

Prisma does omit an `undefined` field from the INSERT column list, so the data a
no-provenance client sends really is unchanged. That is not what breaks. The
`create` at publish-request.service.ts:1249 passes no `select`, so Prisma reads
the row back — INSERT ... RETURNING <every scalar in the model> — and the model
now knows about both columns. Every submit raises P2022, including from a client
that has never heard of provenance.

The same comment already stated this as a general rule two paragraphs later and
then exempted the write from it. It no longer does.

MEASURED, not reasoned. With this code on a preview whose dev-clone DB lacked
the columns, `preview / smoke-tests` failed with exactly 1 failure on two
successive commits, while a control PR passed 65/65 in the same window.
tests/preview-apps-publish.spec.ts is the only smoke spec that submits. The SQL
has since been applied to that dev clone (columns present, nullable, no default;
re-apply is a clean no-op) — this commit is what confirms whether that clears it.

Comment only: the SQL is byte-identical, verified by diffing non-comment lines.

* docs(migration): the dev-clone apply IS the fix — 65/65, so this is a hard deploy prerequisite

Resolves the provisional note left by fe832d3dfc, which said only that the smoke
result was 'being confirmed'. It confirmed: the run immediately after applying
this SQL to the preview's dev clone went 'success — 65 passed, 0 flaky', matching
the control PR exactly. Two failing commits before the apply, green on the first
run after it.

So the apply is not correlated with the fix, it is the fix — which makes this
migration a hard prerequisite of the deploy rather than a tidy-up that can trail
it. Says so, and says the same reasoning transfers to prod: the preview failure
is what a prod deploy-before-apply looks like, observed somewhere harmless.

Comment only; SQL byte-identical, verified by diffing non-comment lines.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Zachary Lowden
2026-08-18 13:57:07 -05:00
committed by GitHub
parent 1ad8ae3443
commit 490b330f3b
17 changed files with 1162 additions and 7 deletions
@@ -0,0 +1,169 @@
-- ============================================================
-- App Blocks — publish-request SOURCE PROVENANCE (#4059)
-- ============================================================
-- `appBlocks.submitVersion` accepts a bundle and records nothing about where it
-- came from, so a bundle built from an uncommitted tree is indistinguishable,
-- afterwards, from one built from a tagged release. Deploy-vs-source drift is
-- observable but not diagnosable.
--
-- Measured cost of that gap: five first-party apps were found behind their live
-- deployed version, one (`custom-generators`) live on a 0.5.2 that has NEVER
-- existed anywhere in its git history. Reconciling them was a session of
-- archaeology. Two columns turn that into a lookup.
--
-- source_commit the 40-hex git sha the CLIENT says the bundle was built from
-- source_dirty whether the CLIENT says that work tree had uncommitted changes
--
-- 🔴 CLIENT-CLAIMED, NEVER SERVER-VERIFIED. The server accepts these off the
-- submit body. It validates the SHAPE (lowercase 40-hex, enforced in
-- `submitVersionSchema`) and stores the value as a CLAIM. It cannot and does not
-- confirm the uploaded bundle was actually built from that commit — a client is
-- free to send a sha it never built. Nothing downstream may present these as
-- verified facts; they are a diagnosis aid, not an attestation.
--
-- 🔴 NOT `forgejo_commit_sha`, WHICH THIS TABLE ALREADY HAS. That column is
-- SERVER-side: it is written on approve, after the platform's own Forgejo commit
-- succeeds, so it is a fact about what the platform did. `source_commit` is a
-- claim about the AUTHOR'S OWN TREE, made before review. They are different
-- commits in different repositories and must never be aliased, defaulted from
-- each other, or fallen back between. The push-originated recorder
-- (`recordPendingFromPush`) writes `forgejo_commit_sha` and deliberately leaves
-- both of these NULL.
--
-- ------------------------------------------------------------------
-- NULLABLE, NO DEFAULT, AND DELIBERATELY NO BACKFILL.
-- ------------------------------------------------------------------
-- Every publish request submitted before this column existed was submitted by a
-- client that computed nothing and sent nothing. There is no value that could be
-- inferred for those rows: the bundle bytes are in MinIO but the tree they came
-- from is exactly the thing that was never recorded.
--
-- So NULL means UNKNOWN, in both columns, and that is a load-bearing distinction
-- for `source_dirty` specifically:
--
-- source_dirty = NULL we do not know whether the tree was dirty
-- source_dirty = false the client asserted the tree was CLEAN
-- source_dirty = true the client asserted the tree was DIRTY
--
-- A backfill of `false` would convert "nobody looked" into "someone looked and
-- it was clean" for every historical row — turning the absence of evidence into
-- positive evidence of exactly the property the feature exists to establish.
-- Do not add one later either; a DEFAULT false would do the same thing to every
-- future row written by a client that sends nothing.
--
-- ------------------------------------------------------------------
-- ⚠️ ORDERING: APPLY THIS BEFORE DEPLOYING THE APP THAT WRITES IT.
-- ------------------------------------------------------------------
-- Both columns are nullable with no default, so once they EXIST the mixed-pod
-- window is safe in both directions. Apply this first and there is no window at
-- all. Deploy first and the READ path is what breaks, immediately and for
-- everyone:
--
-- code │ columns │ outcome
-- ─────┼──────────┼────────────────────────────────────────────────────────
-- old │ present │ safe — the SELECT never names them; INSERT omits them
-- new │ present │ safe — the claim is read and stored
-- old │ MISSING │ today's behaviour
-- new │ MISSING │ ✗✗ TOTAL OUTAGE OF BOTH READ **AND** WRITE
--
-- 🔴 BOTH PATHS FAIL, BOTH UNCONDITIONALLY. An earlier revision of this comment
-- called the write "SECONDARY and CONDITIONAL — only a submit that actually
-- CARRIES provenance names a column the table lacks". **That was wrong**, and it
-- was wrong in the reassuring direction. It is corrected here rather than
-- deleted, because "day one is safe for everyone who sends nothing" is exactly
-- the belief that gets a deploy scheduled ahead of an apply.
--
-- READ — `src/pages/api/v1/blocks/submissions.ts` adds BOTH columns to its
-- `SELECT` constant, which serves BOTH the `findFirst` and the `findMany`. It
-- does not depend on what any client sends, on whether any row has provenance,
-- or on anyone using the feature: a 100% failure of
-- `GET /api/v1/blocks/submissions` with Prisma P2022. The CLI's status and poll
-- commands go down wholesale.
--
-- WRITE — equally unconditional, by a DIFFERENT mechanism. It is true that
-- Prisma omits an `undefined` field from the INSERT column list, so the *data*
-- a no-provenance client sends is unchanged. That is not what breaks. The
-- `create` at `publish-request.service.ts:1249` passes **no `select`**, so
-- Prisma reads the row back — `INSERT … RETURNING <every scalar in the model>` —
-- and the model now knows about both columns. So EVERY submit raises P2022,
-- including from a client that has never heard of provenance. `submitVersion`
-- is the only writer on that path, and both front doors funnel through it.
--
-- This is the SAME generic hazard stated below, applied to the write; the
-- earlier text asserted the general rule and then exempted the write from it.
-- MEASURED, not reasoned: with the code deployed to a preview whose dev-clone DB
-- lacked the columns, `preview / smoke-tests` failed with exactly 1 failure —
-- `tests/preview-apps-publish.spec.ts`, the only smoke spec that submits — on
-- two successive commits, while a control PR passed 65/65 in the same window.
-- The SQL below was then applied to that dev clone (columns confirmed present,
-- nullable, no default; a re-apply is a clean no-op) and the very next run went
-- `success — 65 passed, 0 flaky`, matching the control PR exactly. So the apply
-- is not merely correlated with the fix: it is the whole fix, and this migration
-- is a HARD PREREQUISITE of the deploy rather than a tidy-up that can follow it.
--
-- 🔴 THE SAME APPLIES TO PROD, FOR THE SAME REASON. The preview failure is what
-- a prod deploy-before-apply looks like, observed in a safe place: every submit
-- and every status poll, down, for everyone, until the columns exist.
--
-- Same precedent as 20260731120000_app_block_spend_tier_and_cap_override; the
-- generic form of the hazard is that Prisma enumerates every scalar in the
-- model when a query gives no `select` at all, so even queries that never
-- mention these columns raise P2022 once the model knows about them. That
-- applies to a `create` reading its row back exactly as it does to a SELECT.
--
-- ⚠️ NEVER DROP THESE COLUMNS ON A ROLLBACK. Old Prisma clients emit explicit
-- column lists, so extra columns are inert to them — leaving them costs nothing,
-- while dropping them destroys the only record of which bundles came from which
-- tree, which is precisely the information that was expensive to recover by hand.
--
-- ⚠️ MANUAL APPLY — per datapacket-talos CLAUDE.md DB rule #8 the main civitai
-- CNPG nvme0 DB does NOT auto-apply migrations. This file is committed for
-- history; a HUMAN applies the SQL below per environment. CI / deploy does NOT
-- run it.
--
-- ADDITIVE + NON-BREAKING:
-- - Both columns nullable with no default → PG 11+ metadata-only ALTER, no
-- table rewrite, safe online on a large table.
-- - No index: neither column is a filter predicate. They are read per-row on
-- an already-self-scoped / primary-key-filtered query
-- (`/api/v1/blocks/submissions`) and looked up by hand during a drift
-- investigation. Add one if a scan-by-commit query ever appears; it does not
-- exist today.
-- - No CHECK on `source_commit`: the shape gate is `submitVersionSchema`'s
-- `/^[0-9a-f]{40}$/`. A DB CHECK is deliberately NOT added because this
-- column is advisory — a rejected INSERT here would fail a SUBMIT over a
-- field that must never be a submit gate. Contrast `Placement_spendType_check`,
-- where a bad value reaches a PAYOUT.
-- - `IF NOT EXISTS` makes the apply idempotent (re-runnable, safe online).
--
-- The table is snake_case-`@@map`ped (`@@map("app_block_publish_requests")`) and
-- its columns are `@map`ped too, so both identifiers here are snake_case —
-- unlike the camelCase-quoted `"Placement"."spendType"` style elsewhere in this
-- directory. Confirmed against 20260731120000_app_block_spend_tier_and_cap_override,
-- which alters `"app_blocks"."spend_tier"` the same way.
ALTER TABLE "app_block_publish_requests"
ADD COLUMN IF NOT EXISTS "source_commit" TEXT,
ADD COLUMN IF NOT EXISTS "source_dirty" BOOLEAN;
-- VERIFY THE SCHEMA. `IF NOT EXISTS` is a no-op on retry and reports success
-- either way, so confirm the columns landed rather than reading a clean exit as
-- proof. Must return exactly two rows, both `is_nullable = YES` and both
-- `column_default` NULL — a non-NULL default here is the backfill this migration
-- refuses, arriving by another route:
--
-- SELECT column_name, is_nullable, data_type, column_default
-- FROM information_schema.columns
-- WHERE table_name = 'app_block_publish_requests'
-- AND column_name IN ('source_commit', 'source_dirty');
--
-- VERIFY THE BEHAVIOUR, after the deploy. Nothing above proves the app writes
-- them. Submit once from a CLI that stamps provenance, then:
--
-- SELECT id, slug, version, source_commit, source_dirty, submitted_at
-- FROM app_block_publish_requests
-- ORDER BY submitted_at DESC LIMIT 5;
--
-- The new row must carry a 40-hex `source_commit` and a non-NULL `source_dirty`.
-- A row submitted by an OLD CLI legitimately carries NULL in both — that is the
-- feature working, not a fault.
@@ -2763,6 +2763,15 @@ model AppBlockPublishRequest {
rejectionReason String? @map("rejection_reason")
approvalNotes String? @map("approval_notes")
forgejoCommitSha String? @map("forgejo_commit_sha") // populated on approve after Forgejo commit succeeds
// #4059 build provenance — an UNTRUSTED CLIENT CLAIM about the tree the bundle
// was built from. NOT interchangeable with forgejoCommitSha above: that is a
// SERVER-side sha written on approve once the Forgejo commit succeeded, so it
// is a fact. These two are whatever the submitting client said; the server
// never confirms the bundle was actually built from sourceCommit, and nothing
// downstream may imply it did. NULL = unknown (a pre-feature row, or a client
// that sent nothing) — never "clean".
sourceCommit String? @map("source_commit") // client-claimed 40-hex git sha the bundle was built from
sourceDirty Boolean? @map("source_dirty") // client-claimed uncommitted-changes flag. NULL (unknown) != false (known clean)
deployState String? @map("deploy_state") // Phase 2 build/deploy lifecycle (approved requests): building|deploying|live|failed
deployDetail String? @map("deploy_detail") // human-readable detail, primarily the failure reason
deployUpdatedAt DateTime? @map("deploy_updated_at") @db.Timestamptz(6) // last deploy_state transition
@@ -306,6 +306,8 @@ export type AppBlockPublishRequest = {
rejection_reason: string | null;
approval_notes: string | null;
forgejo_commit_sha: string | null;
source_commit: string | null;
source_dirty: boolean | null;
deploy_state: string | null;
deploy_detail: string | null;
deploy_updated_at: Timestamp | null;
+2
View File
@@ -1987,6 +1987,8 @@ export interface AppBlockPublishRequest {
rejectionReason: string | null;
approvalNotes: string | null;
forgejoCommitSha: string | null;
sourceCommit: string | null;
sourceDirty: boolean | null;
deployState: string | null;
deployDetail: string | null;
deployUpdatedAt: Date | null;
+16 -3
View File
@@ -1,6 +1,9 @@
import type { NextApiResponse } from 'next';
import { isProd } from '~/env/other';
import { submitVersionSchema } from '~/server/schema/blocks/publish-request.schema';
import {
submitVersionParseErrorMessage,
submitVersionSchema,
} from '~/server/schema/blocks/publish-request.schema';
import { isAppBlocksEnabled } from '~/server/services/app-blocks-flag';
import { ModEndpoint } from '~/server/utils/endpoint-helpers';
import { isAllowedOriginRequest } from '~/server/utils/origin-helpers';
@@ -63,7 +66,10 @@ export default ModEndpoint(
const parsed = submitVersionSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ message: 'Invalid bundle payload' });
// Names the offending field when the failure is confined to the #4059
// provenance fields; still exactly 'Invalid bundle payload' for a bundle
// problem. See submitVersionParseErrorMessage.
res.status(400).json({ message: submitVersionParseErrorMessage(parsed.error) });
return;
}
@@ -82,7 +88,14 @@ export default ModEndpoint(
try {
const { submitVersion } = await import('~/server/services/blocks/publish-request.service');
const result = await submitVersion({ bundleBuffer, submittedByUserId: user.id });
const result = await submitVersion({
bundleBuffer,
submittedByUserId: user.id,
// #4059 — pass the client's provenance CLAIM through untouched. Absent
// stays absent; the service must not invent a value.
sourceCommit: parsed.data.sourceCommit,
sourceDirty: parsed.data.sourceDirty,
});
res.status(200).json(result);
} catch (err) {
// Service throws plain Errors with human-readable messages (bundle too
+23
View File
@@ -106,6 +106,8 @@ type SubmissionRow = {
deployState: string | null;
deployDetail: string | null;
deployUpdatedAt: Date | null;
sourceCommit: string | null;
sourceDirty: boolean | null;
submittedAt: Date;
reviewedAt: Date | null;
updatedAt: Date;
@@ -141,6 +143,23 @@ function shapeRow(row: SubmissionRow, appsDomain: string) {
deployState: row.deployState, // null | 'building' | 'deploying' | 'live' | 'failed'
deployDetail: row.deployDetail,
deployUpdatedAt: row.deployUpdatedAt ? row.deployUpdatedAt.toISOString() : null,
// #4059 build provenance — the submitting CLIENT'S OWN CLAIM about the tree
// the bundle was built from, echoed back unverified. The server never
// confirmed the bundle was built from `sourceCommit`, so a consumer must not
// render either of these as an attestation.
//
// 🔴 NULL IS NOT FALSE, and both are passed through raw for exactly that
// reason. `sourceDirty: null` means UNKNOWN (a pre-#4059 row, or a client
// that sent nothing); `sourceDirty: false` means the client asserted the tree
// was CLEAN. Coercing null to false would turn "nobody looked" into "someone
// looked and it was clean" — the opposite of what this feature is for. Do not
// add a `?? false` here or in any consumer.
//
// Deliberately NOT `forgejoCommitSha`, which stays off this projection with
// the rest of the internal-only columns: that is a server-side sha in the
// platform's own repo, not the author's.
sourceCommit: row.sourceCommit,
sourceDirty: row.sourceDirty,
submittedAt: row.submittedAt.toISOString(),
reviewedAt: row.reviewedAt ? row.reviewedAt.toISOString() : null,
updatedAt: row.updatedAt.toISOString(),
@@ -162,6 +181,10 @@ const SELECT = {
deployState: true,
deployDetail: true,
deployUpdatedAt: true,
// #4059 — client-claimed build provenance. Safe to project: it is the caller's
// OWN submission and the value came from that caller in the first place.
sourceCommit: true,
sourceDirty: true,
submittedAt: true,
reviewedAt: true,
updatedAt: true,
+17 -3
View File
@@ -4,7 +4,10 @@ import type { NextApiRequest, NextApiResponse } from 'next';
import type { SessionUser } from '~/types/session';
import { getSessionFromBearerToken } from '~/server/auth/bearer-token';
import { sysRedis, REDIS_SYS_KEYS, withSysReadDeadline } from '~/server/redis/client';
import { submitVersionSchema } from '~/server/schema/blocks/publish-request.schema';
import {
submitVersionParseErrorMessage,
submitVersionSchema,
} from '~/server/schema/blocks/publish-request.schema';
import { isAppBlocksAuthorEnabled, isAppBlocksEnabled } from '~/server/services/app-blocks-flag';
import { TokenScope } from '~/shared/constants/token-scope.constants';
import { Flags } from '~/shared/utils/flags';
@@ -241,7 +244,11 @@ export default withAxiom(async (req: AxiomAPIRequest, res: NextApiResponse) => {
// with the MAX_BUNDLE_SIZE_BYTES pre-decode cap).
const parsed = submitVersionSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ message: 'Invalid bundle payload' });
// Names the offending field when the failure is confined to the #4059
// provenance fields; still exactly 'Invalid bundle payload' for a bundle
// problem. This is the route the CLI actually posts to, so an unnamed
// rejection here is the one that costs an author a debugging session.
res.status(400).json({ message: submitVersionParseErrorMessage(parsed.error) });
return;
}
@@ -261,7 +268,14 @@ export default withAxiom(async (req: AxiomAPIRequest, res: NextApiResponse) => {
// publish logic is not forked; this route is only a second auth front-door.
try {
const { submitVersion } = await import('~/server/services/blocks/publish-request.service');
const result = await submitVersion({ bundleBuffer, submittedByUserId: user.id });
const result = await submitVersion({
bundleBuffer,
submittedByUserId: user.id,
// #4059 — pass the client's provenance CLAIM through untouched. Absent
// stays absent; the service must not invent a value.
sourceCommit: parsed.data.sourceCommit,
sourceDirty: parsed.data.sourceDirty,
});
// The service returns a richer object; the CLI contract is the stable subset.
// `status` is always 'pending' for a fresh submission (mod review queue).
res.status(200).json({
@@ -57,10 +57,120 @@ export const submitVersionSchema = z.object({
.string()
.min(1)
.max(Math.ceil((MAX_BUNDLE_SIZE_BYTES * 4) / 3) + 16),
// #4059 build provenance. Both OPTIONAL: a client that sends neither submits
// exactly as it did before this existed, and this must never become a submit
// gate — an author whose tooling predates it, or who builds outside a git
// checkout, still ships.
//
// ── WIRE CONTRACT ──────────────────────────────────────────────────────────
// These are the THREE answers a client can give, and only three:
//
// omit the key entirely → UNKNOWN
// send the key as JSON `null` → UNKNOWN (the same answer, spelled twice)
// send a value → the claim
//
// and for `sourceDirty` the value half is itself two DIFFERENT answers:
// `false` = the client asserted the tree was CLEAN
// `true` = the client asserted the tree was DIRTY
// `false` is a claim, not an absence. Never collapse it into UNKNOWN, and
// never `?? false` an UNKNOWN into it — the tri-state is the feature.
//
// 🔴 `.nullish()`, NOT `.optional()`. `.optional()` accepts `undefined` only,
// and JSON has no `undefined` — so a client encoding UNKNOWN the natural way,
// as `{"sourceDirty": null}`, had its WHOLE SUBMIT rejected with a 400, and
// for `sourceCommit` that 400 read "must be a 40-character lowercase hex git
// commit sha", which is wrong about what happened. That is precisely the
// submit gate this field is forbidden to be. Measured against zod 4.0.17.
//
// The `?? undefined` normalisation is load-bearing, not tidying: it is what
// keeps a `null` off the Prisma `create` data. Prisma OMITS an `undefined`
// field from the INSERT but emits an explicit `NULL` for a `null` one — the
// resulting ROW is identical either way, but the SQL is not, and an explicit
// NULL would make EVERY provenance-less submit name the new columns, turning
// the migration-ordering hazard (see the migration header) from conditional
// into unconditional. `SubmitVersionParams` types both as `?: T` and never
// `| null`, so the seam cannot quietly drift back.
// ───────────────────────────────────────────────────────────────────────────
//
// 🔴 UNTRUSTED CLIENT CLAIM. The server validates the SHAPE and stores the
// value; it CANNOT confirm the uploaded bundle was actually built from this
// commit, and a client is free to send a sha it never built from. Nothing
// downstream — UI, API, CLI — may render or describe these as verified. They
// are a diagnosis aid for deploy-vs-source drift, not an attestation.
//
// 🔴 NOT `forgejoCommitSha`, which this row also carries. That one is
// SERVER-side, written on approve after the platform's own Forgejo commit
// succeeded — a fact about a different repository. Never alias, default, or
// fall back between the two.
//
// A malformed value REJECTS the request rather than being dropped. Zod's
// default object mode strips unknown keys silently, which is exactly how a
// provenance-sending client would look like it worked while storing nothing —
// the inert-feature shape this field exists to close. So the regex is a hard
// 400: lowercase 40-hex only (git's own canonical rendering; accepting
// uppercase too would mean the same commit is two distinct strings in the
// column and a lookup has to normalise).
sourceCommit: z
.string()
.regex(/^[0-9a-f]{40}$/)
.nullish()
.transform((v) => v ?? undefined),
// Whether the client's work tree had uncommitted changes at build time.
// Tri-state at rest: absent/null = UNKNOWN, false = the client asserted CLEAN,
// true = the client asserted DIRTY. `false` and `null` are DIFFERENT answers
// and stay distinguishable end to end — do not `?? false` this anywhere.
sourceDirty: z
.boolean()
.nullish()
.transform((v) => v ?? undefined),
});
export type SubmitVersionInput = z.infer<typeof submitVersionSchema>;
/**
* Per-field hints for a `submitVersionSchema` rejection. Only the #4059
* provenance fields appear here, on purpose see below.
*/
const SUBMIT_VERSION_FIELD_HINTS: Record<string, string> = {
sourceCommit: 'sourceCommit must be a 40-character lowercase hex git commit sha',
sourceDirty: 'sourceDirty must be a boolean',
};
/** The message both submit routes have always returned for a parse failure. */
export const INVALID_BUNDLE_MESSAGE = 'Invalid bundle payload';
/**
* Render a `submitVersionSchema` parse failure as a message that NAMES the field
* that failed.
*
* 🔴 Why this exists: both submit routes answered EVERY parse failure with the
* flat `'Invalid bundle payload'`. Once the schema also validates provenance, a
* client whose `sourceCommit` was the wrong shape would be told its BUNDLE was
* bad and go hunting the zip the diagnosis cost this feature exists to remove,
* re-created one layer up.
*
* Genuine bundle failures are UNCHANGED: any issue touching `bundleBase64`
* (or an unrecognised path, e.g. a non-object body) still yields exactly
* `INVALID_BUNDLE_MESSAGE`, so nothing that used to read that string reads
* something else now. Only a failure confined to the provenance fields gets the
* named message.
*
* One copy, called from both routes: the predicate was open-coded at two call
* sites already and would have drifted at the next edit.
*/
export function submitVersionParseErrorMessage(error: z.ZodError): string {
const fields = new Set(
error.issues.map((issue) => (typeof issue.path[0] === 'string' ? issue.path[0] : ''))
);
const named = [...fields].filter((f) => f in SUBMIT_VERSION_FIELD_HINTS);
// A bundle problem (or anything unrecognised) wins — bundle behaviour is frozen.
if (named.length === 0 || named.length !== fields.size) return INVALID_BUNDLE_MESSAGE;
return `Invalid submit payload: ${named
.sort()
.map((f) => SUBMIT_VERSION_FIELD_HINTS[f])
.join('; ')}`;
}
export const withdrawRequestSchema = z.object({
publishRequestId: z.string().min(1).max(64),
});
@@ -0,0 +1,246 @@
import { describe, expect, it } from 'vitest';
import {
INVALID_BUNDLE_MESSAGE,
submitVersionParseErrorMessage,
submitVersionSchema,
} from '~/server/schema/blocks/publish-request.schema';
/**
* #4059 `submitVersionSchema` build provenance (`sourceCommit` / `sourceDirty`).
*
* 🔴 THE ANTI-STRIP TEST IS THE POINT OF THIS FILE. A plain `z.object` STRIPS
* unknown keys silently, so before these fields existed a client sending
* provenance got no error and no storage it looked like it worked and stored
* nothing. So it is not enough to assert that a payload carrying provenance
* PARSES: it has to assert the values are PRESENT ON `parsed.data`. Deleting the
* fields from the schema must turn this file red.
*
* The other half is that a malformed `sourceCommit` REJECTS rather than being
* dropped silently dropping is the same inert-feature failure wearing a
* different hat.
*/
// 40 lowercase hex, containing digits AND af, and not a repeated character —
// so a mutant that widens the class or drops the length anchor has something to
// be caught by. Every off-shape fixture below is an independent literal rather
// than a transform of this one.
const VALID_SHA = '4f3a9c2e17b06d85fa1c39e470b28d6ac519e0f3';
const BUNDLE = Buffer.from('fake-zip-bytes').toString('base64');
function parse(over: Record<string, unknown> = {}) {
return submitVersionSchema.safeParse({ bundleBase64: BUNDLE, ...over });
}
describe('submitVersionSchema — #4059 provenance (accept)', () => {
it('ACCEPTS a well-formed pair AND KEEPS BOTH VALUES on parsed.data (anti-strip)', () => {
const parsed = parse({ sourceCommit: VALID_SHA, sourceDirty: true });
expect(parsed.success).toBe(true);
if (!parsed.success) return;
// Not "it parsed" — the values SURVIVED the parse. This is the assertion a
// stripping schema fails.
expect(parsed.data.sourceCommit).toBe('4f3a9c2e17b06d85fa1c39e470b28d6ac519e0f3');
expect(parsed.data.sourceDirty).toBe(true);
});
it('KEEPS sourceDirty:false — false is a CLAIM (clean), not an absence', () => {
const parsed = parse({ sourceCommit: VALID_SHA, sourceDirty: false });
expect(parsed.success).toBe(true);
if (!parsed.success) return;
// `toBe(false)` and not a falsy check: `undefined` would pass a falsy check
// and mean the opposite thing (unknown).
expect(parsed.data.sourceDirty).toBe(false);
expect('sourceDirty' in parsed.data).toBe(true);
});
it('ACCEPTS a payload with NEITHER field (no-regression: this must never gate a submit)', () => {
const parsed = parse();
expect(parsed.success).toBe(true);
if (!parsed.success) return;
expect(parsed.data.bundleBase64).toBe(BUNDLE);
expect(parsed.data.sourceCommit).toBeUndefined();
expect(parsed.data.sourceDirty).toBeUndefined();
});
it('ACCEPTS sourceCommit alone (sourceDirty stays UNKNOWN, not false)', () => {
const parsed = parse({ sourceCommit: VALID_SHA });
expect(parsed.success).toBe(true);
if (!parsed.success) return;
expect(parsed.data.sourceCommit).toBe('4f3a9c2e17b06d85fa1c39e470b28d6ac519e0f3');
// 🔴 Absent, NOT coerced to false — the two are different answers.
expect(parsed.data.sourceDirty).toBeUndefined();
});
});
/**
* 🔴 JSON NULL IS THE WIRE ENCODING OF "UNKNOWN", AND IT MUST NOT 400.
*
* `.optional()` accepts `undefined` only, and JSON has no `undefined` so a
* client whose own model holds these as nullable, encoding UNKNOWN the natural
* way as `{"sourceDirty": null}`, had its WHOLE SUBMIT rejected with a 400. For
* `sourceCommit` the message even read "must be a 40-character lowercase hex git
* commit sha", which is wrong about what happened. Provenance is never allowed
* to be a submit gate; this file is where that is pinned.
*
* Each case asserts BOTH halves: the parse succeeds, AND the value that comes
* out is `undefined` (not `null`), because `undefined` is what makes Prisma OMIT
* the column from the INSERT rather than emit an explicit NULL.
*/
describe('submitVersionSchema — #4059 provenance (JSON null === UNKNOWN)', () => {
it('ACCEPTS sourceCommit: null and normalises it to undefined', () => {
const parsed = parse({ sourceCommit: null });
expect(parsed.success).toBe(true);
if (!parsed.success) return;
expect(parsed.data.sourceCommit).toBeUndefined();
// Explicitly NOT null: a null reaching Prisma names the column in the INSERT.
expect(parsed.data.sourceCommit).not.toBeNull();
});
it('ACCEPTS sourceDirty: null and normalises it to undefined', () => {
const parsed = parse({ sourceDirty: null });
expect(parsed.success).toBe(true);
if (!parsed.success) return;
expect(parsed.data.sourceDirty).toBeUndefined();
expect(parsed.data.sourceDirty).not.toBeNull();
// 🔴 null is UNKNOWN, never the `false` claim. If this ever reads `false`,
// "nobody looked" has been turned into "someone looked and it was clean".
expect(parsed.data.sourceDirty).not.toBe(false);
});
it('ACCEPTS BOTH null (the shape a nullable-model client sends when it knows nothing)', () => {
const parsed = parse({ sourceCommit: null, sourceDirty: null });
expect(parsed.success).toBe(true);
if (!parsed.success) return;
expect(parsed.data.sourceCommit).toBeUndefined();
expect(parsed.data.sourceDirty).toBeUndefined();
// The bundle is untouched — the submit proceeds exactly as before.
expect(parsed.data.bundleBase64).toBe(BUNDLE);
});
it('ACCEPTS sourceCommit: null beside a REAL sourceDirty (mixed known/unknown)', () => {
const parsed = parse({ sourceCommit: null, sourceDirty: true });
expect(parsed.success).toBe(true);
if (!parsed.success) return;
expect(parsed.data.sourceCommit).toBeUndefined();
// The sibling that WAS supplied survives intact — null on one field must not
// take the other down with it.
expect(parsed.data.sourceDirty).toBe(true);
});
it('ACCEPTS sourceDirty: null beside a REAL sourceCommit (mixed known/unknown)', () => {
const parsed = parse({ sourceCommit: VALID_SHA, sourceDirty: null });
expect(parsed.success).toBe(true);
if (!parsed.success) return;
expect(parsed.data.sourceCommit).toBe('4f3a9c2e17b06d85fa1c39e470b28d6ac519e0f3');
expect(parsed.data.sourceDirty).toBeUndefined();
});
it('the sourceDirty TRI-STATE survives: null→undefined, false→false, true→true', () => {
// 🔴 Each row asserts `.success` FIRST. Reading `.data?.sourceDirty` alone
// would pass vacuously on a REJECT (`data` is undefined there, so the
// `toBeUndefined()` row succeeds for the wrong reason) — measured: that is
// exactly how an earlier draft of this test stayed green against the
// pre-fix `.optional()` schema while the other six went red.
const cases: Array<[unknown, boolean | undefined]> = [
[null, undefined],
[false, false],
[true, true],
];
for (const [wire, expected] of cases) {
const parsed = parse({ sourceDirty: wire });
expect(parsed.success).toBe(true);
if (!parsed.success) continue;
expect(parsed.data.sourceDirty).toBe(expected);
}
});
it('a null does NOT produce a 400 message (the gate this closes)', () => {
const parsed = parse({ sourceCommit: null, sourceDirty: null });
// Nothing to render: there is no error. The pre-fix behaviour produced
// 'Invalid submit payload: sourceCommit must be a 40-character lowercase hex
// git commit sha', which named a malformation that had not occurred.
expect(parsed.success).toBe(true);
expect(parsed.error).toBeUndefined();
});
});
describe('submitVersionSchema — #4059 provenance (reject)', () => {
it('REJECTS a 39-hex sourceCommit (too short)', () => {
const parsed = parse({ sourceCommit: '4f3a9c2e17b06d85fa1c39e470b28d6ac519e0f' });
expect(parsed.success).toBe(false);
});
it('REJECTS a 41-hex sourceCommit (too long)', () => {
const parsed = parse({ sourceCommit: '4f3a9c2e17b06d85fa1c39e470b28d6ac519e0f3a' });
expect(parsed.success).toBe(false);
});
it('REJECTS an UPPERCASE 40-hex sourceCommit (lowercase is the canonical rendering)', () => {
const parsed = parse({ sourceCommit: '4F3A9C2E17B06D85FA1C39E470B28D6AC519E0F3' });
expect(parsed.success).toBe(false);
});
it('REJECTS a non-hex 40-character sourceCommit', () => {
const parsed = parse({ sourceCommit: 'zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz' });
expect(parsed.success).toBe(false);
});
it('REJECTS a non-string sourceCommit', () => {
const parsed = parse({ sourceCommit: 1234567890 });
expect(parsed.success).toBe(false);
});
it('REJECTS sourceDirty as the STRING "true" (no coercion)', () => {
const parsed = parse({ sourceDirty: 'true' });
expect(parsed.success).toBe(false);
});
it('a rejection is NOT a silent drop — the bundle does not sneak through', () => {
// The failure mode this replaces: a stripping schema would have returned
// success with the bad field gone, and the submit would have proceeded.
const parsed = parse({ sourceCommit: 'nope' });
expect(parsed.success).toBe(false);
expect(parsed.data).toBeUndefined();
});
});
describe('submitVersionParseErrorMessage', () => {
it('NAMES sourceCommit when the failure is confined to it', () => {
const parsed = parse({ sourceCommit: 'nope' });
expect(parsed.success).toBe(false);
if (parsed.success) return;
const msg = submitVersionParseErrorMessage(parsed.error);
expect(msg).toContain('sourceCommit');
// And it is NOT the flat bundle message — that is the whole point.
expect(msg).not.toBe(INVALID_BUNDLE_MESSAGE);
});
it('NAMES sourceDirty when the failure is confined to it', () => {
const parsed = parse({ sourceDirty: 'true' });
expect(parsed.success).toBe(false);
if (parsed.success) return;
const msg = submitVersionParseErrorMessage(parsed.error);
expect(msg).toContain('sourceDirty');
expect(msg).not.toBe(INVALID_BUNDLE_MESSAGE);
});
it('leaves a genuine BUNDLE failure reading exactly the legacy message', () => {
const parsed = submitVersionSchema.safeParse({ bundleBase64: '' });
expect(parsed.success).toBe(false);
if (parsed.success) return;
expect(submitVersionParseErrorMessage(parsed.error)).toBe('Invalid bundle payload');
});
it('a MIXED failure (bundle + provenance) still reads as the bundle message', () => {
const parsed = submitVersionSchema.safeParse({ bundleBase64: '', sourceCommit: 'nope' });
expect(parsed.success).toBe(false);
if (parsed.success) return;
expect(submitVersionParseErrorMessage(parsed.error)).toBe('Invalid bundle payload');
});
it('a non-object body reads as the bundle message (unrecognised path)', () => {
const parsed = submitVersionSchema.safeParse('not-an-object');
expect(parsed.success).toBe(false);
if (parsed.success) return;
expect(submitVersionParseErrorMessage(parsed.error)).toBe('Invalid bundle payload');
});
});
@@ -73,6 +73,13 @@ const onsiteListing = {
appBlockId: 'apb_1',
};
/**
* The client-claimed source commit on the approved row being cloned. Distinct
* from `forgejoCommitSha` below in every character, so an assertion cannot pass
* by the two being confused for one another.
*/
const SOURCE_SHA = '4f3a9c2e17b06d85fa1c39e470b28d6ac519e0f3';
/** The latest approved block publish request cloned into the fresh pending one. */
const lastApprovedReq = {
appBlockId: 'apb_1',
@@ -84,6 +91,9 @@ const lastApprovedReq = {
fileSummary: { files: [], added: [], removed: [], changed: [] },
manifestDiffSummary: { kind: 'update' },
forgejoCommitSha: 'sha_abc',
// #4059 client-claimed provenance on the approved row.
sourceCommit: SOURCE_SHA,
sourceDirty: true,
};
beforeEach(() => {
@@ -157,6 +167,97 @@ describe('resetOnsiteListingToPending', () => {
expect(res.publishRequestId).toMatch(/^pubreq_/);
});
/**
* #4059 the clone must CARRY the client-claimed provenance forward.
*
* The justification is narrow and it is the only one: this clone re-submits a
* BYTE-IDENTICAL bundle (same `bundleKey`, same `bundleSha256`) that the
* approved row already carried. A claim about where those exact bytes came
* from is still the SAME claim about the SAME bytes carrying it is not
* inventing anything. Dropping it, by contrast, permanently loses the answer
* to "which tree did these bytes come from?" for any app that goes through a
* suspend reset-to-pending cycle, which is exactly the archaeology #4059
* exists to make unnecessary.
*
* 🔴 This is NOT the `recordPendingFromPush` case, which correctly leaves both
* NULL: that path has no client and no author work tree, so there is no claim
* to carry. Here there is one, and it is already attached to these bytes.
*/
describe('#4059 provenance carry-forward', () => {
it('SELECTS both provenance columns on the latest-approved read', async () => {
await resetOnsiteListingToPending({
input: { appListingId: 'apl_1', reason: 'needs another look' },
reviewerUserId: MOD,
});
// Call 0 is the latest-approved lookup (call 1 is the open-pending probe).
const select = mockRead.appBlockPublishRequest.findFirst.mock.calls[0][0].select;
expect(select).toMatchObject({ sourceCommit: true, sourceDirty: true });
});
it('CARRIES sourceCommit + sourceDirty onto the cloned pending request', async () => {
await resetOnsiteListingToPending({
input: { appListingId: 'apl_1', reason: 'needs another look' },
reviewerUserId: MOD,
});
const reqArg = mockWrite.appBlockPublishRequest.create.mock.calls[0][0].data;
expect(reqArg.sourceCommit).toBe('4f3a9c2e17b06d85fa1c39e470b28d6ac519e0f3');
expect(reqArg.sourceDirty).toBe(true);
// And it is the AUTHOR'S claim, not the platform's Forgejo sha — the two
// travel together on this row and must never be aliased.
expect(reqArg.forgejoCommitSha).toBe('sha_abc');
expect(reqArg.sourceCommit).not.toBe(reqArg.forgejoCommitSha);
// The bytes are what licenses the carry-forward; assert they really are
// the same bytes, so this test fails loudly if the clone ever stops being
// byte-identical while still copying the claim.
expect(reqArg.bundleKey).toBe('bundles/deadbeef.zip');
expect(reqArg.bundleSha256).toBe('deadbeef');
});
it('CARRIES sourceDirty:false as FALSE (a CLAIM of clean, not an absence)', async () => {
mockRead.appBlockPublishRequest.findFirst
.mockReset()
.mockResolvedValueOnce({ ...lastApprovedReq, sourceDirty: false })
.mockResolvedValueOnce(null);
await resetOnsiteListingToPending({
input: { appListingId: 'apl_1', reason: 'needs another look' },
reviewerUserId: MOD,
});
const reqArg = mockWrite.appBlockPublishRequest.create.mock.calls[0][0].data;
// `toBe(false)`, not a falsy check: null/undefined would pass a falsy
// check and mean UNKNOWN, the opposite answer.
expect(reqArg.sourceDirty).toBe(false);
expect(reqArg.sourceDirty).not.toBeNull();
expect(reqArg.sourceDirty).not.toBeUndefined();
});
// 🔴 INVARIANT GUARD, NOT REGRESSION COVERAGE — this one was measured GREEN
// against the pre-carry-forward service, because a path that writes neither
// column trivially satisfies "neither column was invented". It earns its
// place against the FALLBACK mutants (`?? forgejoCommitSha`, `?? false`),
// which it does kill; do not count it as coverage for the drop this
// describe-block fixes — the three siblings above are that.
it('does NOT INVENT a value when the approved row has NULLs (unknown stays unknown)', async () => {
mockRead.appBlockPublishRequest.findFirst
.mockReset()
.mockResolvedValueOnce({ ...lastApprovedReq, sourceCommit: null, sourceDirty: null })
.mockResolvedValueOnce(null);
await resetOnsiteListingToPending({
input: { appListingId: 'apl_1', reason: 'needs another look' },
reviewerUserId: MOD,
});
const reqArg = mockWrite.appBlockPublishRequest.create.mock.calls[0][0].data;
expect(reqArg.sourceCommit ?? null).toBeNull();
expect(reqArg.sourceDirty ?? null).toBeNull();
// 🔴 And specifically NOT fallen back to the Forgejo sha, which IS present
// on this row — the one substitution that would look plausible and be a
// fabricated claim about an author's tree.
expect(reqArg.forgejoCommitSha).toBe('sha_abc');
expect(reqArg.sourceCommit).not.toBe('sha_abc');
// Nor coerced to the `false` claim.
expect(reqArg.sourceDirty).not.toBe(false);
});
});
it('NOT_FOUND for a missing listing', async () => {
mockRead.appListing.findUnique.mockReset().mockResolvedValue(null);
await expect(
@@ -3076,6 +3076,30 @@ describe('recordPendingFromPush', () => {
// Push-originated marker: empty bundle pointers.
expect(createArg.data.bundleKey).toBe('');
expect(createArg.data.bundleSha256).toBe('');
// 🔴 #4059 — INVARIANT GUARD, NOT REGRESSION COVERAGE. Say so plainly: this
// block was MEASURED to stay green when both provenance fields were deleted
// from the `submitVersion` INSERT (only publish-request.service.test.ts went
// red), because `recordPendingFromPush` never wrote them in the first place
// and the bug it would have to catch has never existed on this path. It
// pins an invariant; it does not cover the submit path. Do not count it.
//
// The invariant: this path has NO client and NO author work tree, so it sets
// NEITHER provenance column — they stay NULL (unknown). `forgejoCommitSha`
// above is a SERVER-side commit in the platform's own repo; copying it into
// `sourceCommit` would manufacture a claim about an author's tree that
// nobody made.
expect(createArg.data.sourceCommit).toBeUndefined();
expect(createArg.data.sourceDirty).toBeUndefined();
// The key must be ABSENT, not merely `undefined`-valued. This CAN fail while
// the two `toBeUndefined()`s above pass — an explicit `sourceCommit:
// undefined` in the create data satisfies them and fails this — which the
// old `expect(...).not.toBe(pushArgs.sha)` could not do: that one was
// strictly implied by `toBeUndefined()` and could never fail on its own.
// Absence is also the load-bearing property: Prisma OMITS an absent field
// from the INSERT but names the column for an explicit `null`, so absence is
// what keeps this path's SQL byte-for-byte what it was before #4059.
expect(Object.keys(createArg.data)).not.toContain('sourceCommit');
expect(Object.keys(createArg.data)).not.toContain('sourceDirty');
});
it('(c) create throws P2002 → re-reads the EXACT-sha winner and returns its id', async () => {
@@ -691,4 +691,76 @@ describe('submitVersion — sensitive-scope justification gate (enforced AT SUBM
expect(dbMocks.createPublishRequest).not.toHaveBeenCalled();
expect(dbMocks.appListingCreate).not.toHaveBeenCalled();
});
// ---------------------------------------------------------------------------
// #4059 build provenance — the service must PERSIST the client's claim into
// the publish-request row, and must not invent one when the client sent
// nothing. Asserted against the mocked `create` call args with literal values.
// ---------------------------------------------------------------------------
const SHA = '4f3a9c2e17b06d85fa1c39e470b28d6ac519e0f3';
function createData() {
const call = dbMocks.createPublishRequest.mock.calls[0][0] as {
data: Record<string, unknown>;
};
return call.data;
}
it('#4059 PERSISTS sourceCommit + sourceDirty into the publish-request row', async () => {
const buf = await makeBundle({ ...baseManifest, scopes: [] });
await submitVersion({
bundleBuffer: buf,
submittedByUserId: 1,
sourceCommit: SHA,
sourceDirty: true,
});
expect(dbMocks.createPublishRequest).toHaveBeenCalledTimes(1);
const data = createData();
expect(data.sourceCommit).toBe('4f3a9c2e17b06d85fa1c39e470b28d6ac519e0f3');
expect(data.sourceDirty).toBe(true);
});
it('#4059 persists sourceDirty:false as FALSE (a CLAIM of clean, not an absence)', async () => {
const buf = await makeBundle({ ...baseManifest, scopes: [] });
await submitVersion({
bundleBuffer: buf,
submittedByUserId: 1,
sourceCommit: SHA,
sourceDirty: false,
});
const data = createData();
// 🔴 `toBe(false)` and not a falsy check — `undefined` here would mean
// UNKNOWN, the opposite claim.
expect(data.sourceDirty).toBe(false);
});
it('#4059 writes NEITHER column when the client sends nothing (NULL = unknown)', async () => {
const buf = await makeBundle({ ...baseManifest, scopes: [] });
await submitVersion({ bundleBuffer: buf, submittedByUserId: 1 });
const data = createData();
// Prisma omits an `undefined` field, so the column lands NULL. The service
// must NOT default `sourceDirty` to false — that would turn "nobody looked"
// into "someone looked and it was clean".
expect(data.sourceCommit).toBeUndefined();
expect(data.sourceDirty).toBeUndefined();
});
it('#4059 does NOT conflate sourceCommit with forgejoCommitSha', async () => {
const buf = await makeBundle({ ...baseManifest, scopes: [] });
await submitVersion({
bundleBuffer: buf,
submittedByUserId: 1,
sourceCommit: SHA,
sourceDirty: false,
});
const data = createData();
expect(data.sourceCommit).toBe('4f3a9c2e17b06d85fa1c39e470b28d6ac519e0f3');
// The submit path never mints a Forgejo commit — that happens on approve. A
// fallback between the two would show up here as the sha leaking across.
expect(data.forgejoCommitSha).toBeUndefined();
});
});
@@ -1210,6 +1210,9 @@ export async function resetOnsiteListingToPending(opts: {
fileSummary: true,
manifestDiffSummary: true,
forgejoCommitSha: true,
// #4059 — selected so the clone below can CARRY them. See the create.
sourceCommit: true,
sourceDirty: true,
},
});
if (!lastApproved) {
@@ -1279,6 +1282,29 @@ export async function resetOnsiteListingToPending(opts: {
fileSummary: lastApproved.fileSummary as Prisma.InputJsonValue,
manifestDiffSummary: lastApproved.manifestDiffSummary as Prisma.InputJsonValue,
forgejoCommitSha: lastApproved.forgejoCommitSha,
// #4059 — carry the client's provenance CLAIM forward, verbatim.
//
// The justification is narrow and it is the only one: this clone
// re-submits BYTE-IDENTICAL bundle bytes (same `bundleKey`, same
// `bundleSha256` as the approved row above). A claim about which tree
// those exact bytes came from is still the SAME claim about the SAME
// bytes — copying it invents nothing. Dropping it would permanently
// lose the answer to "which tree did these bytes come from?" for any
// app that goes through a suspend → reset-to-pending cycle, which is
// the archaeology #4059 exists to make unnecessary.
//
// Copied RAW, including NULL: a NULL on the source row means UNKNOWN
// and must stay UNKNOWN here. No `??` fallback of any kind — least of
// all to `forgejoCommitSha`, which is a SERVER-side sha in the
// platform's own repo and would fabricate an author's-tree claim
// nobody made. `false` likewise stays `false` (asserted CLEAN), never
// folded into UNKNOWN.
//
// 🔴 NOT the `recordPendingFromPush` case, which correctly writes
// NEITHER: that path has no client and no author work tree, so there
// is no claim in existence to carry.
sourceCommit: lastApproved.sourceCommit,
sourceDirty: lastApproved.sourceDirty,
status: 'pending',
},
});
@@ -107,6 +107,23 @@ export type ManifestDiffSummary =
export type SubmitVersionParams = {
bundleBuffer: Buffer;
submittedByUserId: number;
/**
* #4059 build provenance an UNTRUSTED CLIENT CLAIM about the tree the bundle
* was built from. Shape-validated by `submitVersionSchema` (lowercase 40-hex)
* and stored verbatim; the server never confirms the bundle was actually built
* from it, so nothing downstream may present it as a verified fact.
*
* 🔴 NOT `forgejoCommitSha`. That is a SERVER-side sha written on approve after
* the platform's own Forgejo commit succeeds a fact about a different
* repository. Never alias, default, or fall back between the two.
*/
sourceCommit?: string;
/**
* The client's claim that its work tree had uncommitted changes. Tri-state:
* `undefined` = unknown, `false` = client asserted CLEAN, `true` = client
* asserted DIRTY. `false` and unknown are different answers never `?? false`.
*/
sourceDirty?: boolean;
};
export type SubmitVersionResult = {
@@ -1013,7 +1030,7 @@ export async function submitVersion(params: SubmitVersionParams): Promise<Submit
import('~/server/utils/app-block-ids'),
import('~/server/schema/blocks/publish-request.schema'),
]);
const { bundleBuffer, submittedByUserId } = params;
const { bundleBuffer, submittedByUserId, sourceCommit, sourceDirty } = params;
if (bundleBuffer.length > MAX_BUNDLE_SIZE_BYTES) {
throw new Error(`bundle is ${bundleBuffer.length} bytes (max ${MAX_BUNDLE_SIZE_BYTES})`);
@@ -1243,6 +1260,17 @@ export async function submitVersion(params: SubmitVersionParams): Promise<Submit
fileSummary: fileSummary as object,
manifestDiffSummary: manifestDiffSummary as object,
status: 'pending',
// #4059 — the client's UNVERIFIED provenance claim, stored as a claim.
// Passing `undefined` leaves the column NULL (Prisma omits an undefined
// field), which is what an old client sending nothing must produce:
// NULL means UNKNOWN in both columns, never "clean".
//
// 🔴 `sourceCommit` is NOT `forgejoCommitSha` (written on approve, after
// the platform's own Forgejo commit succeeds — a SERVER fact about a
// different repo). Different commits, different repositories, different
// trust. Never alias, default, or fall back between them.
sourceCommit,
sourceDirty,
},
});
} catch (err) {
@@ -1905,6 +1933,12 @@ export async function recordPendingFromPush(args: {
},
status: 'pending',
forgejoCommitSha: args.sha,
// #4059: `sourceCommit` / `sourceDirty` are deliberately NOT set here and
// stay NULL. This path has no client and no author work tree — the row
// originates from a Forgejo push, so `args.sha` is a SERVER-side commit in
// the platform's own repo. Copying it into `sourceCommit` would turn a
// server fact into a fabricated claim about an author's tree that nobody
// made. NULL is the correct answer: unknown.
},
});
} catch (err) {
+100
View File
@@ -232,4 +232,104 @@ describe('POST /api/blocks/submit-version', () => {
expect(res._status).toBe(400);
expect(res._body).toEqual({ message: 'bundle exceeds 50 MiB' });
});
// ---------------------------------------------------------------------------
// #4059 build provenance. The route's job is to pass the client's CLAIM
// through unchanged and to reject a malformed one with a message that names
// the field — a provenance rejection reported as "Invalid bundle payload"
// sends the author hunting the zip.
// ---------------------------------------------------------------------------
const SHA = '4f3a9c2e17b06d85fa1c39e470b28d6ac519e0f3';
it('passes sourceCommit + sourceDirty through to the service verbatim', async () => {
const res = makeRes();
await invoke(makeReq({ body: { bundleBase64, sourceCommit: SHA, sourceDirty: true } }), res);
expect(res._status).toBe(200);
const arg = mockSubmitVersion.mock.calls[0][0];
expect(arg.sourceCommit).toBe('4f3a9c2e17b06d85fa1c39e470b28d6ac519e0f3');
expect(arg.sourceDirty).toBe(true);
});
it('passes sourceDirty:false through as FALSE, not as absent', async () => {
const res = makeRes();
await invoke(makeReq({ body: { bundleBase64, sourceCommit: SHA, sourceDirty: false } }), res);
expect(res._status).toBe(200);
const arg = mockSubmitVersion.mock.calls[0][0];
expect(arg.sourceDirty).toBe(false);
});
it('a submit with NO provenance still reaches the service with both undefined', async () => {
const res = makeRes();
await invoke(makeReq({ body: { bundleBase64 } }), res);
expect(res._status).toBe(200);
const arg = mockSubmitVersion.mock.calls[0][0];
expect(arg.sourceCommit).toBeUndefined();
expect(arg.sourceDirty).toBeUndefined();
});
// 🔴 JSON `null` = UNKNOWN and must NOT 400 — pinned at the ROUTE as well as
// at the schema, so the two surfaces cannot disagree in isolation.
it('JSON null on both provenance fields is a 200 (UNKNOWN), reaching the service as undefined', async () => {
const res = makeRes();
await invoke(makeReq({ body: { bundleBase64, sourceCommit: null, sourceDirty: null } }), res);
expect(res._status).toBe(200);
expect(mockSubmitVersion).toHaveBeenCalledTimes(1);
const arg = mockSubmitVersion.mock.calls[0][0];
// undefined, NOT null — undefined is what makes Prisma omit the column.
expect(arg.sourceCommit).toBeUndefined();
expect(arg.sourceDirty).toBeUndefined();
expect(arg.sourceCommit).not.toBeNull();
expect(arg.sourceDirty).not.toBeNull();
});
it('JSON null on ONE field does not take a valid SIBLING down with it', async () => {
const res = makeRes();
await invoke(makeReq({ body: { bundleBase64, sourceCommit: SHA, sourceDirty: null } }), res);
expect(res._status).toBe(200);
const arg = mockSubmitVersion.mock.calls[0][0];
expect(arg.sourceCommit).toBe('4f3a9c2e17b06d85fa1c39e470b28d6ac519e0f3');
expect(arg.sourceDirty).toBeUndefined();
});
it('400s a malformed sourceCommit with a message NAMING the field, and never submits', async () => {
const res = makeRes();
await invoke(makeReq({ body: { bundleBase64, sourceCommit: 'not-a-sha' } }), res);
expect(res._status).toBe(400);
expect((res._body as { message: string }).message).toContain('sourceCommit');
// 🔴 The rejection must NOT be reported as a bundle problem.
expect((res._body as { message: string }).message).not.toBe('Invalid bundle payload');
// And it is a rejection, not a silent drop.
expect(mockSubmitVersion).not.toHaveBeenCalled();
});
it('400s an UPPERCASE sourceCommit (lowercase 40-hex only)', async () => {
const res = makeRes();
await invoke(
makeReq({ body: { bundleBase64, sourceCommit: '4F3A9C2E17B06D85FA1C39E470B28D6AC519E0F3' } }),
res
);
expect(res._status).toBe(400);
expect((res._body as { message: string }).message).toContain('sourceCommit');
expect(mockSubmitVersion).not.toHaveBeenCalled();
});
it('400s a non-boolean sourceDirty with a message NAMING the field', async () => {
const res = makeRes();
await invoke(makeReq({ body: { bundleBase64, sourceDirty: 'true' } }), res);
expect(res._status).toBe(400);
expect((res._body as { message: string }).message).toContain('sourceDirty');
expect(mockSubmitVersion).not.toHaveBeenCalled();
});
it('BUNDLE rejections keep the exact legacy message (unchanged by #4059)', async () => {
const res = makeRes();
await invoke(makeReq({ body: { bundleBase64: '' } }), res);
expect(res._status).toBe(400);
expect(res._body).toEqual({ message: 'Invalid bundle payload' });
const res2 = makeRes();
await invoke(makeReq({ body: {} }), res2);
expect(res2._status).toBe(400);
expect(res2._body).toEqual({ message: 'Invalid bundle payload' });
});
});
+105
View File
@@ -158,6 +158,12 @@ function dbRow(over: Partial<Record<string, unknown>> = {}) {
deployState: 'live',
deployDetail: null,
deployUpdatedAt: new Date('2026-06-22T00:00:00Z'),
// #4059: the DEFAULT fixture is a LEGACY row — submitted before provenance
// existed, so both columns are NULL (unknown). The happy-path `toEqual`
// below therefore doubles as the legacy-shape assertion: a legacy row must
// shape to `null`, not be dropped from the payload and not be coerced.
sourceCommit: null,
sourceDirty: null,
submittedAt: new Date('2026-06-20T00:00:00Z'),
reviewedAt: new Date('2026-06-21T00:00:00Z'),
updatedAt: new Date('2026-06-22T00:00:00Z'),
@@ -331,6 +337,9 @@ describe('GET /api/v1/blocks/submissions', () => {
deployState: 'live',
deployDetail: null,
deployUpdatedAt: '2026-06-22T00:00:00.000Z',
// #4059 — a legacy (pre-provenance) row: both NULL, both PRESENT.
sourceCommit: null,
sourceDirty: null,
submittedAt: '2026-06-20T00:00:00.000Z',
reviewedAt: '2026-06-21T00:00:00.000Z',
updatedAt: '2026-06-22T00:00:00.000Z',
@@ -459,6 +468,102 @@ describe('GET /api/v1/blocks/submissions', () => {
);
});
// ---------------------------------------------------------------------------
// #4059 build provenance on the READ path. This is the projection
// `civitai app status` reads, so a value that is stored but not returned is
// the same inert feature as a value that was never stored.
// ---------------------------------------------------------------------------
describe('#4059 source provenance', () => {
const SHA = '4f3a9c2e17b06d85fa1c39e470b28d6ac519e0f3';
it('SURFACES a populated sourceCommit + sourceDirty:true', async () => {
mockGetSession.mockResolvedValueOnce(MOD_SESSION);
mockFindMany.mockResolvedValueOnce([dbRow({ sourceCommit: SHA, sourceDirty: true })]);
const { req, res } = authGet();
await handler(req as never, res as never);
expect(res._getStatusCode()).toBe(200);
const out = res._getJSONData() as {
submissions: Array<{ sourceCommit: string | null; sourceDirty: boolean | null }>;
};
expect(out.submissions[0].sourceCommit).toBe('4f3a9c2e17b06d85fa1c39e470b28d6ac519e0f3');
expect(out.submissions[0].sourceDirty).toBe(true);
});
/**
* 🔴 `null` (UNKNOWN) and `false` (the client asserted CLEAN) are DIFFERENT
* answers and must stay distinguishable end to end. A `?? false` anywhere on
* this path collapses them and turns "nobody looked" into "someone looked and
* it was clean" for every pre-#4059 row a fabricated fact, and precisely
* the opposite of what this feature exists to establish. These two tests are
* a pair: neither alone can see the collapse.
*/
it('a legacy row keeps sourceDirty:NULL — not dropped, not coerced to false', async () => {
mockGetSession.mockResolvedValueOnce(MOD_SESSION);
mockFindMany.mockResolvedValueOnce([dbRow({ sourceCommit: null, sourceDirty: null })]);
const { req, res } = authGet();
await handler(req as never, res as never);
const s = (res._getJSONData() as { submissions: Array<Record<string, unknown>> })
.submissions[0];
// Present as a key (not dropped) AND null (not coerced).
expect('sourceDirty' in s).toBe(true);
expect('sourceCommit' in s).toBe(true);
expect(s.sourceDirty).toBeNull();
expect(s.sourceCommit).toBeNull();
expect(s.sourceDirty).not.toBe(false);
});
it('a KNOWN-CLEAN row reads sourceDirty:false — and false is not null', async () => {
mockGetSession.mockResolvedValueOnce(MOD_SESSION);
mockFindMany.mockResolvedValueOnce([dbRow({ sourceCommit: SHA, sourceDirty: false })]);
const { req, res } = authGet();
await handler(req as never, res as never);
const s = (res._getJSONData() as { submissions: Array<Record<string, unknown>> })
.submissions[0];
expect(s.sourceDirty).toBe(false);
expect(s.sourceDirty).not.toBeNull();
});
it('the ?id= single-item read surfaces provenance too', async () => {
mockGetSession.mockResolvedValueOnce(MOD_SESSION);
mockFindFirst.mockResolvedValueOnce(dbRow({ sourceCommit: SHA, sourceDirty: false }));
const { req, res } = authGet({ id: 'pubreq_01' });
await handler(req as never, res as never);
expect(res._getStatusCode()).toBe(200);
const out = res._getJSONData() as {
submission: { sourceCommit: string | null; sourceDirty: boolean | null };
};
expect(out.submission.sourceCommit).toBe('4f3a9c2e17b06d85fa1c39e470b28d6ac519e0f3');
expect(out.submission.sourceDirty).toBe(false);
});
it('the SELECT projection asks the DB for both columns', async () => {
mockGetSession.mockResolvedValueOnce(MOD_SESSION);
const { req, res } = authGet();
await handler(req as never, res as never);
// Without this the columns are never fetched, so shapeRow reads
// `undefined` and the keys vanish from the JSON — stored but invisible.
const call = mockFindMany.mock.calls[0][0] as { select: Record<string, unknown> };
expect(call.select.sourceCommit).toBe(true);
expect(call.select.sourceDirty).toBe(true);
// The SERVER-side forgejo sha stays OFF this projection: different thing,
// internal-only, and never the author's commit.
expect(call.select.forgejoCommitSha).toBeUndefined();
});
it('does NOT leak forgejoCommitSha alongside the client claim', async () => {
mockGetSession.mockResolvedValueOnce(MOD_SESSION);
mockFindMany.mockResolvedValueOnce([
dbRow({ sourceCommit: SHA, sourceDirty: true, forgejoCommitSha: 'server-side-sha' }),
]);
const { req, res } = authGet();
await handler(req as never, res as never);
const s = (res._getJSONData() as { submissions: Array<Record<string, unknown>> })
.submissions[0];
expect(s).not.toHaveProperty('forgejoCommitSha');
expect(s.sourceCommit).toBe('4f3a9c2e17b06d85fa1c39e470b28d6ac519e0f3');
});
});
it('429 when the per-user rate limit is exceeded', async () => {
mockGetSession.mockResolvedValueOnce(MOD_SESSION);
mockMultiIncr.value = 61; // > RATE_LIMIT.max (60)
@@ -436,4 +436,109 @@ describe('POST /api/v1/blocks/submit-version (token auth)', () => {
expect(res._getStatusCode()).toBe(400);
expect((res._getJSONData() as { message: string }).message).toContain('50 MiB');
});
// ---------------------------------------------------------------------------
// #4059 build provenance. 🔴 THIS is the route the `civitai` CLI posts to —
// the session route beside it is the mod-browser front door. Wiring only that
// one would leave the CLI's provenance silently stripped, which is exactly the
// inert-feature shape #4059 exists to close. Both routes are wired; both are
// pinned.
// ---------------------------------------------------------------------------
const SHA = '4f3a9c2e17b06d85fa1c39e470b28d6ac519e0f3';
it('passes sourceCommit + sourceDirty through to the service verbatim', async () => {
mockGetSession.mockResolvedValueOnce(MOD_SESSION);
const { req, res } = createMocks({
headers: { authorization: 'Bearer good-key' },
body: { ...goodBody, sourceCommit: SHA, sourceDirty: true },
});
await handler(req as never, res as never);
expect(res._getStatusCode()).toBe(200);
const callArg = mockSubmitVersion.mock.calls[0][0];
expect(callArg.sourceCommit).toBe('4f3a9c2e17b06d85fa1c39e470b28d6ac519e0f3');
expect(callArg.sourceDirty).toBe(true);
});
it('passes sourceDirty:false through as FALSE, not as absent', async () => {
mockGetSession.mockResolvedValueOnce(MOD_SESSION);
const { req, res } = createMocks({
headers: { authorization: 'Bearer good-key' },
body: { ...goodBody, sourceCommit: SHA, sourceDirty: false },
});
await handler(req as never, res as never);
expect(res._getStatusCode()).toBe(200);
expect(mockSubmitVersion.mock.calls[0][0].sourceDirty).toBe(false);
});
it('a submit with NO provenance still reaches the service with both undefined', async () => {
mockGetSession.mockResolvedValueOnce(MOD_SESSION);
const { req, res } = createMocks({
headers: { authorization: 'Bearer good-key' },
body: goodBody,
});
await handler(req as never, res as never);
expect(res._getStatusCode()).toBe(200);
const callArg = mockSubmitVersion.mock.calls[0][0];
expect(callArg.sourceCommit).toBeUndefined();
expect(callArg.sourceDirty).toBeUndefined();
});
// 🔴 JSON `null` = UNKNOWN, and it MUST NOT 400. Asserted at the ROUTE and not
// only at the schema: the schema test exercises one surface, and "verified in
// isolation" is how a seam defect survives. This is the surface an actual CLI
// hits, and a 400 here rejects the WHOLE SUBMIT over an advisory field.
it('JSON null on both provenance fields is a 200 (UNKNOWN), reaching the service as undefined', async () => {
mockGetSession.mockResolvedValueOnce(MOD_SESSION);
const { req, res } = createMocks({
headers: { authorization: 'Bearer good-key' },
body: { ...goodBody, sourceCommit: null, sourceDirty: null },
});
await handler(req as never, res as never);
expect(res._getStatusCode()).toBe(200);
expect(mockSubmitVersion).toHaveBeenCalledOnce();
const callArg = mockSubmitVersion.mock.calls[0][0];
// undefined, NOT null — undefined is what makes Prisma omit the column.
expect(callArg.sourceCommit).toBeUndefined();
expect(callArg.sourceDirty).toBeUndefined();
expect(callArg.sourceCommit).not.toBeNull();
expect(callArg.sourceDirty).not.toBeNull();
});
it('JSON null on ONE field does not take a valid SIBLING down with it', async () => {
mockGetSession.mockResolvedValueOnce(MOD_SESSION);
const { req, res } = createMocks({
headers: { authorization: 'Bearer good-key' },
body: { ...goodBody, sourceCommit: SHA, sourceDirty: null },
});
await handler(req as never, res as never);
expect(res._getStatusCode()).toBe(200);
const callArg = mockSubmitVersion.mock.calls[0][0];
expect(callArg.sourceCommit).toBe('4f3a9c2e17b06d85fa1c39e470b28d6ac519e0f3');
expect(callArg.sourceDirty).toBeUndefined();
});
it('400s a malformed sourceCommit with a message NAMING the field, and never submits', async () => {
mockGetSession.mockResolvedValueOnce(MOD_SESSION);
const { req, res } = createMocks({
headers: { authorization: 'Bearer key' },
body: { ...goodBody, sourceCommit: 'not-a-sha' },
});
await handler(req as never, res as never);
expect(res._getStatusCode()).toBe(400);
const msg = (res._getJSONData() as { message: string }).message;
expect(msg).toContain('sourceCommit');
expect(msg).not.toBe('Invalid bundle payload');
expect(mockSubmitVersion).not.toHaveBeenCalled();
});
it('BUNDLE rejections keep the exact legacy message (unchanged by #4059)', async () => {
mockGetSession.mockResolvedValueOnce(MOD_SESSION);
const { req, res } = createMocks({
headers: { authorization: 'Bearer key' },
body: { bundleBase64: '' },
});
await handler(req as never, res as never);
expect(res._getStatusCode()).toBe(400);
expect(res._getJSONData()).toEqual({ message: 'Invalid bundle payload' });
});
});