feat(app-blocks): W13 P3a — off-site listing submission backend (dark) (#2950)

* feat(app-blocks): W13 P3a off-site listing submission backend (dark)

Adds the native external-link off-site app submission flow behind the
`app-blocks-author` flag (mods + app-dev-testers). Design B1 (locked):
submit creates, in one transaction, a DRAFT AppListing(kind='offsite',
status='draft') + a pending AppListingPublishRequest(kind='offsite',
appListingId=<draft id>), so the author can reuse the P1 asset CRUD to
attach icon/cover/screenshots before a mod approves. The read path hides
non-approved rows, so a draft never surfaces in the store.

- New offsite-listing.schema.ts: submitExternalListingSchema
  (name/externalUrl/slug/tagline?/description?/category?/contentRating
  default 'g'/changelog?), reusing validateExternalUrl +
  assertNoOnPlatformSurface from external-app.schema (https-only, external
  vs on-platform mutual exclusivity).
- New offsite-listing.service.ts: submitExternalListing (owner-bound,
  slug-collision pre-check + P2002-race branch, cross-kind block-id check),
  withdrawExternalRequest (IDOR + TOCTOU status-guarded updateMany,
  terminal: deletes the draft listing to release the slug),
  listMySubmissions + read-only mod queue lists (pending/approved/rejected).
- Wire procs on the appListings router: submit/withdraw/listMySubmissions
  as appDeveloperProcedure; the queue lists as moderatorProcedure.
- Widen the P1 asset-CRUD flag gate mod->author (enforceAppBlocksAuthorFlag
  via isAppBlocksAuthorEnabled); the service-layer owner check still bounds
  each caller to their own listing. backfillAssets stays moderatorProcedure.
- Comment-only Prisma update on AppListingPublishRequest.appListingId (B1
  sets it at submit) — no schema/DDL change, no migration.

Tests: offsite-listing.schema (18), offsite-listing.service (17), router
authz matrix (22) — all green. e2e spec authored (submit -> mod queue ->
withdraw, self-cleaning; runs in Tekton pr-smoke-test, not locally).

Dark: no UI. PR-b (approve/reject + assertListingAssetsComplete), PR-c
(UI), and PR-d (#2821 retirement) follow as separate PRs off main.

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

* harden(app-blocks/offsite): rate-limit + pending cap + block_id primary re-check

Fold the pre-deploy hardening items from the PR #2950 audit into the P3a
off-site submission backend (dark behind app-blocks-author; reachable by
non-mod dev-testers via tRPC once deployed):

- submitExternalListing: add rateLimit (10/hour) middleware, mirroring the
  public read procs' rateLimit idiom — throttles draft-spam / slug-squat.
- Per-user OUTSTANDING pending-submission cap (MAX_PENDING_OFFSITE_SUBMISSIONS
  = 10) in the service — bounds standing orphan-draft accrual (drafts have no
  TTL, only clear on withdraw/reject); at/over cap -> TOO_MANY_REQUESTS.
- Cross-kind AppBlock.block_id collision: re-check from the PRIMARY (dbWrite)
  inside the create tx to close the replica-lag window the constraint-less
  pre-check leaves open (AppListing.slug is P2002-backstopped; block_id is not).
- Re-assert author-declared contentRating against OFFSITE_CONTENT_RATINGS in
  the service (defense-in-depth, matching the URL/surface/category re-checks;
  keeps the 'g' default).
- offsite-listing.schema: z.ZodIssueCode.custom -> 'custom' (Zod v4 idiom).

Tests: +5 service cases (pending cap at/under, block_id primary-recheck path,
contentRating re-assert). 61 offsite unit tests green (schema+service+router).

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

* chore(w13-p3a): re-trigger Tekton preview build

* chore(ci): re-trigger preview build (Tekton transient issue resolved)

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

* test(app-blocks/offsite): run P3a submit e2e as mod, not the synthetic tester

The preview 'tester' fixture (id 2000000002) is in the preview-ACCESS
allowlist but NOT the app-blocks-author cohort, so submitExternalListing
(appDeveloperProcedure) 403s it by design. Mods are authors via the
app-blocks-author mod floor, so run the whole submit->queue->withdraw leg
as mod, matching every sibling apps smoke spec (publish/install/marketplace/page).
The author-gate rejection is covered by the unit router-authz tests.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Zachary Lowden
2026-07-06 14:46:23 -05:00
committed by GitHub
parent 5b832af832
commit 7c8814d2ca
8 changed files with 1591 additions and 20 deletions
@@ -2613,7 +2613,7 @@ model AppListingMetric {
/// on-site Tekton build queue clean. STRUCTURE ONLY in P0; wired in P3.
model AppListingPublishRequest {
id String @id // alpr_<ULID>
appListingId String? @map("app_listing_id") // NULL while first request pending; FK on approve
appListingId String? @map("app_listing_id") // Off-site (B1): set at SUBMIT to the draft AppListing. On-site: NULL until approve. Nullable (SetNull on listing delete).
appListing AppListing? @relation(fields: [appListingId], references: [id], onDelete: SetNull)
kind String // 'onsite' | 'offsite'
slug String
@@ -0,0 +1,252 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { TRPCError } from '@trpc/server';
/**
* W13 P3a — off-site submission router AUTHZ matrix.
*
* Drives the REAL `appListingsRouter` via `createCaller` so the middleware wiring
* (not a mock) decides:
* - submit / withdraw / listMySubmissions are `appDeveloperProcedure`
* (`app-blocks-author`): a non-author is FORBIDDEN; a mod (author floor) and
* an app-dev-tester (cohort) pass.
* - listPending/Approved/Rejected are `moderatorProcedure`: a non-mod author is
* FORBIDDEN (an author can submit but NOT review).
* - the widened asset-CRUD flag gate (`enforceAppBlocksAuthorFlag`) lets a
* tester manage their OWN listing's assets but the service owner check still
* bounds them to their own listings (a foreign listing → FORBIDDEN).
*
* `appDeveloperProcedure` reads `getFeatureFlags(ctx).appBlocksAuthor`, so we
* mock that with a faithful per-user impl (mod OR the tester cohort → true). The
* `app-blocks-author` flag helper (`isAppBlocksAuthorEnabled`) is mocked with the
* SAME rule for the asset-CRUD gate. The services are mocked so importing the
* router never drags in the generated Prisma client.
*/
const AUTHOR_IDS = new Set([2]); // the app-dev-tester cohort (non-mod authors)
const {
mockSubmit,
mockWithdraw,
mockListMy,
mockListPending,
mockListApproved,
mockListRejected,
mockSetIcon,
mockIsAppBlocksEnabled,
mockIsAppBlocksAuthorEnabled,
} = vi.hoisted(() => ({
mockSubmit: vi.fn(async () => ({ listingId: 'apl_1', publishRequestId: 'alpr_1', slug: 's' })),
mockWithdraw: vi.fn(async () => undefined),
mockListMy: vi.fn(async () => ({ items: [], nextCursor: null })),
mockListPending: vi.fn(async () => ({ items: [{ id: 'alpr_1' }], nextCursor: null })),
mockListApproved: vi.fn(async () => ({ items: [], nextCursor: null })),
mockListRejected: vi.fn(async () => ({ items: [], nextCursor: null })),
// Faithful owner-check stand-in: throw FORBIDDEN when the caller doesn't own
// the target listing (listingId encodes the owner: `own-<id>` / `other-<id>`).
mockSetIcon: vi.fn(async (input: { listingId: string }, user: { id: number }) => {
const ownerId = Number(input.listingId.split('-')[1]);
if (ownerId !== user.id) throw new TRPCError({ code: 'FORBIDDEN', message: 'not owner' });
return { iconId: 5 };
}),
mockIsAppBlocksEnabled: vi.fn(),
mockIsAppBlocksAuthorEnabled: vi.fn(),
}));
vi.mock('~/server/services/blocks/offsite-listing.service', () => ({
submitExternalListing: mockSubmit,
withdrawExternalRequest: mockWithdraw,
listMySubmissions: mockListMy,
listPendingOffsiteRequests: mockListPending,
listApprovedOffsiteRequests: mockListApproved,
listRejectedOffsiteRequests: mockListRejected,
}));
vi.mock('~/server/services/blocks/app-listing-assets.service', () => ({
setListingIcon: mockSetIcon,
}));
vi.mock('~/server/services/app-blocks-flag', () => ({
isAppBlocksEnabled: mockIsAppBlocksEnabled,
isAppBlocksAuthorEnabled: mockIsAppBlocksAuthorEnabled,
}));
// appDeveloperProcedure gates on getFeatureFlags(ctx).appBlocksAuthor — mock it
// with the SAME faithful rule (mod floor OR the tester cohort).
vi.mock('~/server/services/feature-flags.service', async () => {
const actual = await vi.importActual<typeof import('~/server/services/feature-flags.service')>(
'~/server/services/feature-flags.service'
);
return {
...actual,
getFeatureFlags: (ctx: { user?: { id?: number; isModerator?: boolean } }) => ({
appBlocksAuthor:
!!ctx.user && (!!ctx.user.isModerator || AUTHOR_IDS.has(ctx.user.id ?? -1)),
}),
};
});
vi.mock('~/server/middleware.trpc', async () => {
const { middleware } = await import('~/server/trpc');
return { rateLimit: () => middleware(async ({ next }) => next()) };
});
vi.mock('~/server/utils/server-domain', () => ({ isHostForColor: () => false }));
import { appListingsRouter } from '../app-listings.router';
import { TokenScope } from '~/shared/constants/token-scope.constants';
/** Faithful per-user author gate: ON iff the caller is a mod OR in the tester cohort. */
function fakeAuthorFlag(opts?: { user?: { id?: number; isModerator?: boolean } }) {
const u = opts?.user;
return Promise.resolve(!!u && (!!u.isModerator || AUTHOR_IDS.has(u.id ?? -1)));
}
function fakeCtx(user: unknown) {
return {
acceptableOrigin: true,
user,
apiKeyId: null,
tokenScope: TokenScope.Full,
req: { headers: {} } as never,
res: { setHeader: () => undefined } as never,
cache: { edgeTTL: 0 },
features: {} as never,
track: undefined,
};
}
const mod = { id: 1, isModerator: true, tier: 'free', username: 'mod', onboarding: 0x1f };
const tester = { id: 2, isModerator: false, tier: 'free', username: 'tester', onboarding: 0x1f };
const nonAuthor = { id: 3, isModerator: false, tier: 'free', username: 'user', onboarding: 0x1f };
beforeEach(() => {
vi.clearAllMocks();
mockIsAppBlocksEnabled.mockImplementation((opts?: { user?: { isModerator?: boolean } }) =>
Promise.resolve(!!opts?.user?.isModerator)
);
mockIsAppBlocksAuthorEnabled.mockImplementation(fakeAuthorFlag);
});
// ---------------------------------------------------------------------------
// AUTHOR procs (appDeveloperProcedure).
// ---------------------------------------------------------------------------
const submitInput = { slug: 'cool-app', name: 'Cool', externalUrl: 'https://x.example.com' };
describe('submitExternalListing — appDeveloperProcedure (app-blocks-author)', () => {
it('non-author (non-mod, no cohort) → FORBIDDEN, service NOT called', async () => {
const caller = appListingsRouter.createCaller(fakeCtx(nonAuthor) as never);
await expect(caller.submitExternalListing(submitInput)).rejects.toBeInstanceOf(TRPCError);
expect(mockSubmit).not.toHaveBeenCalled();
});
it('anonymous → UNAUTHORIZED/FORBIDDEN, service NOT called', async () => {
const caller = appListingsRouter.createCaller(fakeCtx(undefined) as never);
await expect(caller.submitExternalListing(submitInput)).rejects.toBeInstanceOf(TRPCError);
expect(mockSubmit).not.toHaveBeenCalled();
});
it('moderator (author floor) → passes; service called with the caller id', async () => {
const caller = appListingsRouter.createCaller(fakeCtx(mod) as never);
await caller.submitExternalListing(submitInput);
expect(mockSubmit).toHaveBeenCalledTimes(1);
expect(mockSubmit.mock.calls[0][0]).toMatchObject({ userId: mod.id });
});
it('app-dev-tester (cohort) → passes; service called with the tester id', async () => {
const caller = appListingsRouter.createCaller(fakeCtx(tester) as never);
await caller.submitExternalListing(submitInput);
expect(mockSubmit).toHaveBeenCalledTimes(1);
expect(mockSubmit.mock.calls[0][0]).toMatchObject({ userId: tester.id });
});
});
describe('withdrawExternalRequest — appDeveloperProcedure', () => {
it('non-author → FORBIDDEN', async () => {
const caller = appListingsRouter.createCaller(fakeCtx(nonAuthor) as never);
await expect(
caller.withdrawExternalRequest({ publishRequestId: 'alpr_1' })
).rejects.toBeInstanceOf(TRPCError);
expect(mockWithdraw).not.toHaveBeenCalled();
});
it('tester → passes; withdraw called with the caller id (IDOR bound in the service)', async () => {
const caller = appListingsRouter.createCaller(fakeCtx(tester) as never);
await caller.withdrawExternalRequest({ publishRequestId: 'alpr_1' });
expect(mockWithdraw).toHaveBeenCalledWith({ publishRequestId: 'alpr_1', userId: tester.id });
});
it('a service failure maps to BAD_REQUEST with the message', async () => {
mockWithdraw.mockRejectedValueOnce(new Error('you can only withdraw your own publish requests'));
const caller = appListingsRouter.createCaller(fakeCtx(tester) as never);
await expect(
caller.withdrawExternalRequest({ publishRequestId: 'alpr_x' })
).rejects.toMatchObject({ code: 'BAD_REQUEST', message: expect.stringContaining('your own') });
});
});
describe('listMySubmissions — appDeveloperProcedure', () => {
it('non-author → FORBIDDEN', async () => {
const caller = appListingsRouter.createCaller(fakeCtx(nonAuthor) as never);
await expect(caller.listMySubmissions({})).rejects.toBeInstanceOf(TRPCError);
expect(mockListMy).not.toHaveBeenCalled();
});
it('tester → passes; scoped to the caller id', async () => {
const caller = appListingsRouter.createCaller(fakeCtx(tester) as never);
await caller.listMySubmissions({});
expect(mockListMy).toHaveBeenCalledWith(expect.objectContaining({ userId: tester.id }));
});
});
// ---------------------------------------------------------------------------
// MOD queue lists (moderatorProcedure).
// ---------------------------------------------------------------------------
describe('review-queue lists — moderatorProcedure', () => {
for (const proc of ['listPendingRequests', 'listApprovedRequests', 'listRejectedRequests'] as const) {
it(`${proc}: a non-mod AUTHOR (tester) is FORBIDDEN`, async () => {
const caller = appListingsRouter.createCaller(fakeCtx(tester) as never);
await expect(caller[proc]({})).rejects.toBeInstanceOf(TRPCError);
});
it(`${proc}: a plain user is FORBIDDEN`, async () => {
const caller = appListingsRouter.createCaller(fakeCtx(nonAuthor) as never);
await expect(caller[proc]({})).rejects.toBeInstanceOf(TRPCError);
});
it(`${proc}: a moderator passes`, async () => {
const caller = appListingsRouter.createCaller(fakeCtx(mod) as never);
await expect(caller[proc]({})).resolves.toBeDefined();
});
}
});
// ---------------------------------------------------------------------------
// Asset-CRUD flag widening (enforceAppBlocksAuthorFlag) + owner boundary.
// ---------------------------------------------------------------------------
describe('setIcon — widened author flag gate + service owner check', () => {
it('non-author (author flag off) → UNAUTHORIZED, service NOT called', async () => {
const caller = appListingsRouter.createCaller(fakeCtx(nonAuthor) as never);
await expect(
caller.setIcon({ listingId: 'own-3', imageId: 5 })
).rejects.toBeInstanceOf(TRPCError);
expect(mockSetIcon).not.toHaveBeenCalled();
});
it('app-dev-tester CAN attach to their OWN listing', async () => {
const caller = appListingsRouter.createCaller(fakeCtx(tester) as never);
// listingId `own-2` is owned by the tester (id 2).
await expect(caller.setIcon({ listingId: 'own-2', imageId: 5 })).resolves.toEqual({ iconId: 5 });
expect(mockSetIcon).toHaveBeenCalledWith({ listingId: 'own-2', imageId: 5 }, tester);
});
it('app-dev-tester CANNOT attach to ANOTHER users listing (owner check → FORBIDDEN)', async () => {
const caller = appListingsRouter.createCaller(fakeCtx(tester) as never);
// listingId `other-99` is owned by user 99, not the tester.
await expect(
caller.setIcon({ listingId: 'other-99', imageId: 5 })
).rejects.toMatchObject({ code: 'FORBIDDEN' });
});
it('moderator (author floor) can attach as well', async () => {
const caller = appListingsRouter.createCaller(fakeCtx(mod) as never);
await expect(caller.setIcon({ listingId: 'own-1', imageId: 5 })).resolves.toEqual({ iconId: 5 });
});
});
+150 -19
View File
@@ -14,9 +14,16 @@ import {
getAppListingDetailSchema,
listAppListingsSchema,
} from '~/server/schema/blocks/app-listing-read.schema';
import { rateLimit } from '~/server/middleware.trpc';
import { isAppBlocksEnabled } from '~/server/services/app-blocks-flag';
import {
listMySubmissionsSchema,
listOffsiteRequestsSchema,
submitExternalListingSchema,
withdrawExternalRequestSchema,
} from '~/server/schema/blocks/offsite-listing.schema';
import { rateLimit } from '~/server/middleware.trpc';
import { isAppBlocksAuthorEnabled, isAppBlocksEnabled } from '~/server/services/app-blocks-flag';
import {
appDeveloperProcedure,
middleware,
moderatorProcedure,
protectedProcedure,
@@ -27,23 +34,44 @@ import { throwAuthorizationError, throwNotFoundError } from '~/server/utils/erro
import { isHostForColor } from '~/server/utils/server-domain';
/**
* App Store Listings (W13) — P1 asset pipeline router (NEW router, locked
* decision §5.1 — NOT an extension of `blocks.router`). All procs are DARK and
* additive: owner-scoped (mod override) creator asset management + a mod-only
* placeholder backfill. Nothing here is on a public read path (that is P2) or a
* live approval gate (P3).
* App Store Listings (W13) — asset pipeline + off-site submission router (NEW
* router, locked decision §5.1 — NOT an extension of `blocks.router`). All procs
* are DARK and additive: owner-scoped (mod override) creator asset management, a
* mod-only placeholder backfill, the P2a unified store read path, and (P3a) the
* off-site submission flow. No UI in P3a.
*
* Flag gate: reuses the mod-segmented `app-blocks-enabled` flag (evaluated WITH
* the request user's context, like blocks.router's `enforceAppBlocksFlag`), so
* P1 ships dark-to-non-mods and immediately usable by the civitai team. A
* dedicated `app-listings-enabled` flag lands with the P2 read path when there
* is a user-facing surface to widen independently.
* Flag gates (three tiers):
* - `enforceAppBlocksAuthorFlag` (`app-blocks-author`) — the AUTHOR gate on the
* creator asset-CRUD procs + the off-site submit/withdraw/my-submissions
* procs (mods + app-dev-testers). Widened from mod-only in P3a so a dev-tester
* can manage their OWN listing's assets + submit off-site apps; the
* service-layer owner check still bounds every mutation to the caller.
* - `moderatorProcedure` (+ `enforceAppBlocksFlag` on backfill) — the mod-only
* backfill + the read-only off-site review-queue lists.
* - `enforceAppListingsReadFlag` (`app-blocks-enabled`) — the DARK public store
* read path (empty page / NOT_FOUND until the segment widens at cutover).
*/
const enforceAppBlocksFlag = middleware(async ({ ctx, next }) => {
if (await isAppBlocksEnabled({ user: ctx.user })) return next();
throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Apps are not enabled' });
});
/**
* AUTHOR flag gate (P3a) — the WIDENED gate for the creator asset-CRUD procs +
* the off-site submit/withdraw/my-submissions procs. Evaluated WITH the caller's
* context against `app-blocks-author` (`isAppBlocksAuthorEnabled`: mod floor +
* the `app-dev-testers` cohort segment), so an app-dev-tester may manage their
* OWN listing's assets + submit off-site apps — while the SERVICE-layer owner
* check still bounds every mutation to the caller's own listings. This REPLACES
* the mod-only `enforceAppBlocksFlag` (`isAppBlocksEnabled`) on those procs;
* mods still pass via the author floor. Fail-CLOSED: absent flag / Flipt-down →
* mods only. (The mod-only `backfillAssets` proc keeps `enforceAppBlocksFlag`.)
*/
const enforceAppBlocksAuthorFlag = middleware(async ({ ctx, next }) => {
if (await isAppBlocksAuthorEnabled({ user: ctx.user })) return next();
throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Apps authoring is not enabled' });
});
/**
* Flag gate for the P2a PUBLIC READ procs (unified store). Anon-CAPABLE but DARK
* until launch: for a real anon / non-mod viewer the mod-segmented
@@ -72,7 +100,7 @@ function isRedCapableRequest(ctx: { req?: { headers?: { host?: string } } }): bo
export const appListingsRouter = router({
/** Owner/mod read of a listing's current assets (creator dashboard). */
getAssets: protectedProcedure
.use(enforceAppBlocksFlag)
.use(enforceAppBlocksAuthorFlag)
.input(listingAssetsQuerySchema)
.query(async ({ ctx, input }) => {
const { getListingAssets } = await import('~/server/services/blocks/app-listing-assets.service');
@@ -80,7 +108,7 @@ export const appListingsRouter = router({
}),
setIcon: protectedProcedure
.use(enforceAppBlocksFlag)
.use(enforceAppBlocksAuthorFlag)
.input(setListingIconSchema)
.mutation(async ({ ctx, input }) => {
const { setListingIcon } = await import('~/server/services/blocks/app-listing-assets.service');
@@ -88,7 +116,7 @@ export const appListingsRouter = router({
}),
setCover: protectedProcedure
.use(enforceAppBlocksFlag)
.use(enforceAppBlocksAuthorFlag)
.input(setListingCoverSchema)
.mutation(async ({ ctx, input }) => {
const { setListingCover } = await import('~/server/services/blocks/app-listing-assets.service');
@@ -96,7 +124,7 @@ export const appListingsRouter = router({
}),
addScreenshot: protectedProcedure
.use(enforceAppBlocksFlag)
.use(enforceAppBlocksAuthorFlag)
.input(addListingScreenshotSchema)
.mutation(async ({ ctx, input }) => {
const { addListingScreenshot } = await import(
@@ -106,7 +134,7 @@ export const appListingsRouter = router({
}),
reorderScreenshots: protectedProcedure
.use(enforceAppBlocksFlag)
.use(enforceAppBlocksAuthorFlag)
.input(reorderListingScreenshotsSchema)
.mutation(async ({ ctx, input }) => {
const { reorderListingScreenshots } = await import(
@@ -116,7 +144,7 @@ export const appListingsRouter = router({
}),
updateScreenshotCaption: protectedProcedure
.use(enforceAppBlocksFlag)
.use(enforceAppBlocksAuthorFlag)
.input(updateListingScreenshotCaptionSchema)
.mutation(async ({ ctx, input }) => {
const { updateListingScreenshotCaption } = await import(
@@ -126,7 +154,7 @@ export const appListingsRouter = router({
}),
removeScreenshot: protectedProcedure
.use(enforceAppBlocksFlag)
.use(enforceAppBlocksAuthorFlag)
.input(removeListingScreenshotSchema)
.mutation(async ({ ctx, input }) => {
const { removeListingScreenshot } = await import(
@@ -152,6 +180,109 @@ export const appListingsRouter = router({
return backfillListingAssets({ limit: input.limit, dryRun: input.dryRun });
}),
// -------------------------------------------------------------------------
// P3a OFF-SITE SUBMISSION (external-link) — DARK behind `app-blocks-author`.
//
// The native publish-request flow for a pure external-link off-site app
// (design B1: submit creates a DRAFT AppListing + a pending
// AppListingPublishRequest in one tx). AUTHOR procs (submit/withdraw/
// my-submissions) are `appDeveloperProcedure` (mods + app-dev-testers); the
// read-only review-queue lists are `moderatorProcedure`. approve/reject land in
// PR-b. Nothing renders any UI in this PR.
// -------------------------------------------------------------------------
/**
* AUTHOR: submit a pure external-link off-site app. Creates a DRAFT
* `AppListing` + a `pending` `AppListingPublishRequest` (B1); the author then
* attaches assets via the (author-gated) asset-CRUD procs above before a mod
* approves it (PR-b). Owner-bound to the caller (no user-supplied owner).
*/
submitExternalListing: appDeveloperProcedure
.use(
rateLimit({
// A row-creating write reachable by non-mod dev-testers — heavier than
// the store reads, so a conservative hourly cap throttles draft-spam /
// slug-squat. The per-user PENDING cap in the service bounds the standing
// orphan-draft count; this bounds the submit RATE.
limit: 10,
period: 3600,
errorMessage: 'Too many submissions — slow down.',
})
)
.input(submitExternalListingSchema)
.mutation(async ({ ctx, input }) => {
if (!ctx.user) throw throwAuthorizationError('Not authenticated');
const { submitExternalListing } = await import(
'~/server/services/blocks/offsite-listing.service'
);
return submitExternalListing({ input, userId: ctx.user.id });
}),
/**
* AUTHOR: withdraw the caller's OWN pending off-site request (terminal). IDOR +
* TOCTOU checked in the service; deletes the draft listing (releases the slug).
* Idempotent. All failure modes map to BAD_REQUEST with the service message
* (mirrors `blocks.withdrawPublishRequest`).
*/
withdrawExternalRequest: appDeveloperProcedure
.input(withdrawExternalRequestSchema)
.mutation(async ({ ctx, input }) => {
if (!ctx.user) throw throwAuthorizationError('Not authenticated');
const { withdrawExternalRequest } = await import(
'~/server/services/blocks/offsite-listing.service'
);
try {
await withdrawExternalRequest({
publishRequestId: input.publishRequestId,
userId: ctx.user.id,
});
} catch (err) {
throw new TRPCError({ code: 'BAD_REQUEST', message: (err as Error).message });
}
return { ok: true };
}),
/** AUTHOR: the caller's OWN off-site submissions (my-submissions page, PR-c). */
listMySubmissions: appDeveloperProcedure
.input(listMySubmissionsSchema)
.query(async ({ ctx, input }) => {
if (!ctx.user) return { items: [], nextCursor: null };
const { listMySubmissions } = await import(
'~/server/services/blocks/offsite-listing.service'
);
return listMySubmissions({ userId: ctx.user.id, limit: input.limit, cursor: input.cursor });
}),
/** MOD: pending off-site review queue (read-only in PR-a; approve/reject in PR-b). */
listPendingRequests: moderatorProcedure
.input(listOffsiteRequestsSchema)
.query(async ({ input }) => {
const { listPendingOffsiteRequests } = await import(
'~/server/services/blocks/offsite-listing.service'
);
return listPendingOffsiteRequests(input);
}),
/** MOD: approved off-site request history. */
listApprovedRequests: moderatorProcedure
.input(listOffsiteRequestsSchema)
.query(async ({ input }) => {
const { listApprovedOffsiteRequests } = await import(
'~/server/services/blocks/offsite-listing.service'
);
return listApprovedOffsiteRequests(input);
}),
/** MOD: rejected off-site request history. */
listRejectedRequests: moderatorProcedure
.input(listOffsiteRequestsSchema)
.query(async ({ input }) => {
const { listRejectedOffsiteRequests } = await import(
'~/server/services/blocks/offsite-listing.service'
);
return listRejectedOffsiteRequests(input);
}),
// -------------------------------------------------------------------------
// P2a UNIFIED STORE READ PATH (over BOTH kinds) — publicProcedure, DARK.
//
@@ -0,0 +1,153 @@
import { describe, expect, it } from 'vitest';
import { MAX_EXTERNAL_URL_LENGTH } from '~/server/schema/blocks/external-app.schema';
import {
OFFSITE_DESCRIPTION_MAX,
submitExternalListingSchema,
} from '~/server/schema/blocks/offsite-listing.schema';
/**
* App Store Listings (W13 P3a) off-site submission INPUT validation.
*
* Pins the submit-schema gates: https-only external URL (delegated to the shared
* `validateExternalUrl`), slug shape, name/description bounds, taxonomy category,
* author-declared contentRating (default SFW), optional changelog, and the
* external on-platform mutual-exclusivity (a page/targets/iframe field is
* REJECTED, not silently dropped).
*/
const base = {
slug: 'cool-app',
name: 'Cool App',
externalUrl: 'https://cool.example.com/app',
};
describe('submitExternalListingSchema — happy path', () => {
it('accepts a well-formed https submission (minimal)', () => {
const parsed = submitExternalListingSchema.safeParse(base);
expect(parsed.success).toBe(true);
// contentRating defaults to SFW when omitted.
if (parsed.success) expect(parsed.data.contentRating).toBe('g');
});
it('accepts a full submission (tagline/description/category/changelog/rating)', () => {
const parsed = submitExternalListingSchema.safeParse({
...base,
tagline: 'a cool off-site app',
description: 'longer body',
category: 'utility',
contentRating: 'pg13',
changelog: 'v1 launch',
});
expect(parsed.success).toBe(true);
if (parsed.success) expect(parsed.data.contentRating).toBe('pg13');
});
it('changelog is optional', () => {
expect(submitExternalListingSchema.safeParse(base).success).toBe(true);
});
});
describe('submitExternalListingSchema — externalUrl (delegates to validateExternalUrl)', () => {
it('REJECTS a non-https (http) URL', () => {
const r = submitExternalListingSchema.safeParse({ ...base, externalUrl: 'http://x.com' });
expect(r.success).toBe(false);
});
it('REJECTS dangerous schemes (javascript / data)', () => {
for (const externalUrl of ['javascript:alert(1)', 'data:text/html,<b>x</b>']) {
expect(submitExternalListingSchema.safeParse({ ...base, externalUrl }).success).toBe(false);
}
});
it('REJECTS an empty URL', () => {
expect(submitExternalListingSchema.safeParse({ ...base, externalUrl: '' }).success).toBe(false);
});
it('REJECTS an over-long URL (>2048 chars)', () => {
const long = 'https://example.com/' + 'a'.repeat(MAX_EXTERNAL_URL_LENGTH);
expect(submitExternalListingSchema.safeParse({ ...base, externalUrl: long }).success).toBe(false);
});
});
describe('submitExternalListingSchema — slug / name / category / description', () => {
it('REJECTS a malformed slug (uppercase / leading digit / too short / underscore)', () => {
for (const slug of ['Cool', '1app', 'ab', 'a_b', '-app']) {
expect(
submitExternalListingSchema.safeParse({ ...base, slug }).success,
`slug "${slug}"`
).toBe(false);
}
});
it('REJECTS an empty name and an over-long name', () => {
expect(submitExternalListingSchema.safeParse({ ...base, name: '' }).success).toBe(false);
expect(
submitExternalListingSchema.safeParse({ ...base, name: 'x'.repeat(121) }).success
).toBe(false);
});
it('REJECTS an unknown category (must be in the taxonomy)', () => {
expect(
submitExternalListingSchema.safeParse({ ...base, category: 'not-a-category' }).success
).toBe(false);
});
it('accepts a known taxonomy category', () => {
expect(submitExternalListingSchema.safeParse({ ...base, category: 'games' }).success).toBe(true);
});
it('REJECTS an over-long description (>2000)', () => {
expect(
submitExternalListingSchema.safeParse({
...base,
description: 'x'.repeat(OFFSITE_DESCRIPTION_MAX + 1),
}).success
).toBe(false);
});
});
describe('submitExternalListingSchema — contentRating', () => {
it('accepts every valid rating', () => {
for (const contentRating of ['g', 'pg', 'pg13', 'r', 'x'] as const) {
expect(
submitExternalListingSchema.safeParse({ ...base, contentRating }).success,
contentRating
).toBe(true);
}
});
it('REJECTS an unknown rating', () => {
expect(
submitExternalListingSchema.safeParse({ ...base, contentRating: 'nc17' }).success
).toBe(false);
});
});
describe('submitExternalListingSchema — external ⟂ on-platform mutual exclusivity', () => {
it('REJECTS a submission declaring a page surface', () => {
expect(
submitExternalListingSchema.safeParse({ ...base, page: { path: '/run' } }).success
).toBe(false);
});
it('REJECTS a submission declaring target slots', () => {
expect(
submitExternalListingSchema.safeParse({
...base,
targets: [{ slotId: 'model.sidebar_top' }],
}).success
).toBe(false);
});
it('REJECTS a submission declaring an iframe surface', () => {
expect(
submitExternalListingSchema.safeParse({ ...base, iframe: { src: 'https://x.civit.ai' } })
.success
).toBe(false);
});
it('an EMPTY targets array declares nothing → accepted', () => {
expect(submitExternalListingSchema.safeParse({ ...base, targets: [] }).success).toBe(true);
});
});
@@ -0,0 +1,108 @@
import * as z from 'zod';
import {
MAX_EXTERNAL_URL_LENGTH,
assertNoOnPlatformSurface,
validateExternalUrl,
} from '~/server/schema/blocks/external-app.schema';
import { SLUG_REGEX } from '~/server/schema/blocks/publish-request.schema';
import { MARKETPLACE_CATEGORIES } from '~/server/services/blocks/marketplace-categories.constants';
/**
* App Store Listings (W13) P3a OFF-SITE (external-link) submission schemas.
*
* The AUTHOR-facing submit surface for a pure external-link off-site app: a
* native publish-request flow (NOT the retired #2821 mod-only
* `registerExternalApp` AppBlock path). An app author (the widened
* `app-blocks-author` cohort mods + app-dev-testers) submits display metadata
* + an https target; the service creates a DRAFT `AppListing` + a `pending`
* `AppListingPublishRequest` (design B1). Mods review + approve/reject in a
* LATER PR (PR-b). Everything here is DARK behind `app-blocks-author`.
*
* URL validation is DELEGATED to the single source of truth in
* `external-app.schema.ts` (`validateExternalUrl` https-only, length-bounded)
* and `assertNoOnPlatformSurface` (external on-platform), so the submit schema,
* the retired-register schema, the service, and the read path can't drift.
*/
/**
* Off-site listing maturity ratings (author-declared, default `g`). Same domain
* as `AppBlock.content_rating` / `AppListing.content_rating` (`g`..`x`); an
* off-site listing has no runtime .red/.com serving gate to mirror, so the author
* declares it and a mod can adjust at approve (PR-b). The store read path clamps
* mature (`r`/`x`) rows off a non-red host.
*/
export const OFFSITE_CONTENT_RATINGS = ['g', 'pg', 'pg13', 'r', 'x'] as const;
export type OffsiteContentRating = (typeof OFFSITE_CONTENT_RATINGS)[number];
/** Bounds for the author-supplied display fields (mirror the register/listing shapes). */
export const OFFSITE_NAME_MAX = 120;
export const OFFSITE_TAGLINE_MAX = 140;
export const OFFSITE_DESCRIPTION_MAX = 2000;
export const OFFSITE_CHANGELOG_MAX = 2000;
/**
* Author submit input for a pure external-link off-site listing.
*
* `externalUrl` is bound loose here (string length) and validated for the
* https-only / absolute-URL shape by the shared `validateExternalUrl` in the
* superRefine below (single source of truth), so a `http:` / `javascript:` /
* `data:` / over-long URL is rejected at the schema boundary not just in the
* service. `page` / `targets` / `iframe` are accepted as unknown ONLY so the
* mutual-exclusivity check can REJECT them (an external app must not declare an
* on-platform surface see `assertNoOnPlatformSurface`); the service never reads
* them.
*/
export const submitExternalListingSchema = z
.object({
slug: z.string().min(3).max(40).regex(SLUG_REGEX),
name: z.string().min(1).max(OFFSITE_NAME_MAX),
externalUrl: z.string().min(1).max(MAX_EXTERNAL_URL_LENGTH),
tagline: z.string().max(OFFSITE_TAGLINE_MAX).optional(),
description: z.string().max(OFFSITE_DESCRIPTION_MAX).optional(),
// Validated against the shared taxonomy const so adding a category needs no
// schema change (mirrors the read-path `listAppListingsSchema.category`).
category: z.enum(MARKETPLACE_CATEGORIES).optional(),
// Author-declared maturity; defaults to SFW so an omitted rating is never
// silently treated as mature.
contentRating: z.enum(OFFSITE_CONTENT_RATINGS).default('g'),
// Optional "what is this app" changelog note (AppListingPublishRequest.changelog).
changelog: z.string().max(OFFSITE_CHANGELOG_MAX).optional(),
// Accepted-but-forbidden on-platform surface fields (rejected below).
page: z.unknown().optional(),
targets: z.unknown().optional(),
iframe: z.unknown().optional(),
})
.superRefine((val, ctx) => {
const url = validateExternalUrl(val.externalUrl);
if (!url.ok) {
ctx.addIssue({ code: 'custom', message: url.error, path: ['externalUrl'] });
}
const surface = assertNoOnPlatformSurface({
page: val.page,
targets: val.targets,
iframe: val.iframe,
});
if (!surface.ok) {
ctx.addIssue({ code: 'custom', message: surface.error, path: ['externalUrl'] });
}
});
export type SubmitExternalListingInput = z.infer<typeof submitExternalListingSchema>;
/** Withdraw one of the caller's own pending off-site requests (IDOR-checked in the service). */
export const withdrawExternalRequestSchema = z.object({
publishRequestId: z.string().min(1).max(64),
});
export type WithdrawExternalRequestInput = z.infer<typeof withdrawExternalRequestSchema>;
/** Keyset-paginate the caller's own off-site submissions (my-submissions page, PR-c). */
export const listMySubmissionsSchema = z.object({
limit: z.number().int().min(1).max(100).optional(),
cursor: z.string().min(1).max(64).optional(),
});
export type ListMySubmissionsInput = z.infer<typeof listMySubmissionsSchema>;
/** Keyset-paginate the mod-facing off-site review queue (pending/approved/rejected). */
export const listOffsiteRequestsSchema = listMySubmissionsSchema;
export type ListOffsiteRequestsInput = z.infer<typeof listOffsiteRequestsSchema>;
@@ -0,0 +1,355 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { TRPCError } from '@trpc/server';
import {
MAX_PENDING_OFFSITE_SUBMISSIONS,
OffsiteRequestError,
submitExternalListing,
withdrawExternalRequest,
} from '~/server/services/blocks/offsite-listing.service';
import type { SubmitExternalListingInput } from '~/server/schema/blocks/offsite-listing.schema';
/**
* App Store Listings (W13 P3a) off-site submission SERVICE tests (design B1).
*
* Covers submit (draft AppListing + pending request in one tx; owner-binding;
* slug-collision pre-check AND P2002-race branch; cross-kind block-id collision;
* URL re-validation; unknown category) and withdraw (own-pending withdrawn +
* draft deletion; NOT_OWNED / NOT_PENDING / idempotent-withdrawn; the
* status-guarded TOCTOU re-read). All DB deps are mocked no real Prisma.
*/
const { mockDb, ids } = vi.hoisted(() => ({
mockDb: {
appListing: {
findUnique: vi.fn(async (..._a: unknown[]): Promise<unknown> => null),
create: vi.fn(async (args: { data: unknown }) => args.data),
deleteMany: vi.fn(async (..._a: unknown[]) => ({ count: 1 })),
},
appBlock: {
findFirst: vi.fn(async (..._a: unknown[]): Promise<unknown> => null),
},
appListingPublishRequest: {
findUnique: vi.fn(async (..._a: unknown[]): Promise<unknown> => null),
count: vi.fn(async (..._a: unknown[]) => 0),
create: vi.fn(async (args: { data: unknown }) => args.data),
updateMany: vi.fn(async (..._a: unknown[]) => ({ count: 1 })),
},
// Interactive transaction: run the callback with the same mock as `tx`.
$transaction: vi.fn(async (cb: (tx: unknown) => Promise<unknown>) => cb(mockDb)),
},
ids: { n: 0 },
}));
vi.mock('~/server/db/client', () => ({ dbRead: mockDb, dbWrite: mockDb }));
vi.mock('~/server/utils/app-block-ids', () => ({
newAppListingId: () => `apl_test_${++ids.n}`,
newAppListingPublishRequestId: () => `alpr_test_${++ids.n}`,
}));
const CALLER = 42;
const OTHER = 99;
const validInput: SubmitExternalListingInput = {
slug: 'cool-app',
name: 'Cool App',
externalUrl: 'https://cool.example.com/app',
contentRating: 'g',
};
beforeEach(() => {
ids.n = 0;
mockDb.appListing.findUnique.mockReset().mockResolvedValue(null);
mockDb.appListing.create.mockReset().mockImplementation(async (a: { data: unknown }) => a.data);
mockDb.appListing.deleteMany.mockReset().mockResolvedValue({ count: 1 });
mockDb.appBlock.findFirst.mockReset().mockResolvedValue(null);
mockDb.appListingPublishRequest.findUnique.mockReset().mockResolvedValue(null);
mockDb.appListingPublishRequest.count.mockReset().mockResolvedValue(0);
mockDb.appListingPublishRequest.create
.mockReset()
.mockImplementation(async (a: { data: unknown }) => a.data);
mockDb.appListingPublishRequest.updateMany.mockReset().mockResolvedValue({ count: 1 });
mockDb.$transaction
.mockReset()
.mockImplementation(async (cb: (tx: unknown) => Promise<unknown>) => cb(mockDb));
});
// ---------------------------------------------------------------------------
// submitExternalListing
// ---------------------------------------------------------------------------
describe('submitExternalListing', () => {
it('happy path: creates a DRAFT offsite AppListing + a pending publish request', async () => {
const res = await submitExternalListing({ input: validInput, userId: CALLER });
expect(res.slug).toBe('cool-app');
expect(res.listingId).toMatch(/^apl_test_/);
expect(res.publishRequestId).toMatch(/^alpr_test_/);
const listingData = mockDb.appListing.create.mock.calls[0][0].data as Record<string, unknown>;
expect(listingData).toMatchObject({
kind: 'offsite',
status: 'draft',
slug: 'cool-app',
externalUrl: 'https://cool.example.com/app',
connectClientId: null,
appBlockId: null,
contentRating: 'g',
userId: CALLER,
});
const reqData = mockDb.appListingPublishRequest.create.mock.calls[0][0]
.data as Record<string, unknown>;
expect(reqData).toMatchObject({
kind: 'offsite',
status: 'pending',
slug: 'cool-app',
appListingId: res.listingId,
submittedByUserId: CALLER,
});
// Both writes happened inside the transaction.
expect(mockDb.$transaction).toHaveBeenCalledTimes(1);
});
it('IDOR: the created rows always carry the AUTHENTICATED caller as owner/submitter', async () => {
// Even if the input somehow carried a foreign userId-like field, the service
// reads only `userId` (the authenticated caller) — there is no owner input.
await submitExternalListing({ input: validInput, userId: OTHER });
const listingData = mockDb.appListing.create.mock.calls[0][0].data as { userId: number };
const reqData = mockDb.appListingPublishRequest.create.mock.calls[0][0]
.data as { submittedByUserId: number };
expect(listingData.userId).toBe(OTHER);
expect(reqData.submittedByUserId).toBe(OTHER);
});
it('slug already taken (existing AppListing pre-check) → friendly BAD_REQUEST, no write', async () => {
mockDb.appListing.findUnique.mockResolvedValue({ id: 'apl_existing' });
await expect(submitExternalListing({ input: validInput, userId: CALLER })).rejects.toMatchObject(
{ code: 'BAD_REQUEST', message: expect.stringContaining('already taken') }
);
expect(mockDb.$transaction).not.toHaveBeenCalled();
});
it('slug taken via the P2002 create RACE → same friendly error', async () => {
// Pre-checks pass (null), but the unique constraint fires inside the tx.
mockDb.$transaction.mockRejectedValue({ code: 'P2002' });
await expect(submitExternalListing({ input: validInput, userId: CALLER })).rejects.toMatchObject(
{ code: 'BAD_REQUEST', message: expect.stringContaining('already taken') }
);
});
it('a non-P2002 tx error is NOT masked as a slug collision', async () => {
mockDb.$transaction.mockRejectedValue(new Error('db down'));
await expect(submitExternalListing({ input: validInput, userId: CALLER })).rejects.toThrow(
'db down'
);
});
it('cross-kind: a slug equal to an existing AppBlock.block_id is rejected', async () => {
mockDb.appBlock.findFirst.mockResolvedValue({ id: 'block_x' });
await expect(submitExternalListing({ input: validInput, userId: CALLER })).rejects.toMatchObject(
{ code: 'BAD_REQUEST', message: expect.stringContaining('already taken') }
);
expect(mockDb.$transaction).not.toHaveBeenCalled();
});
it('cross-kind: block_id collision missed by the replica pre-check is caught by the PRIMARY re-check inside the tx (no draft created)', async () => {
// Replica pre-check (1st findFirst) is lag-stale → null; the PRIMARY re-read
// inside the tx (2nd findFirst) sees the block → same friendly error, and the
// draft AppListing is never created.
mockDb.appBlock.findFirst
.mockResolvedValueOnce(null)
.mockResolvedValueOnce({ id: 'block_lagged' });
await expect(submitExternalListing({ input: validInput, userId: CALLER })).rejects.toMatchObject(
{ code: 'BAD_REQUEST', message: expect.stringContaining('already taken') }
);
// The tx opened (primary re-check runs inside it) but no rows were created.
expect(mockDb.$transaction).toHaveBeenCalledTimes(1);
expect(mockDb.appListing.create).not.toHaveBeenCalled();
expect(mockDb.appListingPublishRequest.create).not.toHaveBeenCalled();
});
it('per-user pending cap: AT the cap → TOO_MANY_REQUESTS, no write', async () => {
mockDb.appListingPublishRequest.count.mockResolvedValue(MAX_PENDING_OFFSITE_SUBMISSIONS);
await expect(submitExternalListing({ input: validInput, userId: CALLER })).rejects.toMatchObject(
{ code: 'TOO_MANY_REQUESTS', message: expect.stringContaining('pending') }
);
// The count is scoped to the caller's pending offsite requests.
expect(mockDb.appListingPublishRequest.count).toHaveBeenCalledWith({
where: { submittedByUserId: CALLER, kind: 'offsite', status: 'pending' },
});
expect(mockDb.$transaction).not.toHaveBeenCalled();
});
it('per-user pending cap: UNDER the cap → allowed (draft created)', async () => {
mockDb.appListingPublishRequest.count.mockResolvedValue(MAX_PENDING_OFFSITE_SUBMISSIONS - 1);
const res = await submitExternalListing({ input: validInput, userId: CALLER });
expect(res.slug).toBe('cool-app');
expect(mockDb.$transaction).toHaveBeenCalledTimes(1);
});
it('re-asserts contentRating against the offsite enum (an out-of-set value is rejected before any write)', async () => {
await expect(
submitExternalListing({
input: { ...validInput, contentRating: 'xxx' as never },
userId: CALLER,
})
).rejects.toMatchObject({
code: 'BAD_REQUEST',
message: expect.stringContaining('content rating'),
});
expect(mockDb.$transaction).not.toHaveBeenCalled();
});
it('re-validates externalUrl (a non-https URL is rejected before any write)', async () => {
await expect(
submitExternalListing({
input: { ...validInput, externalUrl: 'http://insecure.example.com' },
userId: CALLER,
})
).rejects.toBeInstanceOf(TRPCError);
expect(mockDb.$transaction).not.toHaveBeenCalled();
});
it('rejects an unknown category (service re-checks the taxonomy)', async () => {
await expect(
submitExternalListing({
input: { ...validInput, category: 'bogus' as never },
userId: CALLER,
})
).rejects.toMatchObject({ code: 'BAD_REQUEST', message: expect.stringContaining('category') });
expect(mockDb.$transaction).not.toHaveBeenCalled();
});
it('rejects a submission declaring an on-platform surface (defense-in-depth)', async () => {
await expect(
submitExternalListing({
input: { ...validInput, iframe: { src: 'https://x.civit.ai' } } as never,
userId: CALLER,
})
).rejects.toBeInstanceOf(TRPCError);
expect(mockDb.$transaction).not.toHaveBeenCalled();
});
});
// ---------------------------------------------------------------------------
// withdrawExternalRequest
// ---------------------------------------------------------------------------
describe('withdrawExternalRequest', () => {
it('own pending → withdrawn + the draft listing is deleted (slug released)', async () => {
mockDb.appListingPublishRequest.findUnique.mockResolvedValue({
id: 'alpr_1',
status: 'pending',
submittedByUserId: CALLER,
appListingId: 'apl_1',
});
await withdrawExternalRequest({ publishRequestId: 'alpr_1', userId: CALLER });
expect(mockDb.appListingPublishRequest.updateMany).toHaveBeenCalledWith({
where: { id: 'alpr_1', status: 'pending' },
data: { status: 'withdrawn' },
});
// Draft deletion is status-guarded (never removes an approved listing).
expect(mockDb.appListing.deleteMany).toHaveBeenCalledWith({
where: { id: 'apl_1', status: 'draft' },
});
});
it('NOT_OWNED when the request belongs to another user (no write)', async () => {
mockDb.appListingPublishRequest.findUnique.mockResolvedValue({
id: 'alpr_1',
status: 'pending',
submittedByUserId: OTHER,
appListingId: 'apl_1',
});
await expect(
withdrawExternalRequest({ publishRequestId: 'alpr_1', userId: CALLER })
).rejects.toMatchObject({ code: 'NOT_OWNED' });
expect(mockDb.appListingPublishRequest.updateMany).not.toHaveBeenCalled();
expect(mockDb.appListing.deleteMany).not.toHaveBeenCalled();
});
it('NOT_FOUND when the request does not exist', async () => {
mockDb.appListingPublishRequest.findUnique.mockResolvedValue(null);
await expect(
withdrawExternalRequest({ publishRequestId: 'nope', userId: CALLER })
).rejects.toMatchObject({ code: 'NOT_FOUND' });
});
it('NOT_PENDING when the request is already approved (no write)', async () => {
mockDb.appListingPublishRequest.findUnique.mockResolvedValue({
id: 'alpr_1',
status: 'approved',
submittedByUserId: CALLER,
appListingId: 'apl_1',
});
await expect(
withdrawExternalRequest({ publishRequestId: 'alpr_1', userId: CALLER })
).rejects.toMatchObject({ code: 'NOT_PENDING' });
expect(mockDb.appListingPublishRequest.updateMany).not.toHaveBeenCalled();
});
it('idempotent: an already-withdrawn request is a no-op success (no delete)', async () => {
mockDb.appListingPublishRequest.findUnique.mockResolvedValue({
id: 'alpr_1',
status: 'withdrawn',
submittedByUserId: CALLER,
appListingId: 'apl_1',
});
await expect(
withdrawExternalRequest({ publishRequestId: 'alpr_1', userId: CALLER })
).resolves.toBeUndefined();
expect(mockDb.appListingPublishRequest.updateMany).not.toHaveBeenCalled();
expect(mockDb.appListing.deleteMany).not.toHaveBeenCalled();
});
it('TOCTOU: guarded write matches 0 rows, re-read shows withdrawn → idempotent success', async () => {
mockDb.appListingPublishRequest.findUnique
// classify read: pending
.mockResolvedValueOnce({
id: 'alpr_1',
status: 'pending',
submittedByUserId: CALLER,
appListingId: 'apl_1',
})
// re-read from primary after count 0: raced into withdrawn
.mockResolvedValueOnce({ status: 'withdrawn' });
mockDb.appListingPublishRequest.updateMany.mockResolvedValue({ count: 0 });
await expect(
withdrawExternalRequest({ publishRequestId: 'alpr_1', userId: CALLER })
).resolves.toBeUndefined();
// We did not perform the withdraw → we do NOT re-delete the draft.
expect(mockDb.appListing.deleteMany).not.toHaveBeenCalled();
});
it('TOCTOU: guarded write matches 0 rows, re-read shows approved → NOT_PENDING', async () => {
mockDb.appListingPublishRequest.findUnique
.mockResolvedValueOnce({
id: 'alpr_1',
status: 'pending',
submittedByUserId: CALLER,
appListingId: 'apl_1',
})
.mockResolvedValueOnce({ status: 'approved' });
mockDb.appListingPublishRequest.updateMany.mockResolvedValue({ count: 0 });
await expect(
withdrawExternalRequest({ publishRequestId: 'alpr_1', userId: CALLER })
).rejects.toMatchObject({ code: 'NOT_PENDING' });
expect(mockDb.appListing.deleteMany).not.toHaveBeenCalled();
});
it('the thrown error is an OffsiteRequestError with a typed code', async () => {
mockDb.appListingPublishRequest.findUnique.mockResolvedValue({
id: 'alpr_1',
status: 'pending',
submittedByUserId: OTHER,
appListingId: 'apl_1',
});
await expect(
withdrawExternalRequest({ publishRequestId: 'alpr_1', userId: CALLER })
).rejects.toBeInstanceOf(OffsiteRequestError);
});
});
@@ -0,0 +1,396 @@
import { TRPCError } from '@trpc/server';
import { dbRead, dbWrite } from '~/server/db/client';
import {
assertNoOnPlatformSurface,
validateExternalUrl,
} from '~/server/schema/blocks/external-app.schema';
import {
OFFSITE_CONTENT_RATINGS,
type SubmitExternalListingInput,
} from '~/server/schema/blocks/offsite-listing.schema';
import { isMarketplaceCategory } from '~/server/services/blocks/marketplace-categories.constants';
import { newAppListingId, newAppListingPublishRequestId } from '~/server/utils/app-block-ids';
/**
* App Store Listings (W13) P3a OFF-SITE submission service (Design B1).
*
* The author-facing submit / withdraw / my-submissions surface for a pure
* external-link off-site app, plus the mod-facing read-only review-queue lists.
* The mirror of the on-site `publish-request.service` state machine, but over the
* `AppListingPublishRequest` + `AppListing` tables (no bundle / build / deploy).
*
* DESIGN B1 (locked): `submitExternalListing` creates, in ONE transaction, a
* DRAFT `AppListing(kind='offsite', status='draft')` PLUS a `pending`
* `AppListingPublishRequest(kind='offsite', appListingId=<draft id>)`. The draft
* lets the author reuse the P1 asset CRUD (owner-gated) to attach icon/cover/
* screenshots before approval; the read path hides non-approved rows, so a draft
* never surfaces in the store. Slug-squat protection is FREE from
* `AppListing.slug @unique` (no pending-per-slug partial-unique migration).
*
* TERMINAL cleanup: `withdrawExternalRequest` DELETES the draft `AppListing`
* (releasing the slug + cascading its screenshots). Approve/reject (PR-b) are NOT
* in this PR.
*
* DARK: submit/withdraw/my-submissions are gated by `app-blocks-author` (mods +
* app-dev-testers) at the router; the queue lists are `moderatorProcedure`.
* Nothing renders any UI in PR-a.
*/
// ---------------------------------------------------------------------------
// Typed failure modes for withdrawExternalRequest (mirror WithdrawRequestError).
// ---------------------------------------------------------------------------
export type OffsiteRequestErrorCode = 'NOT_FOUND' | 'NOT_OWNED' | 'NOT_PENDING';
export class OffsiteRequestError extends Error {
readonly code: OffsiteRequestErrorCode;
constructor(code: OffsiteRequestErrorCode, message: string) {
super(message);
this.name = 'OffsiteRequestError';
this.code = code;
}
}
/** Friendly, deterministic slug-collision error (pre-check + P2002-race branch). */
function slugTakenError(slug: string): TRPCError {
return new TRPCError({ code: 'BAD_REQUEST', message: `slug "${slug}" already taken` });
}
/**
* Per-user cap on OUTSTANDING (`pending`) off-site submissions. Drafts only clear
* on withdraw/reject (no TTL), so an unbounded submit rate would let one author
* accrue orphan drafts + squat slugs; this bounds the standing count (the router
* `rateLimit` bounds the submit RATE). Mods bypass the router rate-limit but are
* still subject to this cap.
*/
export const MAX_PENDING_OFFSITE_SUBMISSIONS = 10;
// ---------------------------------------------------------------------------
// submitExternalListing (author).
// ---------------------------------------------------------------------------
export type SubmitExternalListingResult = {
listingId: string;
publishRequestId: string;
slug: string;
};
/**
* Create a DRAFT off-site listing + a pending publish request in one transaction.
*
* Owner-binding (IDOR): both the `AppListing.userId` and the
* `AppListingPublishRequest.submittedByUserId` are set from the AUTHENTICATED
* caller (`userId`) the input carries NO owner field, so a caller can never
* submit on another user's behalf.
*
* Slug collision: pre-checked against BOTH `AppListing.slug` (unique across both
* kinds) AND an existing `AppBlock.block_id` (an on-site slug), then backstopped
* inside the tx the `AppListing.slug @unique` constraint (P2002) closes the
* AppListing checkcreate race, and a PRIMARY re-read of `AppBlock.block_id`
* closes the (constraint-less) block-id replica-lag window. Every path the SAME
* friendly `slug "X" already taken`.
*
* Abuse bounds: a per-user cap on OUTSTANDING pending submissions
* ({@link MAX_PENDING_OFFSITE_SUBMISSIONS}) bounds standing orphan-draft accrual
* (drafts have no TTL); the router adds a submit-RATE limit.
*/
export async function submitExternalListing(opts: {
input: SubmitExternalListingInput;
userId: number;
}): Promise<SubmitExternalListingResult> {
const { input, userId } = opts;
// Defense-in-depth: re-run the shared URL + surface validators (this fn is
// exported and unit-tested directly, not only reached through the schema).
const url = validateExternalUrl(input.externalUrl);
if (!url.ok) throw new TRPCError({ code: 'BAD_REQUEST', message: url.error });
const surface = assertNoOnPlatformSurface({
page: input.page,
targets: input.targets,
iframe: input.iframe,
});
if (!surface.ok) throw new TRPCError({ code: 'BAD_REQUEST', message: surface.error });
if (input.category != null && !isMarketplaceCategory(input.category)) {
throw new TRPCError({ code: 'BAD_REQUEST', message: `unknown category "${input.category}"` });
}
// Re-assert the author-declared maturity against the shared enum (this fn is
// exported + unit-tested directly, so mirror the URL/surface/category re-checks
// rather than trusting the caller). Absent → the SFW `'g'` default below.
const contentRating = input.contentRating ?? 'g';
if (!(OFFSITE_CONTENT_RATINGS as readonly string[]).includes(contentRating)) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: `unknown content rating "${contentRating}"`,
});
}
// Per-user pending-submission cap: bound the standing orphan-draft count (drafts
// only clear on withdraw/reject, no TTL). At/over the cap → TOO_MANY_REQUESTS.
const pendingCount = await dbRead.appListingPublishRequest.count({
where: { submittedByUserId: userId, kind: 'offsite', status: 'pending' },
});
if (pendingCount >= MAX_PENDING_OFFSITE_SUBMISSIONS) {
throw new TRPCError({
code: 'TOO_MANY_REQUESTS',
message: `You have ${pendingCount} pending submissions (max ${MAX_PENDING_OFFSITE_SUBMISSIONS}). Withdraw one or wait for review before submitting another.`,
});
}
const slug = input.slug;
// Pre-check both the store slug (both kinds) and an on-site block id so the
// author gets a friendly error rather than a raw constraint violation.
const existingListing = await dbRead.appListing.findUnique({
where: { slug },
select: { id: true },
});
if (existingListing) throw slugTakenError(slug);
const existingBlock = await dbRead.appBlock.findFirst({
where: { blockId: slug },
select: { id: true },
});
if (existingBlock) throw slugTakenError(slug);
const listingId = newAppListingId();
const publishRequestId = newAppListingPublishRequestId();
try {
await dbWrite.$transaction(async (tx) => {
// Cross-kind block_id collision — PRIMARY re-check. The AppListing.slug
// pre-check is backstopped by its @unique (P2002), but AppBlock.block_id
// has no such constraint against AppListing, so its replica pre-check above
// has a lag window. Re-read from the PRIMARY inside the tx to close it —
// same friendly `slug "X" already taken`.
const blockOnPrimary = await tx.appBlock.findFirst({
where: { blockId: slug },
select: { id: true },
});
if (blockOnPrimary) throw slugTakenError(slug);
await tx.appListing.create({
data: {
id: listingId,
kind: 'offsite',
status: 'draft',
slug,
name: input.name,
tagline: input.tagline ?? null,
description: input.description ?? null,
category: input.category ?? null,
// Author-declared, re-asserted against the enum above; defaults to SFW
// so an omitted rating is never mature.
contentRating,
externalUrl: url.url,
// External-link sub-kind only — the OAuth-connect seam stays inert.
connectClientId: null,
// A natively-created off-site listing has no backing AppBlock.
appBlockId: null,
userId,
},
});
await tx.appListingPublishRequest.create({
data: {
id: publishRequestId,
appListingId: listingId,
kind: 'offsite',
slug,
submittedByUserId: userId,
status: 'pending',
changelog: input.changelog ?? null,
},
});
});
} catch (err) {
// Lost the check→create race (or a slug the pre-check missed): the
// AppListing.slug @unique fires P2002. Collapse to the same friendly error.
if ((err as { code?: unknown })?.code === 'P2002') throw slugTakenError(slug);
throw err;
}
return { listingId, publishRequestId, slug };
}
// ---------------------------------------------------------------------------
// withdrawExternalRequest (author) — mirror publish-request.service withdrawRequest.
// ---------------------------------------------------------------------------
/**
* Author-initiated, terminal withdrawal of their OWN pending off-site request.
* Idempotent (re-withdrawing an already-withdrawn row is a no-op success). Throws
* a typed {@link OffsiteRequestError} on a missing row, another user's row, or a
* non-`pending` row.
*
* Deletes the DRAFT `AppListing` on success (B1) so the slug is released and no
* orphan draft accrues; the delete is status-guarded (`status:'draft'`) so it can
* never remove an approved listing.
*
* CONCURRENCY (TOCTOU): the `findUnique` only CLASSIFIES; the mutation is a
* status-guarded `updateMany({ id, status:'pending' })`, so a withdraw that read
* `pending` can't clobber a row a concurrent approve flipped. If the guarded
* write matches 0 rows despite the earlier pending classification, we re-read
* from the PRIMARY and resolve: now `withdrawn` idempotent success; now
* `approved`/`rejected` NOT_PENDING. Mirrors `withdrawRequest`
* (publish-request.service.ts).
*/
export async function withdrawExternalRequest(opts: {
publishRequestId: string;
userId: number;
}): Promise<void> {
const { publishRequestId, userId } = opts;
const row = await dbRead.appListingPublishRequest.findUnique({
where: { id: publishRequestId },
select: { id: true, status: true, submittedByUserId: true, appListingId: true },
});
if (!row) {
throw new OffsiteRequestError('NOT_FOUND', `publish request ${publishRequestId} not found`);
}
if (row.submittedByUserId !== userId) {
throw new OffsiteRequestError('NOT_OWNED', 'you can only withdraw your own publish requests');
}
if (row.status === 'withdrawn') return;
if (row.status !== 'pending') {
throw new OffsiteRequestError(
'NOT_PENDING',
`cannot withdraw a request in status ${row.status}`
);
}
// Status-guarded write: only flip a STILL-`pending` row (closes the TOCTOU
// window against a concurrent approve).
const { count } = await dbWrite.appListingPublishRequest.updateMany({
where: { id: publishRequestId, status: 'pending' },
data: { status: 'withdrawn' },
});
if (count > 0) {
await deleteDraftListing(row.appListingId);
return;
}
// Raced: re-read from the PRIMARY (a replica read could be lag-stale and still
// report `pending`) to decide the authoritative outcome.
const after = await dbWrite.appListingPublishRequest.findUnique({
where: { id: publishRequestId },
select: { status: true },
});
if (!after || after.status === 'withdrawn') {
// Raced into withdrawn (or vanished) → idempotent success. The concurrent
// withdraw owns the draft cleanup, so we do NOT re-delete here.
return;
}
// Raced into approved/rejected → the not-pending guarantee, now true under
// concurrency.
throw new OffsiteRequestError(
'NOT_PENDING',
`cannot withdraw a request in status ${after.status}`
);
}
/**
* Delete a still-DRAFT off-site listing (releases the slug; cascades its
* screenshots via `onDelete: Cascade`). Status-guarded so an approved listing is
* never removed; no-op when the request had no linked listing.
*/
async function deleteDraftListing(appListingId: string | null): Promise<void> {
if (!appListingId) return;
await dbWrite.appListing.deleteMany({ where: { id: appListingId, status: 'draft' } });
}
// ---------------------------------------------------------------------------
// Read-only lists.
// ---------------------------------------------------------------------------
const submissionSelect = {
id: true,
appListingId: true,
slug: true,
status: true,
submittedAt: true,
reviewedAt: true,
rejectionReason: true,
approvalNotes: true,
changelog: true,
appListing: {
select: { name: true, externalUrl: true, category: true, contentRating: true },
},
} as const;
const submitterChip = { select: { id: true, username: true, image: true } } as const;
export type ListOffsiteRequestsOptions = { limit?: number; cursor?: string };
/**
* The caller's OWN off-site submissions, newest-first, keyset-paginated. Scoped
* to `submittedByUserId` never another user's rows.
*/
export async function listMySubmissions(
opts: { userId: number } & ListOffsiteRequestsOptions
) {
const limit = Math.min(opts.limit ?? 25, 100);
const rows = await dbRead.appListingPublishRequest.findMany({
where: { submittedByUserId: opts.userId, kind: 'offsite' },
orderBy: { submittedAt: 'desc' },
take: limit + 1,
...(opts.cursor ? { cursor: { id: opts.cursor }, skip: 1 } : {}),
select: submissionSelect,
});
const hasNext = rows.length > limit;
const items = hasNext ? rows.slice(0, limit) : rows;
return { items, nextCursor: hasNext ? items[items.length - 1].id : null };
}
/** Mod queue: pending off-site requests, oldest-first (FIFO), keyset-paginated. */
export async function listPendingOffsiteRequests(opts: ListOffsiteRequestsOptions = {}) {
const limit = Math.min(opts.limit ?? 25, 100);
const rows = await dbRead.appListingPublishRequest.findMany({
where: { status: 'pending', kind: 'offsite' },
orderBy: { submittedAt: 'asc' },
take: limit + 1,
...(opts.cursor ? { cursor: { id: opts.cursor }, skip: 1 } : {}),
select: { ...submissionSelect, submittedBy: submitterChip },
});
const hasNext = rows.length > limit;
const items = hasNext ? rows.slice(0, limit) : rows;
return { items, nextCursor: hasNext ? items[items.length - 1].id : null };
}
/** Mod history: approved off-site requests, most-recently-reviewed first. */
export async function listApprovedOffsiteRequests(opts: ListOffsiteRequestsOptions = {}) {
const limit = Math.min(opts.limit ?? 25, 100);
const rows = await dbRead.appListingPublishRequest.findMany({
where: { status: 'approved', kind: 'offsite' },
orderBy: { reviewedAt: 'desc' },
take: limit + 1,
...(opts.cursor ? { cursor: { id: opts.cursor }, skip: 1 } : {}),
select: {
...submissionSelect,
submittedBy: submitterChip,
reviewedBy: submitterChip,
},
});
const hasNext = rows.length > limit;
const items = hasNext ? rows.slice(0, limit) : rows;
return { items, nextCursor: hasNext ? items[items.length - 1].id : null };
}
/** Mod history: rejected off-site requests, most-recently-reviewed first. */
export async function listRejectedOffsiteRequests(opts: ListOffsiteRequestsOptions = {}) {
const limit = Math.min(opts.limit ?? 25, 100);
const rows = await dbRead.appListingPublishRequest.findMany({
where: { status: 'rejected', kind: 'offsite' },
orderBy: { reviewedAt: 'desc' },
take: limit + 1,
...(opts.cursor ? { cursor: { id: opts.cursor }, skip: 1 } : {}),
select: {
...submissionSelect,
submittedBy: submitterChip,
reviewedBy: submitterChip,
},
});
const hasNext = rows.length > limit;
const items = hasNext ? rows.slice(0, limit) : rows;
return { items, nextCursor: hasNext ? items[items.length - 1].id : null };
}
+176
View File
@@ -0,0 +1,176 @@
import { expect, test } from '@playwright/test';
import type { APIRequestContext } from '@playwright/test';
import { storageStatePath } from './preview-fixtures';
import { trpcMutation, trpcQuery } from './preview-trpc';
/**
* Preview-e2e: App Blocks W13 P3a OFF-SITE (external-link) submission backend.
*
* The dark author-submit mod-review-queue leg (design B1), run as `mod`:
* - `appListings.submitExternalListing` creates a DRAFT AppListing + a pending
* AppListingPublishRequest (kind='offsite') for an https target.
* - `appListings.listPendingRequests` shows the row.
* - `appListings.withdrawExternalRequest` is terminal the draft listing is
* deleted and the row leaves the pending queue.
*
* approve/reject + the store render + the Visit-anchor invariant land in PR-b/PR-c
* (with the approve service + UI), so they are NOT exercised here this PR ships
* the submission backend only.
*
* ROLE why `mod`, not `tester`: `submitExternalListing`/`withdrawExternalRequest`
* are `appDeveloperProcedure` (`app-blocks-author`) and `listPendingRequests` is
* `moderatorProcedure`. The `mod` fixture satisfies BOTH (mods are authors via the
* app-blocks-author mod floor), so like every sibling apps smoke spec
* (preview-apps-publish/-install/-marketplace/-page) the whole leg runs as mod.
* The synthetic preview `tester` fixture is in the preview-ACCESS allowlist but NOT
* the `app-blocks-author` cohort (that's the real dev-tester user ids), so it 403s
* this proc BY DESIGN; the author-gate rejection is covered by the unit router-authz
* tests, not here.
*
* GATES (Tekton `pr-smoke-test` is authoritative do NOT run browser-mode locally
* on NixOS): the `mod` fixture passes app-blocks-author (floor) + moderatorProcedure.
*
* SAFE + SELF-CLEANING (the dev DB is shared across concurrent previews):
* - The slug is per-preview (`ci-smoke-ext-<host-label>`), so two previews never
* collide on `AppListing.slug @unique`. A same-preview re-run pre-withdraws any
* leftover pending row before submitting.
* - No asset upload / approve NO Image rows, NO Tekton build, NO CF DNS.
* - We withdraw in `finally` (deletes the draft listing + releases the slug) so a
* mid-test failure leaves no draft/pending row and re-runs don't collide.
*/
const AUTHOR_ROLE = 'mod' as const;
const PREVIEW_URL = process.env.PREVIEW_URL ?? '';
// Per-preview slug so concurrent previews don't collide on AppListing.slug @unique.
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-ext-${sanitized}`.slice(0, 40).replace(/-+$/, '');
return /[a-z0-9]$/.test(slug) ? slug : `${slug}0`;
}
const SLUG = previewSlug();
const EXTERNAL_URL = 'https://example.com/ci-smoke-external-app';
type SubmitResult = { listingId: string; publishRequestId: string; slug: string };
type PendingItem = {
id: string;
slug: string;
appListingId: string | null;
appListing: { externalUrl: string | null } | null;
};
type PendingList = { items: PendingItem[]; nextCursor: string | null };
const submitInput = {
slug: SLUG,
name: 'CI Smoke — external app (P3a)',
externalUrl: EXTERNAL_URL,
tagline: 'a pure external-link app',
category: 'utility',
contentRating: 'g',
changelog: 'ci-smoke submit',
};
/** Page the oldest-first pending queue to find our row by slug (it's the newest). */
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 = await trpcQuery<PendingList>(request, 'appListings.listPendingRequests', input);
const hit = list.items.find((i) => i.slug === slug);
if (hit) return hit;
if (!list.nextCursor) break;
cursor = list.nextCursor;
}
return null;
}
/** Best-effort: withdraw any leftover pending row for this slug (self-clean). */
async function withdrawPendingForSlug(
authorRequest: APIRequestContext,
modRequest: APIRequestContext,
slug: string
): Promise<void> {
const row = await findPendingBySlug(modRequest, slug).catch(() => null);
if (row?.id) {
await trpcMutation(authorRequest, 'appListings.withdrawExternalRequest', {
publishRequestId: row.id,
}).catch(() => {});
}
}
test.describe('App Blocks P3a: off-site submit → mod queue → withdraw (mod, self-cleaning)', () => {
test.use({ storageState: storageStatePath(AUTHOR_ROLE) });
test('mod submits an external listing → appears in the pending queue → withdraw removes it', async ({
page,
playwright,
baseURL,
}) => {
// Warm page.request against the preview origin (carries the mod auth cookie;
// the trpc helpers stamp Origin/Referer for the CSRF gate).
await page.goto('/', { waitUntil: 'domcontentloaded' });
const authorRequest = page.request;
// A second mod context, to read the review queue independently of the submitter.
const modRequest = await playwright.request.newContext({
baseURL: baseURL ?? PREVIEW_URL,
storageState: storageStatePath('mod'),
});
let publishRequestId: string | null = null;
try {
// Pre-clean any leftover pending row for this preview's slug (prior crashed run).
await withdrawPendingForSlug(authorRequest, modRequest, SLUG);
// SUBMIT as mod (author via the app-blocks-author mod floor) — creates a draft AppListing + pending request.
const result = await trpcMutation<SubmitResult>(
authorRequest,
'appListings.submitExternalListing',
submitInput
);
publishRequestId = result.publishRequestId;
expect(typeof result.publishRequestId, 'submit returns a publishRequestId').toBe('string');
expect(result.slug, 'slug echoes the submission').toBe(SLUG);
// The row shows up in the MOD pending queue (kind='offsite' rows only).
const item = await findPendingBySlug(modRequest, SLUG);
expect(item, 'the submitted request appears in the mod pending queue').not.toBeNull();
expect(item!.id, 'queue row id matches the submit result').toBe(publishRequestId);
expect(
item!.appListing?.externalUrl,
'the draft listing carries the submitted https URL'
).toBe(EXTERNAL_URL);
// WITHDRAW as mod — terminal; deletes the draft listing + releases the slug.
await trpcMutation(authorRequest, 'appListings.withdrawExternalRequest', {
publishRequestId,
});
publishRequestId = null; // withdrawn — nothing left to clean in finally
// Gone from the pending queue.
const afterWithdraw = await findPendingBySlug(modRequest, SLUG);
expect(afterWithdraw, 'the withdrawn request no longer appears in the queue').toBeNull();
} finally {
// SELF-CLEAN: withdraw so no draft/pending row lingers and re-runs don't collide.
if (publishRequestId) {
await trpcMutation(authorRequest, 'appListings.withdrawExternalRequest', {
publishRequestId,
}).catch(() => {});
} else {
await withdrawPendingForSlug(authorRequest, modRequest, SLUG);
}
await modRequest.dispose();
}
});
});