mirror of
https://github.com/civitai/civitai.git
synced 2026-09-20 22:08:18 +08:00
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:
@@ -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 user’s 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 });
|
||||
});
|
||||
});
|
||||
@@ -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 check→create 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 };
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user