mirror of
https://github.com/civitai/civitai.git
synced 2026-09-20 22:08:18 +08:00
58840582f6
* feat(app-blocks): server-own iframe.src + drop Dockerfile/nginx requirement Two developer-friction reductions for the App Blocks publish flow (Phase 1 of the HF-Spaces-inspired DX arc). Both are deterministic, no schema change. iframe.src is now PLATFORM-OWNED, not developer-authored. The only valid value is the canonical per-app subdomain root (https://<slug>.<APPS_DOMAIN>/), and every gate already demanded exactly that — so a developer had to hand-type a subdomain that doesn't exist until their app is approved, and a wrong value rejected them AFTER a multi-MiB upload. New pure helper `manifest-normalize.ts#stampCanonicalIframeSrc` derives + stamps it at all three ingestion points (submitVersion, approveRequest, the git-push webhook), mirroring how trustTier is already server-owned. approveRequest also rewrites the committed block.manifest.json so the build-source repo stays byte-consistent with app_blocks.manifest and the webhook re-validates the canonical value. Other iframe fields (minHeight, sandbox) stay developer-authored. Dockerfile/nginx.conf are no longer expected in the bundle: the build pipeline injects its own platform-owned recipe and already strips tenant copies at approve (isPlatformOwnedPath), and nothing required them (only block.manifest.json is mandatory). Updates the submit-page copy + the feature doc; the stripping behavior is unchanged. Tests: new manifest-normalize unit test; the submit-time iframe.src reject tests become stamp/normalize tests; the two approve-time H-4 tests now trigger via a sandbox token disallowed for the unverified tier (a webhook-rejectable shape that survives canonical-src stamping) so H-4 coverage is preserved; happy-path asserts the committed + stored manifest carry the canonical src. Full App Blocks suite green locally (423/423, node-only unit project). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(app-blocks): preview-e2e smoke for the publish-request submit leg Adds tests/preview-apps-publish.spec.ts to the preview-e2e suite, closing the coverage gap the existing apps specs (install/marketplace) left: they deliberately skip submit/approve. Runs as the provisioned `mod` fixture (ci-smoke-mod), self-seeding + self-cleaning against the shared dev DB. Asserts the deterministic DX change end-to-end on a live preview: a bundle whose manifest OMITS iframe.src and contains NO Dockerfile is ACCEPTED by POST /api/blocks/submit-version, and the stored manifest (read back via blocks.listPendingRequests) carries the server-stamped canonical https://<slug>.<APPS_DOMAIN>/. On origin/main the same bundle 400s with "manifest.iframe.src must be a string". SAFE + self-cleaning: per-preview slug (no cross-preview pending-unique collision), pre/post withdrawPublishRequest, and submit only touches the review org + MinIO + one dev-DB row — NO Tekton build, NO civitai-apps Deployment, NO CF DNS. approve→build→render is intentionally NOT e2e'd (real shared-infra + ~5min flaky build); its stamping is covered by the unit suite (orchestration + git-push.gate + manifest-normalize). Compiles + is discovered by playwright --list under the preview-smoke project; not yet run against a live preview (the run needs the preview NEXTAUTH_SECRET). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(app-blocks): commit faithful manifest (audit M1) — only stamp iframe.src Audit follow-up to PR #2581. The approve-time committed block.manifest.json was re-serialized from the stored JSONB (request.manifest), which (a) normalizes key order (Postgres jsonb) so the dev's build repo showed a confusing reorder, and (b) baked the server-resolved `trustTier` into the tenant-visible repo. Neither is a security/runtime issue (the runtime reads app_blocks.manifest, and the git-push webhook no-ops on the approved sha BEFORE validating), but it's an avoidable wart on the developer's repo. Now we commit the developer's ORIGINAL block.manifest.json bytes with ONLY the platform-owned iframe.src corrected — preserving their field order and not injecting server-resolved fields. Falls back to the stored manifest if the bundle's manifest is missing/unparseable. Also documents that the webhook's iframe.src exact-match is now defense-in-depth (the in-memory stamp makes it always match for an object manifest). Unit suite unchanged + green (orchestration happy-path still asserts the committed + stored manifest carry the canonical iframe.src). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
221 lines
9.7 KiB
TypeScript
221 lines
9.7 KiB
TypeScript
import { expect, test } from '@playwright/test';
|
|
import type { APIRequestContext } from '@playwright/test';
|
|
import JSZip from 'jszip';
|
|
import { storageStatePath } from './preview-fixtures';
|
|
import { trpcMutation, trpcQuery } from './preview-trpc';
|
|
|
|
/**
|
|
* Preview-e2e: App Blocks PUBLISH-REQUEST submit leg — proves the developer
|
|
* never authors `iframe.src` and never needs a `Dockerfile` in the bundle.
|
|
*
|
|
* The deterministic DX change under test (see `manifest-normalize.ts`):
|
|
* - `iframe.src` is PLATFORM-OWNED. A bundle whose manifest OMITS it is
|
|
* ACCEPTED, and the platform stamps the canonical per-app subdomain root
|
|
* (`https://<slug>.<APPS_DOMAIN>/`). On origin/main the same bundle was
|
|
* REJECTED at submit with "manifest.iframe.src must be a string".
|
|
* - `Dockerfile`/`nginx.conf` are NOT required in the bundle (only
|
|
* `block.manifest.json` is mandatory); the build pipeline injects its own.
|
|
*
|
|
* Runs as the `mod` fixture (id 2000000001): `/api/blocks/submit-version` is a
|
|
* `ModEndpoint` and `features.appBlocks` (the Flipt mod segment) gates it, so a
|
|
* non-mod would be 401/503 on every call. mod is also rate-limit-exempt.
|
|
*
|
|
* SAFE + SELF-CLEANING (the dev DB + Forgejo are shared across concurrent
|
|
* previews):
|
|
* - The slug is derived PER-PREVIEW from the preview host (`ci-smoke-pub-
|
|
* <host-label>`), so two different previews never collide on the partial
|
|
* unique index `(slug) WHERE status='pending'`. Same-preview re-runs
|
|
* pre-withdraw any leftover pending row before submitting.
|
|
* - submit only pushes to the REVIEW org (`civitai-apps-review/<slug>`), which
|
|
* deliberately has NO build webhook, plus a MinIO object + one dev-DB row —
|
|
* so it triggers NO Tekton build, NO `civitai-apps` Deployment, NO CF DNS.
|
|
* - We `withdrawPublishRequest` in `finally` so a mid-test failure still
|
|
* leaves no pending row and re-runs don't accumulate/collide.
|
|
*
|
|
* --- APPROVE → BUILD → RENDER IS INTENTIONALLY NOT E2E'd HERE -----------------
|
|
* `approveRequest` creates a canonical `civitai-apps/<slug>` Forgejo repo (no
|
|
* cheap teardown without Forgejo admin), fires a real ~5-min Tekton build, an
|
|
* apply Job in the shared prod `civitai-apps` namespace, and a CF DNS record —
|
|
* heavyweight, slow, flaky, and side-effecting on shared infra, the same reason
|
|
* the install spec defers generate/buzz. The approve-side stamping (the
|
|
* committed `block.manifest.json` rewrite + the app_blocks write) and the
|
|
* git-push webhook stamp are covered by the unit suite:
|
|
* - publish-request.orchestration.test.ts ("stamps the canonical iframe.src
|
|
* when the manifest omits it" + the happy-path committed/stored-manifest
|
|
* assertions),
|
|
* - git-push.gate.test.ts, and
|
|
* - manifest-normalize.test.ts.
|
|
* The full submit→approve→render path is exercised by the manual preview check
|
|
* in PR #2581. ---------------------------------------------------------------
|
|
*
|
|
* Verified shapes (origin/main, paths relative to civitai/src):
|
|
* - POST /api/blocks/submit-version (ModEndpoint; body { bundleBase64 }) → 200
|
|
* `{ publishRequestId, slug, version, bundleSha256, fileSummary,
|
|
* manifestDiffSummary }` (publish-request.service.ts SubmitVersionResult).
|
|
* isProd CSRF gate → stamp Origin/Referer (the trpc helper does the same).
|
|
* - blocks.listPendingRequests (moderatorProcedure + flag; input { limit?,
|
|
* cursor? }) → `{ items: [{ id, slug, manifest, ... }], nextCursor }`. The
|
|
* item spreads the row, so `item.manifest` is the stored JSONB manifest.
|
|
* - blocks.getMyPendingForSlug (moderatorProcedure + flag; input { slug }) →
|
|
* `{ pending: { id, ... } | null }` — scoped to the caller's own row.
|
|
* - blocks.withdrawPublishRequest (moderatorProcedure + flag; input
|
|
* { publishRequestId }) → `{ ok: true }`. Idempotent self-clean.
|
|
*/
|
|
|
|
const ROLE = 'mod' as const;
|
|
const PREVIEW_URL = process.env.PREVIEW_URL ?? '';
|
|
const APPS_DOMAIN = process.env.APPS_DOMAIN ?? 'civit.ai';
|
|
const VERSION = '0.1.0';
|
|
|
|
// Per-preview slug so concurrent previews don't collide on the pending-per-slug
|
|
// unique index. Derive from the preview host's first label (e.g.
|
|
// pr-2581.civitaic.com → ci-smoke-pub-pr-2581); sanitize to the slug charset
|
|
// (^[a-z][a-z0-9-]*[a-z0-9]$, 3-40 chars).
|
|
function previewSlug(): string {
|
|
let label = 'local';
|
|
try {
|
|
label = new URL(PREVIEW_URL).hostname.split('.')[0] || 'local';
|
|
} catch {
|
|
/* fall through to default */
|
|
}
|
|
const sanitized = label.toLowerCase().replace(/[^a-z0-9-]/g, '-');
|
|
const slug = `ci-smoke-pub-${sanitized}`.slice(0, 40).replace(/-+$/, '');
|
|
// Guarantee it ends on an alphanumeric (the slug regex requires it).
|
|
return /[a-z0-9]$/.test(slug) ? slug : `${slug}0`;
|
|
}
|
|
|
|
const SLUG = previewSlug();
|
|
const CANONICAL_SRC = `https://${SLUG}.${APPS_DOMAIN}/`;
|
|
|
|
type SubmitResult = { publishRequestId: string; slug: string; version: string };
|
|
type PendingItem = { id: string; slug: string; manifest: { iframe?: { src?: string } } };
|
|
type PendingList = { items: PendingItem[]; nextCursor: string | null };
|
|
|
|
// Bundle whose manifest OMITS iframe.src and which contains NO Dockerfile —
|
|
// exactly the shape the DX change is meant to accept.
|
|
async function buildBundleBase64(): Promise<string> {
|
|
const manifest = {
|
|
blockId: SLUG,
|
|
version: VERSION,
|
|
name: 'CI Smoke — publish DX (omits iframe.src)',
|
|
contentRating: 'g',
|
|
scopes: [] as string[],
|
|
// NOTE: no `src`. minHeight/sandbox stay developer-authored.
|
|
iframe: { minHeight: 300, sandbox: 'allow-scripts allow-forms' },
|
|
};
|
|
const zip = new JSZip();
|
|
zip.file('block.manifest.json', JSON.stringify(manifest, null, 2));
|
|
zip.file('index.html', '<!doctype html><html><body>ci-smoke-pub-dx</body></html>');
|
|
const buf = await zip.generateAsync({ type: 'nodebuffer' });
|
|
return buf.toString('base64');
|
|
}
|
|
|
|
async function submitBundle(
|
|
request: APIRequestContext,
|
|
bundleBase64: string
|
|
): Promise<SubmitResult> {
|
|
const res = await request.post('/api/blocks/submit-version', {
|
|
headers: {
|
|
'content-type': 'application/json',
|
|
origin: PREVIEW_URL,
|
|
referer: `${PREVIEW_URL}/`,
|
|
},
|
|
data: { bundleBase64 },
|
|
});
|
|
const body = (await res.json().catch(() => ({}))) as Record<string, unknown>;
|
|
if (!res.ok()) {
|
|
throw new Error(
|
|
`submit-version -> HTTP ${res.status()}: ${JSON.stringify(body).slice(0, 400)}`
|
|
);
|
|
}
|
|
return body as unknown as SubmitResult;
|
|
}
|
|
|
|
// Find our just-submitted request by slug, paginating the oldest-first queue
|
|
// (our row is the newest, so it can be on a later page on a busy clone).
|
|
async function findPendingBySlug(
|
|
request: APIRequestContext,
|
|
slug: string
|
|
): Promise<PendingItem | null> {
|
|
let cursor: string | null = null;
|
|
for (let page = 0; page < 25; page++) {
|
|
const input: { limit: number; cursor?: string } = { limit: 100 };
|
|
if (cursor) input.cursor = cursor;
|
|
const list: PendingList = await trpcQuery<PendingList>(
|
|
request,
|
|
'blocks.listPendingRequests',
|
|
input
|
|
);
|
|
const hit = list.items.find((i) => i.slug === slug);
|
|
if (hit) return hit;
|
|
if (!list.nextCursor) break;
|
|
cursor = list.nextCursor;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
async function withdrawPendingForSlug(
|
|
request: APIRequestContext,
|
|
slug: string
|
|
): Promise<void> {
|
|
const r = await trpcQuery<{ pending: { id: string } | null }>(
|
|
request,
|
|
'blocks.getMyPendingForSlug',
|
|
{ slug }
|
|
).catch(() => ({ pending: null }));
|
|
if (r?.pending?.id) {
|
|
await trpcMutation(request, 'blocks.withdrawPublishRequest', {
|
|
publishRequestId: r.pending.id,
|
|
}).catch(() => {});
|
|
}
|
|
}
|
|
|
|
test.describe('App Blocks publish-request: iframe.src is platform-stamped (mod, self-cleaning)', () => {
|
|
test.use({ storageState: storageStatePath(ROLE) });
|
|
|
|
test('submit a bundle that omits iframe.src + has no Dockerfile → accepted, canonical src stamped', async ({
|
|
page,
|
|
}) => {
|
|
// Warm page.request against the preview origin (shares the mod auth cookie;
|
|
// the helpers stamp Origin/Referer for the CSRF gate). domcontentloaded ONLY.
|
|
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
|
const request = page.request;
|
|
|
|
// Pre-clean any leftover pending row for this preview's slug (prior crashed run).
|
|
await withdrawPendingForSlug(request, SLUG);
|
|
|
|
let publishRequestId: string | null = null;
|
|
try {
|
|
const bundleBase64 = await buildBundleBase64();
|
|
|
|
// SUBMIT — on origin/main this 400s with "manifest.iframe.src must be a
|
|
// string"; with the DX change the platform derives + stamps it, so the
|
|
// submit (a manifest with no iframe.src, a bundle with no Dockerfile) is
|
|
// accepted. The 200 itself is the first half of the proof.
|
|
const result = await submitBundle(request, bundleBase64);
|
|
publishRequestId = result.publishRequestId;
|
|
expect(typeof result.publishRequestId, 'submit returns a publishRequestId').toBe('string');
|
|
expect(result.slug, 'slug is taken from manifest.blockId').toBe(SLUG);
|
|
|
|
// READ-BACK via the mod queue: the STORED manifest carries the canonical
|
|
// per-app subdomain root even though the uploaded bundle never declared
|
|
// iframe.src. This is the second half of the proof.
|
|
const item = await findPendingBySlug(request, SLUG);
|
|
expect(item, 'the submitted request should appear in the pending queue').not.toBeNull();
|
|
expect(
|
|
item!.manifest?.iframe?.src,
|
|
'iframe.src is server-stamped to the canonical per-app subdomain root'
|
|
).toBe(CANONICAL_SRC);
|
|
} finally {
|
|
// SELF-CLEAN: withdraw so no pending row lingers and re-runs don't collide.
|
|
if (publishRequestId) {
|
|
await trpcMutation(request, 'blocks.withdrawPublishRequest', {
|
|
publishRequestId,
|
|
}).catch(() => {});
|
|
} else {
|
|
await withdrawPendingForSlug(request, SLUG);
|
|
}
|
|
}
|
|
});
|
|
});
|