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>
2026-07-06 14:46:23 -05:00
|
|
|
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 };
|
|
|
|
|
|
fix(tests): repair component-suite build death (sharp) + smoke external-listing OAuth payloads (#3317)
Two independent baseline breaks that fail on every PR preview.
COMPONENT (build death): two page-importing browser tests
(review-detail-page / review-queue-nav) drag the full server router graph
(server-side-helpers -> routers/index -> creator-shop.router ->
creator-shop.service -> `import sharp from 'sharp'`) into the browser
bundle. Next strips the server-only getServerSideProps graph from real
client builds; Vitest's browser build does not, so esbuild's optimizeDeps
scan follows the import into sharp and dies bundling its native
`require('../build/Release/sharp-*.node')` -- killing the WHOLE component
suite before any test runs. (The tests already `vi.mock`
server-side-helpers, but that is a runtime interception and can't stop the
build-time static scan.) Fix at the build level: alias `sharp` to a trivial
stub for the `component` project only (test/stubs/sharp.ts); the `unit`
project keeps real sharp. Verified: the sharp `.node` esbuild error is gone
and the affected tests build + execute.
Also unmasked by the build fix: review-detail-page.browser.test.tsx was
stale vs a page refactor -- the page now renders the review body via
`ReviewDetailView` (which owns the real trpc-backed `ReviewActionBar`),
not the old `OnsiteReviewModalBody` the test stubbed, so 2 tests threw
`trpc.useUtils is not a function`. Re-point the body stub to
`ReviewDetailView` (matches the test's stated intent -- assert shell
wiring, not re-run the action bar's own covered behaviour). 5/5 pass.
SMOKE (400 on submitExternalListing): the three preview-apps-external-*
specs predate #3227, which MERGED OAuth-connect into the single external-app
submit flow ("every external app IS an OAuth app"). `connectClientId` +
`requestedScopes` + `scopeJustifications` are now unconditionally required
for ALL external listings BY DESIGN (well-documented, well-reasoned in the
schema) -- this is intended, NOT a regression, so the source is left
untouched and the stale smoke payloads are updated. Each submit-bearing spec
now creates a throwaway owned OAuth client (`oauthClient.create`), passes its
id as `connectClientId` with an empty scope disclosure (`requestedScopes: 0`
+ `scopeJustifications: {}` -- 0 is a subset of any client ceiling; no scopes
=> no justifications), and deletes the client on cleanup (self-cleaning like
the draft + slug). Verified the new payload against the real zod schema
(passes; the old payload still fails); the end-to-end preview run
(connectClientId ownership is a service/DB check) is CI-to-confirm.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 15:44:05 -05:00
|
|
|
function submitInput(connectClientId: string) {
|
|
|
|
|
return {
|
|
|
|
|
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',
|
|
|
|
|
// W13 merged external+connect model (#3227): every external listing links the
|
|
|
|
|
// caller's OWN OAuth client. This pure external-link app discloses NO scopes,
|
|
|
|
|
// so an empty requested-scope mask (0) + empty justifications is the minimal
|
|
|
|
|
// valid connect shape (0 ⊆ any client ceiling; no scopes → no justifications).
|
|
|
|
|
connectClientId,
|
|
|
|
|
requestedScopes: 0,
|
|
|
|
|
scopeJustifications: {},
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* The W13 merged external+connect model requires every external listing to link
|
|
|
|
|
* the caller's OWN OAuth client (existence/ownership/not-app-block checked in the
|
|
|
|
|
* service). Create a throwaway client per test and delete it on cleanup —
|
|
|
|
|
* self-cleaning like the draft listing + slug.
|
|
|
|
|
*/
|
|
|
|
|
async function createConnectClient(request: APIRequestContext): Promise<string> {
|
|
|
|
|
const res = await trpcMutation<{ clientId: string }>(request, 'oauthClient.create', {
|
|
|
|
|
name: 'CI Smoke — external-listing connect client',
|
|
|
|
|
redirectUris: ['https://example.com/ci-smoke-oauth-callback'],
|
|
|
|
|
});
|
|
|
|
|
return res.clientId;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function deleteConnectClient(
|
|
|
|
|
request: APIRequestContext,
|
|
|
|
|
clientId: string | null
|
|
|
|
|
): Promise<void> {
|
|
|
|
|
if (!clientId) return;
|
|
|
|
|
await trpcMutation(request, 'oauthClient.delete', { id: clientId }).catch(() => {});
|
|
|
|
|
}
|
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>
2026-07-06 14:46:23 -05:00
|
|
|
|
|
|
|
|
/** 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;
|
fix(tests): repair component-suite build death (sharp) + smoke external-listing OAuth payloads (#3317)
Two independent baseline breaks that fail on every PR preview.
COMPONENT (build death): two page-importing browser tests
(review-detail-page / review-queue-nav) drag the full server router graph
(server-side-helpers -> routers/index -> creator-shop.router ->
creator-shop.service -> `import sharp from 'sharp'`) into the browser
bundle. Next strips the server-only getServerSideProps graph from real
client builds; Vitest's browser build does not, so esbuild's optimizeDeps
scan follows the import into sharp and dies bundling its native
`require('../build/Release/sharp-*.node')` -- killing the WHOLE component
suite before any test runs. (The tests already `vi.mock`
server-side-helpers, but that is a runtime interception and can't stop the
build-time static scan.) Fix at the build level: alias `sharp` to a trivial
stub for the `component` project only (test/stubs/sharp.ts); the `unit`
project keeps real sharp. Verified: the sharp `.node` esbuild error is gone
and the affected tests build + execute.
Also unmasked by the build fix: review-detail-page.browser.test.tsx was
stale vs a page refactor -- the page now renders the review body via
`ReviewDetailView` (which owns the real trpc-backed `ReviewActionBar`),
not the old `OnsiteReviewModalBody` the test stubbed, so 2 tests threw
`trpc.useUtils is not a function`. Re-point the body stub to
`ReviewDetailView` (matches the test's stated intent -- assert shell
wiring, not re-run the action bar's own covered behaviour). 5/5 pass.
SMOKE (400 on submitExternalListing): the three preview-apps-external-*
specs predate #3227, which MERGED OAuth-connect into the single external-app
submit flow ("every external app IS an OAuth app"). `connectClientId` +
`requestedScopes` + `scopeJustifications` are now unconditionally required
for ALL external listings BY DESIGN (well-documented, well-reasoned in the
schema) -- this is intended, NOT a regression, so the source is left
untouched and the stale smoke payloads are updated. Each submit-bearing spec
now creates a throwaway owned OAuth client (`oauthClient.create`), passes its
id as `connectClientId` with an empty scope disclosure (`requestedScopes: 0`
+ `scopeJustifications: {}` -- 0 is a subset of any client ceiling; no scopes
=> no justifications), and deletes the client on cleanup (self-cleaning like
the draft + slug). Verified the new payload against the real zod schema
(passes; the old payload still fails); the end-to-end preview run
(connectClientId ownership is a service/DB check) is CI-to-confirm.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 15:44:05 -05:00
|
|
|
let clientId: string | null = null;
|
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>
2026-07-06 14:46:23 -05:00
|
|
|
try {
|
|
|
|
|
// Pre-clean any leftover pending row for this preview's slug (prior crashed run).
|
|
|
|
|
await withdrawPendingForSlug(authorRequest, modRequest, SLUG);
|
fix(tests): repair component-suite build death (sharp) + smoke external-listing OAuth payloads (#3317)
Two independent baseline breaks that fail on every PR preview.
COMPONENT (build death): two page-importing browser tests
(review-detail-page / review-queue-nav) drag the full server router graph
(server-side-helpers -> routers/index -> creator-shop.router ->
creator-shop.service -> `import sharp from 'sharp'`) into the browser
bundle. Next strips the server-only getServerSideProps graph from real
client builds; Vitest's browser build does not, so esbuild's optimizeDeps
scan follows the import into sharp and dies bundling its native
`require('../build/Release/sharp-*.node')` -- killing the WHOLE component
suite before any test runs. (The tests already `vi.mock`
server-side-helpers, but that is a runtime interception and can't stop the
build-time static scan.) Fix at the build level: alias `sharp` to a trivial
stub for the `component` project only (test/stubs/sharp.ts); the `unit`
project keeps real sharp. Verified: the sharp `.node` esbuild error is gone
and the affected tests build + execute.
Also unmasked by the build fix: review-detail-page.browser.test.tsx was
stale vs a page refactor -- the page now renders the review body via
`ReviewDetailView` (which owns the real trpc-backed `ReviewActionBar`),
not the old `OnsiteReviewModalBody` the test stubbed, so 2 tests threw
`trpc.useUtils is not a function`. Re-point the body stub to
`ReviewDetailView` (matches the test's stated intent -- assert shell
wiring, not re-run the action bar's own covered behaviour). 5/5 pass.
SMOKE (400 on submitExternalListing): the three preview-apps-external-*
specs predate #3227, which MERGED OAuth-connect into the single external-app
submit flow ("every external app IS an OAuth app"). `connectClientId` +
`requestedScopes` + `scopeJustifications` are now unconditionally required
for ALL external listings BY DESIGN (well-documented, well-reasoned in the
schema) -- this is intended, NOT a regression, so the source is left
untouched and the stale smoke payloads are updated. Each submit-bearing spec
now creates a throwaway owned OAuth client (`oauthClient.create`), passes its
id as `connectClientId` with an empty scope disclosure (`requestedScopes: 0`
+ `scopeJustifications: {}` -- 0 is a subset of any client ceiling; no scopes
=> no justifications), and deletes the client on cleanup (self-cleaning like
the draft + slug). Verified the new payload against the real zod schema
(passes; the old payload still fails); the end-to-end preview run
(connectClientId ownership is a service/DB check) is CI-to-confirm.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 15:44:05 -05:00
|
|
|
clientId = await createConnectClient(authorRequest);
|
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>
2026-07-06 14:46:23 -05:00
|
|
|
|
|
|
|
|
// 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',
|
fix(tests): repair component-suite build death (sharp) + smoke external-listing OAuth payloads (#3317)
Two independent baseline breaks that fail on every PR preview.
COMPONENT (build death): two page-importing browser tests
(review-detail-page / review-queue-nav) drag the full server router graph
(server-side-helpers -> routers/index -> creator-shop.router ->
creator-shop.service -> `import sharp from 'sharp'`) into the browser
bundle. Next strips the server-only getServerSideProps graph from real
client builds; Vitest's browser build does not, so esbuild's optimizeDeps
scan follows the import into sharp and dies bundling its native
`require('../build/Release/sharp-*.node')` -- killing the WHOLE component
suite before any test runs. (The tests already `vi.mock`
server-side-helpers, but that is a runtime interception and can't stop the
build-time static scan.) Fix at the build level: alias `sharp` to a trivial
stub for the `component` project only (test/stubs/sharp.ts); the `unit`
project keeps real sharp. Verified: the sharp `.node` esbuild error is gone
and the affected tests build + execute.
Also unmasked by the build fix: review-detail-page.browser.test.tsx was
stale vs a page refactor -- the page now renders the review body via
`ReviewDetailView` (which owns the real trpc-backed `ReviewActionBar`),
not the old `OnsiteReviewModalBody` the test stubbed, so 2 tests threw
`trpc.useUtils is not a function`. Re-point the body stub to
`ReviewDetailView` (matches the test's stated intent -- assert shell
wiring, not re-run the action bar's own covered behaviour). 5/5 pass.
SMOKE (400 on submitExternalListing): the three preview-apps-external-*
specs predate #3227, which MERGED OAuth-connect into the single external-app
submit flow ("every external app IS an OAuth app"). `connectClientId` +
`requestedScopes` + `scopeJustifications` are now unconditionally required
for ALL external listings BY DESIGN (well-documented, well-reasoned in the
schema) -- this is intended, NOT a regression, so the source is left
untouched and the stale smoke payloads are updated. Each submit-bearing spec
now creates a throwaway owned OAuth client (`oauthClient.create`), passes its
id as `connectClientId` with an empty scope disclosure (`requestedScopes: 0`
+ `scopeJustifications: {}` -- 0 is a subset of any client ceiling; no scopes
=> no justifications), and deletes the client on cleanup (self-cleaning like
the draft + slug). Verified the new payload against the real zod schema
(passes; the old payload still fails); the end-to-end preview run
(connectClientId ownership is a service/DB check) is CI-to-confirm.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 15:44:05 -05:00
|
|
|
submitInput(clientId)
|
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>
2026-07-06 14:46:23 -05:00
|
|
|
);
|
|
|
|
|
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);
|
|
|
|
|
}
|
fix(tests): repair component-suite build death (sharp) + smoke external-listing OAuth payloads (#3317)
Two independent baseline breaks that fail on every PR preview.
COMPONENT (build death): two page-importing browser tests
(review-detail-page / review-queue-nav) drag the full server router graph
(server-side-helpers -> routers/index -> creator-shop.router ->
creator-shop.service -> `import sharp from 'sharp'`) into the browser
bundle. Next strips the server-only getServerSideProps graph from real
client builds; Vitest's browser build does not, so esbuild's optimizeDeps
scan follows the import into sharp and dies bundling its native
`require('../build/Release/sharp-*.node')` -- killing the WHOLE component
suite before any test runs. (The tests already `vi.mock`
server-side-helpers, but that is a runtime interception and can't stop the
build-time static scan.) Fix at the build level: alias `sharp` to a trivial
stub for the `component` project only (test/stubs/sharp.ts); the `unit`
project keeps real sharp. Verified: the sharp `.node` esbuild error is gone
and the affected tests build + execute.
Also unmasked by the build fix: review-detail-page.browser.test.tsx was
stale vs a page refactor -- the page now renders the review body via
`ReviewDetailView` (which owns the real trpc-backed `ReviewActionBar`),
not the old `OnsiteReviewModalBody` the test stubbed, so 2 tests threw
`trpc.useUtils is not a function`. Re-point the body stub to
`ReviewDetailView` (matches the test's stated intent -- assert shell
wiring, not re-run the action bar's own covered behaviour). 5/5 pass.
SMOKE (400 on submitExternalListing): the three preview-apps-external-*
specs predate #3227, which MERGED OAuth-connect into the single external-app
submit flow ("every external app IS an OAuth app"). `connectClientId` +
`requestedScopes` + `scopeJustifications` are now unconditionally required
for ALL external listings BY DESIGN (well-documented, well-reasoned in the
schema) -- this is intended, NOT a regression, so the source is left
untouched and the stale smoke payloads are updated. Each submit-bearing spec
now creates a throwaway owned OAuth client (`oauthClient.create`), passes its
id as `connectClientId` with an empty scope disclosure (`requestedScopes: 0`
+ `scopeJustifications: {}` -- 0 is a subset of any client ceiling; no scopes
=> no justifications), and deletes the client on cleanup (self-cleaning like
the draft + slug). Verified the new payload against the real zod schema
(passes; the old payload still fails); the end-to-end preview run
(connectClientId ownership is a service/DB check) is CI-to-confirm.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 15:44:05 -05:00
|
|
|
await deleteConnectClient(authorRequest, clientId);
|
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>
2026-07-06 14:46:23 -05:00
|
|
|
await modRequest.dispose();
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
});
|